Usuń boilerplate – Lombok vs AutoValue vs Immutables

blog.jaszczyk.net 3 lat temu

Lombok, AutoValue oraz Immutables to trzy najpopularniejsze biblioteki pomagające pozbyć się boilerplate code.

W tym artykule dowiesz się jakie są między nimi główne różnice, ich zalety oraz wady, które pozwolą ci na świadomy wybór jednego z powyższych narzędzi.

Na końcu znajdziesz podsumowanie, które porównuje wybrane cechy poszczególnych bibliotek.

Jak pozbyć się boilerplate’u?

Przyjrzyjmy się jak wygląda użycie poszczególnych bibliotek w przypadku jednego z bardziej typowych przypadków czyli stworzenie niemutowalnej klasy z builderem.

Użyję domyślnych ustawień oraz sprawdzę jak biblioteki radzą sobie z optionalami, listami i niestandardowymi typami.

Lombok vs boilerplate

Niestety do prawidłowej pracy z tą biblioteką, musimy zainstalować specjalny plugin w naszym IDE (w moim przypadku Intellij).

Na szczęście po tym kroku konfiguracja klasy jest banalna.

@Builder @Value public class LombokClass { int primitive; List<String> list; Optional<Integer> optional; CustomClass customClass; }

Z uwagi, iż Lombok wykorzystuje pola w klasie do generowania odpowiednich metod. o ile chcemy, aby metoda zwracała Optional, musimy zadeklarować pole tego typu co może być mało eleganckie.

Dzięki dodaniu odpowiednich adnotacji możemy stworzyć nasz nowy obiekt w poniższy sposób.

LombokClass lombokClass = LombokClass.builder() .optional(Optional.of(1)) .list(list) .primitive(2) .customClass(customClass) .build();

Jedyna niedogodność to taka, iż dla naszego buildera z optionalem, nie możemy po prostu przekazać wartości, tylko musimy ją zawsze opakować w optionala.

Sprawdźmy teraz niemutowalność naszych obiektów poprzez stworzenie nowego obiektu z identycznymi wartościami, a następnie zmodyfikowanie pierwotnej listy.

@Test void lombokImmutabilityTest() { List<String> list = new ArrayList<>(); list.add("FirstElement"); CustomClass customClass = new CustomClass(10, list, 2.4); LombokClass lombokClass = LombokClass.builder() .optional(Optional.of(1)) .list(list) .primitive(2) .customClass(customClass) .build(); List<String> list2 = List.of("FirstElement"); LombokClass lombokClass2 = LombokClass.builder() .optional(Optional.of(1)) .list(list2) .primitive(2) .customClass(customClass) .build(); assertEquals(lombokClass, lombokClass2); list.add("SecondElement"); assertEquals(lombokClass, lombokClass2); }

Niestety druga asercja kończy się niepowodzeniem z następującym błędem:

Expected :LombokClass(primitive=2, list=[FirstElement, SecondElement], optional=Optional[1], customClass=net.jaszczyk.boilerplate.CustomClass@56620197) Actual :LombokClass(primitive=2, list=[FirstElement], optional=Optional[1], customClass=net.jaszczyk.boilerplate.CustomClass@56620197)

Wynika z tego, iż Lombok korzysta z przekazanej referencji listy bez tworzenia twardej kopii. Musimy o tym pamiętać o ile zależy nam na niemutowalności.

AutoValue vs boilerplate

W tym wypadku na szczęście nie potrzebujemy żadnych dodatkowych wtyczek.

Definicja naszej przykładowej klasy wygląda następująco.

@AutoValue public abstract class AutoValueClass { abstract int primitive(); abstract List<String> list(); abstract Optional<Integer> optional(); abstract CustomClass customClass(); static Builder builder() { return new AutoValue_AutoValueClass.Builder(); } @AutoValue.Builder abstract static class Builder { abstract Builder primitive(int value); abstract Builder list(List<String> value); abstract Builder optional(Optional<Integer> value); abstract Builder customClass(CustomClass value); abstract AutoValueClass build(); } }

Niestety AutoValue domyślnie nie tworzy nam buildera i musimy jawnie go stworzyć w nieco uproszczony sposób. Mimo, iż jest to w pewien sposób udogodnienie w porównaniu do manualnego tworzenia buildera, jednak musimy napisać zdecydowanie więcej kodu w porównaniu do Lomboka.

