Wzorzec projektowy Builder (Budowniczy) to jeden ze wzorców projektowych opisanych przez Gang of Four. Został przez nich zakwalifikowany do kategorii wzorców kreacyjnych. W tym artykule przedstawię Ci zasadę działania tego wzorca oraz jakie przykładowe zastosowania można dla niego znaleźć.
Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce, to koniecznie sprawdź mój wpis o wzorcach projektowych.
Fragmenty kodu, które wykorzystuję w tym artykule, zostały przygotowane w TypeScript. Mam nadzieję, że będą one dla Ciebie zrozumiałe, jeśli masz doświadczenie z popularnymi językami obiektowymi, takimi jak Java czy C#.
Opis wzorca Builder
Cel powstania wzorca Builder krótko i zwięźle określił Gang of Four w swojej książce Design Patterns: Elements of Reusable Object-Oriented Software:
Separate the construction of a complex object from its representation so that the same construction process can create different representations.
Chyba ciężko będzie to ująć prościej. W praktyce zastosowanie wzorca Builder można przyrównać właśnie do budowania. Builder wystawia zestaw metod służących konstrukcji obiektu o konkretnych właściwościach i metodę build
, która inicjalizuje proces budowy. Takie podejście pozwala również wyeliminować tzw. telescoping constructors.
const builtObject = new Builder()
.addThis()
.addThat( value )
.build();
Aby zobrazować działanie wzorca Builder w praktyce, przygotowałem fragment kodu odpowiedzialny za tworzenie nowego konta użytkownika w systemie i przypisaniu go do konkretnej organizacji. Użytkownik domyślnie ma rolę Member
, ale w trakcie budowania możliwe jest nadanie innej roli (Admin
lub Moderator
). Użytkownik musi mieć również ustawione hasło. Dodatkową opcją jest możliwość ustawienia daty ważności konta. Jeśli data nie zostanie podana przy wywołaniu metody setExpirationDate
, to konto będzie ważne przez rok. Jeśli metoda setExpirationDate
nie zostanie wywołana, konto nie będzie miało terminu ważności.
interface IOrganization {
id: string;
name: string;
}
enum Role {
Member = 'Member',
Moderator = 'Moderator',
Admin = 'Admin'
}
class UserBuilder {
private _role: Role = Role.Member;
private _organizationId: string | undefined;
private _passwordHash: string | undefined;
private _expirationDate: Date | undefined;
public constructor( private readonly _userName: string ) {}
public setRole( role: Role ): this {
this._role = role;
return this;
}
public setOrganization( organization: IOrganization ): this {
this._organizationId = organization.id;
return this;
}
public setPassword( password: string ): this {
validatePasswordStrength( password );
this._passwordHash = calculateHash( 'bcrypt',password );
return this;
}
public setExpirationDate( date?: Date ): this {
if ( date && date.getTime() < Date.now() ) {
throw new Error( 'Cannot expire account in the past' );
}
this._expirationDate = date ?? addYears( new Date(), 1 );
return this;
}
public build(): User {
if ( this._passwordHash ) {
throw new Error( 'Password is not set' );
}
if ( this._organizationId ) {
throw new Error( 'Organization is not set' );
}
return User.create(
this._userName,
this._role,
this._organizationId,
this._passwordHash,
this._expirationDate
);
}
}
Tak przygotowany interfejs umożliwia tworzenie użytkowników w prosty sposób i o różnych właściwościach. Jednocześnie interfejs jest intuicyjny i czytelny, dzięki czemu tworzenie nowego użytkownika nie wymaga zaglądania w kod klasy UserBuilder
. Dodatkową zaletą jest to, że przedstawiona implementacja Buildera nie wymaga konstruowania obiektu w określonej kolejności. Nie ma tu więc ryzyka pomyłki czy side effectów wynikających z różnej kolejności etapów konstrukcji obiektów.
const user1 = new UserBuilder( 'userName1' )
.setOrganization( organization1 )
.setExpirationDate()
.setPassword( 'Passw0rd!')
.build();
const user2 = new UserBuilder( 'userName2' )
.setOrganization( organization2 )
.setRole( Role.Admin )
.setExpirationDate( new Date('2035-12-12' ) )
.setPassword( 'Rand0mP@$$w0rd')
.build();
Director
Przy opisie tego wzorca spotkałem się z wykorzystaniem Buildera za pośrednictwem klasy Director
zarządzającej procesem wykorzystania Buildera. Spotkałem się również z implementacjami Buildera z pominięciem Directora.
W podanym przykładzie Director mógłby posłużyć do tworzenia użytkowników z predefiniowanymi opcjami. W poniższym przykładzie instancja klasy UserDirector
mogłaby być powiązana z konkretną organizacją i z góry definiować datę wygaśnięcia konta użytkownika. Director tworzyłby wtedy użytkownika z „domyślnymi ustawieniami”.
class UserDirector {
constructor(
private readonly _builder: UserBuilder,
private readonly _organization: IOrganization
) {}
construct( username: string, password: string ) {
return this._builder
.setUserName( username )
.setOrganization( this._organization )
.setExpirationDate( new Date('2035-12-12' ) )
.setPassword( password )
.build();
}
}
Builder a Factory
Zarówno Builder jak Factory to wzorce kreacyjne jednak ich wykorzystanie wygląda nieco inaczej. Factory będzie lepszym wyborem, gdy do utworzenia obiektu wystarczy nam jedna prosta metoda lub gdy chcemy ukryć szczegóły związane z etapami konstrukcji obiektow.
Aby lepiej zobrazować różnicę między tymi wzorcami, przeczytaj świetną analogię wyjaśniającą różnicę między tymi wzorcami, którą znalazłem na Stack Overflow:
Consider a restaurant. The creation of „today’s meal” is a factory pattern, because you tell the kitchen „get me today’s meal” and the kitchen (factory) decides what object to generate, based on hidden criteria.
The builder appears if you order a custom pizza. In this case, the waiter tells the chef (builder) „I need a pizza; add cheese, onions and bacon to it!” Thus, the builder exposes the attributes the generated object should have, but hides how to set them.
Zalety i wady
Główną zaletą wynikająca z wykorzystania Buildera moim zdaniem jest czytelność. Korzystając z Buildera, czuję jakbym tworzył coś z klocków i uważam, że przedstawiony interfejs byłby zrozumiały nawet dla kogoś, kto nie programuje na co dzień. Intuicyjny kod to ogromna zaleta, szczególnie w dużych projektach lub takich z wysoką rotacją osób w zespole.
Jest to kolejny ze wzorców, dzięki któremu spełnienie Single Responsibility Principle staje się prostsze. W podanym przykładzie klasa User
nie musi wiedzieć np. jaki algorytm hashujący użyto do wygenerowania hasha hasła czy jakie są zasady tworzenia daty wygaśnięcia konta użytkownika.
Również zasada Open-Closed Principle wydaje się tu być spełniona. Swobodnie można dodawać kolejne metody konstrukcji obiektu klasy User
bez dotykania pozostałych metod.
Główną wadą, jaką dostrzegam, jest dość istotne komplikowanie prostych czynności. W wielu przypadkach komplikowanie konstrukcji obiektów Builderem będzie zbędne. Argumentem, jaki często pojawia się przy prezentowaniu Buildera, jest możliwość uniknięcia pomyłki, gdy konstruktor przyjmuje wiele argumentów o takim samym typie. W takim przypadku, zamiast Buildera można wykorzystać value object.
// problem
const user1 = new User(
'user1',
'[email protected]',
'1234'
);
// solution
const user2 = new User( {
userName: 'user2',
email: '[email protected]',
phone: '1234'
} );
Moim zdaniem Builder powinien być wykorzystywany gdy:
- z konstrukcją obiektu wiąże się jakaś dodatkowa logika. W przedstawionym przykałdzie będzie to np. wymóg przekazania hasha hasła zamiast hasła przekazanego jawnym tekstem. Takie podejście pozwala np. wynieść reguły walidacyjne poza konstruowany obiekt;
- obiekty mogą być inicjalizowane w różnych miejscach i wariantach/konfiguracjach. W podanym przykładzie zachodzi ten przypadek. Użytkownik może mieć określone role, opcjonalną datę wygaśnięcia itp.;
- potrzebujemy dostarczyć intuicyjne i czytelne API.
Ciekawym przykładem kodu z mojej codziennej pracy, który wykorzystuje Buildera i ma to sens, jest kod tworzący moduł służący do diagnostyki. W skład systemu wchodzą dziesiątki mikroserwisów z różnymi komponentami. W zależności od mikroserwisu monitorowane będą różne komponenty. Niektóre serwisy wykorzystują bazy danych, inne wykorzystują komunikację eventami, a jeszcze inne cache oparty o Redisa. W zależności od potrzeb, Builder inicjalizuje poszczególne komponenty diagnostyczne.
const diagnosticModule = new DiagnosticModuleBuilder()
.doX()
.doY()
.addCheck( new DbCheck() )
.addCheck( new RedisCheck() )
.build()
service.attachModule( diagnosticModule );
Inny przypadek, gdzie zastosowanie Buildera jest w pełni uzasadnione, to use case przedstawiony w Design Patterns: Elements of Reusable Object-Oriented Software:
A reader for the RTF (Rich Text Format) document exchange format should be able to convert RTF to many text formats. The reader might convert RTF documents into plain ASCII text or into a text widget that can be edited interactively. The problem, however, is that the number of possible conversions is open-ended. So it should be easy to add a new conversion without modifying the reader.
Podsumowanie
Liczbę przykładów gdzie można wykorzystać wzorzec projektowy Builder, ogranicza chyba tylko wyobraźnia. Konstrukcję każdego bardziej skomplikowanego obiektu prawdopodobnie idzie opakować w Buildera. To, że można to robić nie znaczy, że trzeba. Zachęcam jednak by wykorzystywać go tam, gdzie ma to uzasadnienie. Uwaga ta mogłaby być wspólna dla wszystkich wzorców.
Tym razem liczba źródeł i materiałów dodatkowych jest dość uboga. Nie znalazłem zbyt wiele materiałów o Builderze godnych polecenia. Z czystym sumieniem odsyłam do Design Patterns: Elements of Reusable Object-Oriented Software. W materiałach często pojawiały się również referencje do Effective Java, 3rd Edition autorstwa Joshuy Blocha. Nie miałem okazji czytać tej pozycji, jednak w ramach rzetelności zostawiam referencję 🙂
Książkę Design Patterns: Elements of Reusable Object-Oriented Software możesz kupić klikając w TEN link. Kupując z tego linku, wspierasz rozwój bloga.
Źródła i materiały dodatkowe
- Design Patterns: Elements of Reusable Object-Oriented Software – Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (1994) str. 110
- Effective Java, 3rd Edition – Joshua Bloch
- EP17: Design patterns cheat sheet. Also…
- Wzorce Projektowe Inaczej – Budowniczy
- When would you use the Builder Pattern?
- Sourcemaking – Builder Design Pattern
- Builder pattern validation – Effective Java
- Exploring Joshua Bloch’s Builder design pattern in Java
- Koddlo – Builder (Budowniczy)
Zapisz się na mailing i odbierz e-booka
Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.