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:

  1. jeżeli ustawiono wartość, to jest ona ważniejsza niż ta z suppliera.
  2. 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 FutureBuilera. 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.