Tworzenie nowego obiektu wygląda identycznie jak poprzednio.

AutoValueClass autoValueClass = AutoValue_AutoValueClass.builder() .optional(Optional.of(1)) .list(list) .primitive(2) .customClass(customClass) .build();

Podobnie jak w Lomboku nie mamy wygodnej funkcjonalności do przekazywania wartości zamiast całego optionala. Jednak z uwagi, iż manualnie definiujemy buildera, możemy dodać do niego dodatkową metodę.

@AutoValue.Builder abstract static class Builder { abstract Builder primitive(int value); abstract Builder list(List<String> value); abstract Builder optional(Optional<Integer> value); abstract Builder optional(int value); abstract Builder customClass(CustomClass value); abstract AutoValueClass build(); }

Należy uważać w przypadku kiedy argumentem takiej metody jest obiekt, ponieważ wygenerowany kod tworzy za nas optionala, ale dzięki metody of, która w przypadku nulla rzuci NullPointerException.

@Override AutoValueClass.Builder optional(int optional) { this.optional = Optional.of(optional); return this; }

Wykonajmy teraz test na niemutowalność, analogicznie jak w poprzednim przypadku.

@Test void autoValueImmutabilityTest() { List<String> list = new ArrayList<>(); list.add("FirstValue"); CustomClass customClass = new CustomClass(10, list, 2.4); AutoValueClass autoValueClass = AutoValue_AutoValueClass.builder() .optional(Optional.of(1)) .list(list) .primitive(2) .customClass(customClass) .build(); List<String> list2 = List.of("FirstValue"); AutoValueClass autoValueClass2 = AutoValue_AutoValueClass.builder() .optional(Optional.of(1)) .list(list2) .primitive(2) .customClass(customClass) .build(); assertEquals(autoValueClass, autoValueClass2); list.add("SecondValue"); assertEquals(autoValueClass, autoValueClass2); }

Również w tym wypadku test kończy się niepowodzeniem w tym samym miejscu.

Immutables vs boilerplate

Podobnie jak w przypadku AutoValue, również przy tej bibliotece nie potrzebujemy dodatkowych wtyczek.

Konfiguracja klasy jest równie prosta jak w przypadku Lomboka.

@Value.Immutable public interface ImmutablesClass { int primitive(); List<String> list(); Optional<Integer> optional(); CustomClass customClass(); }

Możemy do tego celu użyć zarówno klasy abstrakcyjnej jak i interfejsu.

Zobaczmy jak wygląda tworzenie nowego obiektu.

ImmutablesClass immutablesClass = ImmutableImmutablesClass.builder() .optional(1) .list(list) .primitive(2) .customClass(customClass) .build();

Immutables generuje nam w builderze zarówno metodę przyjmującą Optional jak i samą wartość. Podobnie jak w poprzednim przypadku tutaj również musimy uważać, aby jako wartości nie przekazywać nulla, ponieważ wygenerowany kod dla obiektu używa Objects.requireNonNull.

Zobaczmy jak z niemutowalnością radzi sobie nasza ostatnia biblioteka.

@Test void immutablesImmutabilityTest() { List<String> list = new ArrayList<>(); list.add("FirstElement"); CustomClass customClass = new CustomClass(10, list, 2.4); ImmutablesClass immutablesClass = ImmutableImmutablesClass.builder() .optional(1) .list(list) .primitive(2) .customClass(customClass) .build(); List<String> list2 = List.of("FirstElement"); ImmutableImmutablesClass immutablesClass2 = ImmutableImmutablesClass.builder() .optional(1) .list(list2) .primitive(2) .customClass(customClass) .build(); assertEquals(immutablesClass, immutablesClass2); list.add("SecondValue"); assertEquals(immutablesClass, immutablesClass2); }

W tym wypadku test zakończył się powodzeniem. Oznacza to, iż Immutables gwarantuje nam niemutowalność obiektu choćby w przypadku użycia modyfikowalnej kolekcji.

Przyglądając się wygenerowanemu kodowi rzeczywiście widzimy, iż przekazując kolekcję, każdy jej element jest po kolei kopiowany zamiast przypisywać jej referencję.

public final Builder list(Iterable<String> elements) { this.list.clear(); return addAllList(elements); } public final Builder addAllList(Iterable<String> elements) { for (String element : elements) { this.list.add(Objects.requireNonNull(element, "list element")); } return this; }

