Wzorce Projektowe Inaczej – Budowniczy
Dwa słowa wstępu
Pomysł na artykuły z serii „Wzorce Projektowe Inaczej” zrodził się w momencie, gdy gdzieś na Twitterze przemknął mi link do Record Buildera. Nie wiem, czy to frustracja miałkością pomysłu, czy pokręconą dyskusją, którą można skrócić do „ale to jest w Lomboku” natchnęły mnie do pisania.
Chciałbym przedstawić wam kilka moich uwag i rozważań dotyczących wzorców projektowych. Zapewne większość nie jest odkrywcza, ale może dla kogoś będą miały wartość… albo się kompletnie mylę i będzie na to wzięcie. Nie chodzi mi tylko o wzorce, które obecnie są trochę wykpiwane, trochę zapomniane, będzie też o narzędziach, które wyrosły dookoła. Będzie o tym, czego mi w nich brakuje i trochę o różnicach.
Bob Budowniczy, wszystko rozpierniczy
Na początek Budowniczy W. Dlaczego? A bo od niego wszystko się zaczęło :)
Degeneracja wzorca
Jak otworzycie powyższy link do wikipedii, to możecie tam przeczytać:
Dzięki takiemu rozwiązaniu możliwe jest tworzenie różnych reprezentacji obiektów w tym samym procesie konstrukcyjnym: sposób tworzenia obiektów zamknięty jest w oddzielnych obiektach zwanych Konkretnymi Budowniczymi.
Jednocześnie patrząc na to, co oferują nam współczesne narzędzia, to mamy problem. Zazwyczaj budowniczy/builder jest implementowany w jeden sposób. Co prawda możemy taki wygenerowany kod zmienić, ale kto to robi? W dodatku wspomniane wcześniej Record Builder i Lombok nie generują kodu, który jest dla nas łatwodostępny, a tym bardziej łatwy w modyfikacji.
Listing 1. Prosty rekord z builderem z lomboka
@Builder
public record Person(String name, int age) {
}
Powyższy kod wykorzystuje lomboka. Jeżeli chcemy, żeby IntelliJ wygenerował nam odpowiednik, to otrzymamy:
Listing 2. Builder z IntelliJ
class IdeaBuilder {
private String name;
private int age;
public IdeaBuilder() {
}
public IdeaBuilder name(String val) {
name = val;
return this;
}
public IdeaBuilder age(int val) {
age = val;
return this;
}
public Person build() {
return new Person(name, age);
}
}
Tu uwaga – nazwy klas zmieniam tak, żeby było jasne, skąd pochodzi dany kod lub co, mniej więcej, robi. Bez tego wylądujemy z setką tak samo nazwanych klas. A tego chcę uniknąć.
Oczywiście są pewne różnice w użyciu tych builderów:
Listing 3. Przykłąd użycia
class PersonService {
public Person buildPerson() {
return Person.builder().age(39).name("Koziołek").build();
}
public Person buildPersonWithIdea() {
return new IdeaBuilder().age(39).name("Koziołek").build();
}
}
Lombok wygeneruje metodę statyczną builder
, IntelliJ podsunie konstruktor. Jednak żaden z nich nie da nam zbyt dużo swobody w tworzeniu kodu
wynikowego. Zawsze będzie to prosty zestaw setterów i wywołanie konstruktora. Ja jednak chciałbym odrobinę więcej. Na przykład możliwość budowania
kodu z „opóźnieniem”. Stwórzmy więc leniwą implementację. Tu automaty już się poddają:
Listing 4. Leniwa implementacja
class LazyBuilder {
private String name;
private Integer age;
private Supplier<String> nameSupplier;
private Supplier<Integer> ageSupplier;
public LazyBuilder() {
}
public LazyBuilder name(String val) {
name = val;
return this;
}
public LazyBuilder name(Supplier<String> val) {
Objects.nonNull(val);
nameSupplier = val;
return this;
}
public LazyBuilder age(int val) {
age = val;
return this;
}
public LazyBuilder age(Supplier<Integer> val) {
Objects.nonNull(val);
ageSupplier = val;
return this;
}
public Person build() {
return new Person(
get(name, nameSupplier),
get(age, ageSupplier)
);
}
private <T> T get(T value, Supplier<T> supplier) {
return value != null ? value : (supplier != null ? supplier.get() : null);
}
}
Kod może wydawać się lekko zagmatwany, ale mamy tu dwa założenia:
- jeżeli ustawiono wartość, to jest ona ważniejsza niż ta z
supplier
a. - jeżeli ustawiamy
supplier
, to nie może byćnull
.
To powoduje, że mamy taką brzydką if-ologię w metodzie get
. Tu napisana jest tak żeby „oszczędzać znaki”. Użycie jest podobne do poprzedniego, ale mamy
możliwość zdecydowania, czy używamy wartości, czy pozostawiamy ją do wyliczenia na samym końcu:
Listing 5. I jej użycie
class PersonService {
public Person buildPersonWithSupplier() {
return new LazyBuilder().age(39).name(() -> "Koziołek").build();
}
}
I to jest jedna rzecz, której mi brakuje w istniejących rozwiązaniach. Generowania kodu, który potrafi przyjąć jakąś funkcję, tu Supplier
, jako
źródło danych. Czy nie lepiej napisać:
Listing 6. Delegaty
class PersonService {
private AgeService ageService;
private NameService nameService;
public Person buildPersonWithDelegation() {
return new LazyBuilder().age(ageService::get).name(nameService::get).build();
}
}
Problem polega na tym, że narzędzia zdegenerowały nam wzorzec do „jedynej słusznej implementacji”, co spowodowało, że zatracamy umiejętność
wykorzystania go w bardziej pogmatwanych przypadkach. Na przykład, gdy musimy czekać na jakiś zewnętrzny serwis, to zwykły @Builder
, czy ten
wygenerowany przez IDE traci sens. Zamykamy go w jakiś CompletableFuture
i jest smutno:
Listing 7. Użycie CompletableFuture
class PersonService {
@SneakyThrows
public Person newPersonInFuture() {
return CompletableFuture.supplyAsync(nameService::calcualte)
.thenCombine(CompletableFuture.supplyAsync(ageService::calcualte),
(n, a) -> Person.builder().name(n).age(a).build()
).join();
}
}
Offtopic – użycie join
Tak wiem, tak się nie robi, bo to blokujące, ale w końcu to ktoś, gdzieś woła o ten wynik. Frameworki bardzo ładnie to opakowują i ukrywają przed
naszymi oczami. W przypadku spring-webflux zazwyczaj robi to netty. Gdzie dokładnie… nie wiem (ale grzebię). W naszym przypadku jednak sam
wołam join
, żeby wszystko ładnie „się produkowało”. W praktyce można zwracać Future
albo Mono
i niech użytkownik się martwi.
Implementuj nie generuj
Wracając do problemu, spróbujmy zaimplementować builder, który będzie odpowiednikiem powyższej metody newPersonInFuture
. Przyjmuję, że implementacja
będzie opierać się o Supplier
i nie będzie miała żadnej „zbyt pokręconej” logiki.
Listing 8. Trochę bardziej złożone wykorzystanie
class FutureBuilder {
private Supplier<String> nameSupplier;
private Supplier<Integer> ageSupplier;
public FutureBuilder() {
}
public FutureBuilder name(Supplier<String> val) {
Objects.nonNull(val);
nameSupplier = val;
return this;
}
public FutureBuilder age(Supplier<Integer> val) {
Objects.nonNull(val);
ageSupplier = val;
return this;
}
public Person build() {
return buildAsync().join();
}
public CompletableFuture<Person> buildAsync() {
return toComparableFuture(nameSupplier)
.thenCombine(toComparableFuture(ageSupplier),
Person::new
);
}
private <T> CompletableFuture<T> toComparableFuture(Supplier<T> supplier) {
return supplier != null ? CompletableFuture.supplyAsync(supplier) : CompletableFuture.completedFuture((T) null);
}
}
Jak widać mamy tutaj stosunkowo prosty kod, którego jednak „nikt nie generuje”. I to trochę mnie boli, ponieważ jak pisałem, zdajac się na generatory tracimy pełną moc, jaką dają nam wzorce.
A teraz dodaj pole cwaniaku
Ok… tu wychodzi największa zaleta generatorów. Jeżeli zamienię mój rekord na coś takiego
Listing 8. Nowy, lepszy rekord
@Builder
public record ExtendedPerson(String name, int age, String email) {
}
To zarówno lombok, jak i IDE bardzo zgrabnie poradzą sobie z nowym polem. Jeżeli jednak musimy zrobić, to z palca to zaczyna się klepanie po klawiaturze. Choć też nie do końca. Przykładowo ręczna implementacja leniwego buildera będzie wyglądać tak:
Listing 9. Użycie z nowymi polami
class ExtendedLazyBuilder {
private String name;
private Integer age;
private String email;
private Supplier<String> nameSupplier;
private Supplier<Integer> ageSupplier;
private Supplier<String> emailSupplier;
public ExtendedLazyBuilder() {
}
public ExtendedLazyBuilder name(String val) {
name = val;
return this;
}
public ExtendedLazyBuilder name(Supplier<String> val) {
Objects.nonNull(val);
nameSupplier = val;
return this;
}
public ExtendedLazyBuilder age(int val) {
age = val;
return this;
}
public ExtendedLazyBuilder age(Supplier<Integer> val) {
Objects.nonNull(val);
ageSupplier = val;
return this;
}
public ExtendedLazyBuilder email(String val) {
email = val;
return this;
}
public ExtendedLazyBuilder email(Supplier<String> val) {
Objects.nonNull(val);
emailSupplier = val;
return this;
}
public ExtendedPerson build() {
return new ExtendedPerson(
get(name, nameSupplier),
get(age, ageSupplier),
get(email, emailSupplier)
);
}
private <T> T get(T value, Supplier<T> supplier) {
return value != null ? value : (supplier != null ? supplier.get() : null);
}
}
A jak podacie przykład nawet z jednym polem, to ChatGPT wygeneruje wam resztę kodu. Serio. Przetestowałem i wynik jest identyczny. Problem zaczyna się
w przypadku naszego FutureBuiler
a. ChatGTP poległ, bo API języka nie pozwala na połączenie więcej niż dwóch w CompletableFuture
za pomocą
metody thenCombine
. Zacznijmy od bardzo prostej implementacji:
Listing 10. I nowa wersja z CompletableFuture
class ExtendedFutureBuilder {
private CompletableFuture<String> nameSupplier;
private CompletableFuture<Integer> ageSupplier;
private CompletableFuture<String> emailSupplier;
public ExtendedFutureBuilder() {
}
public ExtendedFutureBuilder name(Supplier<String> val) {
Objects.nonNull(val);
nameSupplier = toComparableFuture(val);
return this;
}
public ExtendedFutureBuilder age(Supplier<Integer> val) {
Objects.nonNull(val);
ageSupplier = toComparableFuture(val);
return this;
}
public ExtendedFutureBuilder email(Supplier<String> val) {
Objects.nonNull(val);
emailSupplier = toComparableFuture(val);
return this;
}
public ExtendedPerson build() {
return asyncBuild().join();
}
public CompletableFuture<ExtendedPerson> asyncBuild() {
return nameSupplier
.thenCombine(
ageSupplier, (name, age) -> Tuple.of(name, age)
).thenCombine(
emailSupplier, (t, email) -> new ExtendedPerson(t._1(), t._2(), email)
);
}
private <T> CompletableFuture<T> toComparableFuture(Supplier<T> source) {
return CompletableFuture.supplyAsync(source);
}
}
Metoda asyncBuild
jest brzydka. Wraz ze wzrostem ilości pól będzie rosła arność użytej krotki, aż w końcu polegniemy. Można wykorzystać mapę. To
rozwiąże problem, choć nie do końca. Spróbujmy przepisać metodę asyncBuild
, tak, żeby była mniej zależna od liczby pól:
Listing 11. Kombinujemy z CompletableFuture
class ExtendedFutureBuilder {
public CompletableFuture<ExtendedPerson> asyncBuild() {
return CompletableFuture.allOf(
nameSupplier,
ageSupplier,
emailSupplier
).thenApply(
v -> new ExtendedPerson(
nameSupplier.resultNow(),
ageSupplier.resultNow(),
emailSupplier.resultNow()
)
);
}
}
Metoda allOf
stworzy nam nowy CompletableFuture
, który będzie gotowy tylko wtedy, gdy wszystkie składowe będą gotowe. Dlatego też możemy
użyć resultNow
, w kolejnym kroku. Wiemy, że wszystkie zadania już się zakończyły.
Uwaga! Pomijam obsługę błędów, bo to trochę inny temat i by tylko zagmatwał kod.
Podsumowanie
Wzorzec budowniczego to nie tylko jedna adnotacja, czy kod wygenerowany przez IDE. Jeżeli ograniczymy się do takiego podejścia, to równie dobrze możemy napisać:
Listing 12. Skrajne uproszenie – brak budowniczego
class PersonService {
private AgeService ageService = new AgeService();
private NameService nameService = new NameService();
public Person manually() {
String name = nameService.get();
int age = ageService.get();
return new Person(name, age);
}
}
Zatem nie zapominajmy, o tym, bo w ten sposób sami siebie ograniczamy.