Kompatybilność z Jacksonem

Jackson to jedna z najpopularniejszych bibliotek służących do serializacji oraz deserializacji obiektów do/z JSONa.

Sprawdźmy jak poszczególne biblioteki sobie z tym radzą korzystając z naszej przykładowej klasy dla każdej z nich.

Lombok

W pierwszej kolejności sprawdźmy jak Lombok radzi sobie z serializacją do JSONa.

@Test void lombokJsonSerializationTest() throws JsonProcessingException { List<String> list = List.of("FirstElement"); CustomClass customClass = new CustomClass(10, list, 2.4); LombokClass lombokClass = LombokClass.builder() .optional(Optional.of(1)) .list(list) .primitive(2) .customClass(customClass) .build(); ObjectMapper objectMapper = new ObjectMapper() .registerModule(new Jdk8Module()); String json = objectMapper.writeValueAsString(lombokClass); assertEquals(json, JSON_STRING); }

Powyższy test przechodzi bez problemu. Bez modyfikacji naszego pierwotnego kodu jesteśmy w łatwy sposób w stanie serializować nasz obiekt do JSONa.

Sprawdźmy czy równie dobrze poradzi sobie z deserializacją dodając poniższą asercję do naszego testu.

LombokClass lombokClassFromJson = objectMapper.readValue(JSON_STRING, LombokClass.class); assertEquals(lombokClass, lombokClassFromJson);

Niestety w tym wypadku dostajemy poniższy exception.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `net.jaszczyk.boilerplate.LombokClass` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator) at [Source: (String)"{"primitive":2,"list":["FirstElement"],"optional":1,"customClass":{"id":10,"names":["FirstElement"],"value":2.4}}"; line: 1, column: 2]

Z uwagi na wykorzystanie buildera, Jackson nie jest w stanie stworzyć naszej klasy, co jest naturalne. Wystarczy jednak dodać nieco konfiguracji, aby nasz builder tworzył się z odpowiednią nazwą oraz, aby Jackson korzystał z metod bez prefixu with do deserializacji.

Poniżej zmodyfikowana klasa, która działa bez problemu w przypadku naszego testu.

@Builder(builderClassName = "LombokClassBuilder", toBuilder = true) @Value @JsonDeserialize(builder = LombokClass.LombokClassBuilder.class) public class LombokClass { int primitive; List<String> list; Optional<Integer> optional; CustomClass customClass; @JsonPOJOBuilder(withPrefix = "") public static class LombokClassBuilder { } }

AutoValue

Spróbujmy serializować obiekt utworzony dzięki AutoValue do JSONa.

@Test void autoValueJsonSerializationTest() throws JsonProcessingException { List<String> list = new ArrayList<>(); list.add("FirstElement"); CustomClass customClass = new CustomClass(10, list, 2.4); AutoValueClass autoValueClass = AutoValue_AutoValueClass.builder() .optional(Optional.of(1)) .list(list) .primitive(2) .customClass(customClass) .build(); ObjectMapper objectMapper = new ObjectMapper() .registerModule(new Jdk8Module()); String json = objectMapper.writeValueAsString(autoValueClass); assertEquals(json, JSON_STRING); }

Niestety z uwagi, iż AutoValue korzysta z metod a nie pól do definiowania klasy, Jackson nie jest wstanie serializować obiektu tej klasy do JSONa. Skutkuje to poniższym błędem.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class net.jaszczyk.boilerplate.AutoValue_AutoValueClass and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)

Na szczęście wystarczy oznaczyć metody adnotacją @JsonProperty i test przechodzi bez problemu.

Zobaczmy jakich modyfikacji musimy dokonać dodając do testu deserializację.

AutoValueClass autoValueClassFromJson = objectMapper.readValue(JSON_STRING, AutoValue_AutoValueClass.class); assertEquals(autoValueClass, autoValueClassFromJson);

Podobnie jak w przypadku Lomboka, Jackson nie jest w stanie stworzyć naszego obiektu z uwagi na buildera. Wystarczy wskazać jako metodę deserializacji builder oraz oznaczyć metody buildera adnotacją @JsonProperty

@AutoValue @JsonDeserialize(builder = AutoValue_AutoValueClass.Builder.class) public abstract class AutoValueClass { @JsonProperty abstract int primitive(); @JsonProperty abstract List<String> list(); @JsonProperty abstract Optional<Integer> optional(); @JsonProperty abstract CustomClass customClass(); static Builder builder() { return new AutoValue_AutoValueClass.Builder(); } @AutoValue.Builder abstract static class Builder { @JsonProperty abstract Builder primitive(int value); @JsonProperty abstract Builder list(List<String> value); @JsonProperty abstract Builder optional(Optional<Integer> value); @JsonProperty abstract Builder customClass(CustomClass value); abstract AutoValueClass build(); } }

Immutables

Wykonajmy nasz test serializujący do JSONa również dla Immutables.

@Test void immutablesJsonSerializationTest() throws JsonProcessingException { List<String> list = new ArrayList<>(); list.add("FirstElement"); CustomClass customClass = new CustomClass(10, list, 2.4); ImmutablesClass immutablesClass = ImmutableImmutablesClass.builder() .optional(1) .list(list) .primitive(2) .customClass(customClass) .build(); ObjectMapper objectMapper = new ObjectMapper() .registerModule(new Jdk8Module()); String json = objectMapper.writeValueAsString(immutablesClass); assertEquals(json, JSON_STRING); }

Analogicznie jak w przypadku AutoValue, Jackson nie jest w stanie rozpoznać pól po samych metodach.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class net.jaszczyk.boilerplate.ImmutableImmutablesClass and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)

Co ciekawe, możemy po prostu oznaczyć te metody adnotacją @JsonProperty, ale lepszym sposobem jest dodanie @JsonDeserialize(as = ImmutableImmutablesClass.class) co również sprawia, iż obiekt serializuje się poprawnie.

Dodajmy do testu asercję związaną z deserializacją.

ImmutablesClass immutablesClassFromJson = objectMapper.readValue(JSON_STRING, ImmutableImmutablesClass.class); assertEquals(immutablesClass, immutablesClassFromJson);

Dzięki wskazaniu klasy do deserializacji, nasz obiekt utworzył się bez żadnego problemu na podstawie JSONa.

@Value.Immutable @JsonDeserialize(as = ImmutableImmutablesClass.class) public interface ImmutablesClass { int primitive(); List<String> list(); Optional<Integer> optional(); CustomClass customClass(); }

Co jeszcze potrafią

Poza przykładową konfiguracją każda z bibliotek ma również szereg innych funkcjonalności. Poniżej przybliżę najprzydatniejsze z nich.

Lombok

Lombok dostarcza wiele funkcjonalności, które możemy użyć praktycznie w każdym miejscu naszego kodu.

Poniżej lista według mnie najbardziej użytecznych, pozostałe funkcjonalności może znaleźć na stronie Project Lombok.

  • val – to samo co var z Java 10, tylko finalne!
  • @Cleanup – wywoła close() na obiekcie do którego dodamy tą adnotację przed końcem jej zasięgu. interesująca alternatywa dla try-with-resources
  • @Getter/@Setter – generowanie getterów i setterów
  • @EqualsAndHashCode – generowanei equals i hashCode na podstawie pól
  • @NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor – generowanie konstruktorów
  • @Data – alias dla @ToString, @EqualsAndHashCode, @Getter, @RequiredArgsConstructor i @Setter dla modyfikowalnych pól
  • @Value – sprawia, iż klasa jest niemutowalna.
  • @Builder – generuje buildera dla naszej klasy.
  • @With – modyfikowanie pól poprzez tworzenie nowej instancji klasy. Przydatne przy niemutowalnych klasach.
  • @Getter(lazy=true) – pobiera wartość dopiero przy próbie wywołania i zapisuje wynik w pamięci.

AutoValue

Dodając adnotację @AutoValue zostanie utworzona klasa o nazwie AutoValue_NazwaKlasy z utworzonym konstruktorem, getterami, toString, hashCode oraz equals. Dodatkowo tak jak w naszym przykładzie możemy również zdefiniować builder.

AutoValue nie zawiera innych funkcjonalności, jednak daje możliwość rozszerzania go dzięki dodatkowych bibliotek. Przykładową listę rozszerzeń dla AutoValue, możecie np. znaleźć na repozytorium Awesome AutoValue Extensions.

Immutables

Domyślnie Immutables generujemy nam szereg metody pomocniczych, jak chociażby możliwość dodawania pojedynczej wartości do listy w builderze, czy możliwość tworzenia nowych obiektów na bazie istniejących.

Biblioteka ta opiera się głównie o tworzenie niemutowalnych obiektów i zapewnia szereg ustawień konfiguracyjnych, które pozwalają dostosować wygenerowany kod do naszych potrzeb.

Poza niemutowalnymi klasami w wyjątkowych wypadkach możemy również zastosować adnotację @Value.Modifiable, która pozwoli nam na modyfikację tak utworzonych obiektów.

Opcji konfiguracyjnych jest cała masa, jednak z uwagi, iż w większości przypadków wystarczą nam domyślne ustawienia, po pełną ich listę zapraszam na stronę projektu Immutables.

Popularność

Project Lombok to zdecydowanie najbardziej popularna biblioteka spośród tej trójki.

Project Lombok na githubie

Nie tylko ma najwięcej gwiazdek, forków i obserwatorów, ale również najwięcej artykułów. Wyszukując w google frazę java boilerplate code library pierwsze dwie strony to praktycznie same informacje o Lomboku. Jest to duży plus, ponieważ łatwo możemy znaleźć przykłady użycia czy odpowiedzi na nasze problemy.

W drugiej kolejności z niewielką różnicą znajduje się AutoValue.

AutoValue na githubie

Nadal znajdziemy wiele artykułów na temat tej biblioteki, ale tutaj jednak musimy bardziej polegać na szukaniu po nazwie.

Na końcu, biorąc pod uwagę statystyki prawie, iż niszowa biblioteka – Immutables.

Immutables na githubie

Oczywiście znajdziemy wiele artykułów na temat tej biblioteki, jednak jest ich zdecydowanie mniej. Jest to poniekąd spowodowane dość niefortunną nazwą, która jest powszechnie używana w programowaniu.

Który najlepiej usuwa boilerplate?

Poniżej krótkie porównanie najbardziej według mnie istotnych cech tych bibliotek.

zielony plus – biblioteka w pełni wspiera funkcjonalność

żółty trójkąt – biblioteka częściowo wspiera funkcjonalność lub wymaga dodatkowej konfiguracji

czerwone koło – biblioteka nie spełnia funkcjonalności

LombokAutoValueImmutables
Nie wymaga pluginów
Wbudowany builder
Tworzenie niemutowalnych klas
Tworzenie mutowalnych klas
Serializacja do JSONa
Deserializacja z JSONa
Możliwości konfiguracyjne
Generuje metody pomocnicze
Dodatkowe funkcjonalności
Popularność

Każda z opisywanych bibliotek ma swoje wady oraz zalety i sprawdzi się w innych okolicznościach.

Jeżeli potrzebujemy prostoty i nie zależy nam na dodatkowych funkcjonalnościach poza budowaniem niemutowalnych obiektów to AutoValue będzie idealnym wyborem.

W przypadku o ile poza udogodnieniami przy tworzeniu klas chcemy dodać też trochę dodatkowych funkcjonalności w naszym projekcie oraz nie przeszkadza nam korzystanie z dodatkowych pluginów, to Lombok świetnie się sprawdzi.

Natomiast o ile naszym celem jest budowanie niemutowalnych klas wraz z możliwością zaawansowanej kontroli i konfiguracji w jaki sposób ma to być realizowane to strzałem w dziesiątkę będzie Immutables.

W mojej ocenie najbardziej użyteczną biblioteką z tych trzech jest Immutables. Gwarantuje niemutowalność choćby w przypadku obsługiwania kolekcji, a także generuje nam szereg dodatkowych metod pomagających w używaniu tak wygenerowanych obiektów. Mimo popularności Lomboka, jego główną wadą jest konieczność korzystania z dodatkowego pluginu oraz to, iż bezpośrednio modyfikuje naszą klasę wynikową. Dzięki temu, iż Immutables generuje nową klasę, która rozszerza/implementuje bazową, kod jest bardziej przewidywalny i czytelniejszy.

Warto jednak zawsze się zastanowić jakich funkcjonalności potrzebujemy w naszym projekcie i na tej podstawie zdecydować, której biblioteki użyć. Wszystkie trzy są godne polecenie i warto z nich korzystać.

Daj znać w komentarzu z której biblioteki korzystasz i dlaczego.

Idź do oryginalnego materiału