Wzorce projektowe w produkcji: co rozwiązują, co kosztują i kiedy ich nie używać
Książka GoF ukazała się w 1994 roku. W ciągu trzydziestu lat od tamtej pory wzorce projektowe przeszły co najmniej trzy pełne cykle: wprowadzenie, nadużycie, backlash i ostrożne ponowne przyjęcie. Jesteśmy gdzieś w czwartym lub piątym cyklu, zależnie od tego, w której części branży pracujesz.
Mój pogląd, ukształtowany przez lata czytania codebases zarówno z wzorcami jak i bez: wzorce to nie rozwiązania. To wspólny słownik do nazywania kształtu problemu, który już rozwiązałeś. Wartość nie leży w implementacji, leży w nazewnictwie, bo nazywanie tego, co zbudowałeś, to sposób komunikowania tego następnemu inżynierowi.
To, co piszę poniżej, to przewodnik zorientowany produkcyjnie. Nie omawiam wszystkich dwudziestu trzech kanonicznych wzorców plus cokolwiek, co społeczność dorzuciła od tamtej pory, tylko te, po które regularnie sięgam, te które najczęściej widzę źle zastosowane i te, dla których nigdy nie znalazłem prawdziwego uzasadnienia w warstwie aplikacyjnej PHP.
Wzorce kreacyjne: konstruowanie obiektów jest trudniejsze niż wygląda
Singleton to wzorzec, który zestarzał się najgorzej. Uzasadniony dokładnie wtedy, gdy masz niemutowalną konfigurację, która jest kosztowna do załadowania i musi być współdzielona w ramach jednego procesu. W prawie każdym innym przypadku, nie. Omówiony szczegółowo w innym miejscu tej serii.
Factory Method to wzorzec, po który sięgam najczęściej w grupie kreacyjnej. Konieczny wtedy, gdy typ tworzonego obiektu jest decyzją runtime, wybór bramki płatności, kanału notyfikacji, parsera dokumentów dla nieznanego formatu pliku. Factory nie buduje obiektów; deleguje konstrukcję do kontenera DI i zwraca interfejs. Omówiony szczegółowo w tej serii.
Builder jest niedostatecznie używany przy złożonych obiektach domeny, a zbyt często stosowany przy konstruowaniu zapytań. QueryBuilder to prawidłowy Builder: akumuluje warunki, potem produkuje niemutowalny obiekt zapytania. UserBuilder istniejący tylko po to, żeby testy były czytelne (UserBuilder::new()->withName('Alice')->withRole('admin')->build()) jest w porządku w testach, ale jeśli potrzebujesz buildera do konstruowania User w kodzie produkcyjnym, twój konstruktor User prawdopodobnie robi za dużo.
Abstract Factory rzadko jest konieczna w kodzie aplikacyjnym. Widziałem ją użytą poprawnie w bibliotece komponentów UI, która musiała przełączać się między jasnym a ciemnym motywem, produkując spójne komponenty button, input i modal bez wiedzy po stronie wywołującego, który motyw jest aktywny. W systemach backendowych kontener DI zazwyczaj zastępuje potrzebę abstract factory.
Prototype, przez dziesięć lat pisania produkcyjnego PHP potrzebowałem tego wzorca celowo jeden raz. Był to system szablonów dokumentów, gdzie kopiowanie struktury szablonu było wystarczająco kosztowne, żeby uzasadnić dedykowany interfejs clone. W każdym innym przypadku clone działa bezpośrednio. Jeśli tworzysz interfejs Prototype z metodą copy(), która po prostu wywołuje clone $this, dodałeś warstwę pośrednią bez żadnej korzyści.
Wzorce strukturalne: te, które naprawdę zarabiają
Adapter to wzorzec strukturalny o najwyższej wartości w kodzie aplikacyjnym. Każda integracja z zewnętrznym systemem, którą piszesz, jest adapterem: bierze interfejs zewnętrznego systemu i tłumaczy go na interfejs twojej domeny. Kluczowa dyscyplina: trzymaj adapter cienki. Jeśli twój adapter Stripe zawiera logikę biznesową o tym, kiedy ponawiać próby lub jak obliczać opłaty, to nie jest adapter, to serwis, który przy okazji wywołuje Stripe.
// Thin adapter: translates types, nothing more
final class StripePaymentGateway implements PaymentGatewayInterface
{
public function __construct(private readonly \Stripe\StripeClient $stripe) {}
public function charge(Money $amount, string $currency): ChargeResult
{
try {
$intent = $this->stripe->paymentIntents->create([
'amount' => $amount->getAmount(), // Stripe wants cents
'currency' => strtolower($currency),
]);
return new ChargeResult(chargeId: $intent->id, status: ChargeStatus::Pending);
} catch (\Stripe\Exception\CardException $e) {
return new ChargeResult(chargeId: null, status: ChargeStatus::Declined, error: $e->getMessage());
}
}
}
Decorator to wzorzec strukturalny, który najczęściej widzę nadmiernie skomplikowany. Dekorator dodaje zachowanie do obiektu bez zmiany jego interfejsu. Kanoniczne przypadki użycia w PHP: dekoratory cachujące, logujące, ograniczające ruch. To jest potężne i poprawne. Co widzę zamiast tego: łańcuchy dekoratorów siedem poziomów głęboko, gdzie debugowanie wymaga zrozumienia, który dekorator jest aktywny w którym kontekście i dlaczego.
// Correct use: transparent caching
final class CachingUserRepository implements UserRepositoryInterface
{
public function __construct(
private readonly UserRepositoryInterface $inner,
private readonly CacheInterface $cache,
private readonly int $ttl = 300,
) {}
public function findById(int $id): ?User
{
$key = "user.{$id}";
return $this->cache->remember($key, $this->ttl, fn() => $this->inner->findById($id));
}
}
Test dekoratora cachującego weryfikuje, że wywołuje inner przy chybieniu cache'a i pomija inner przy trafieniu. Test repozytorium bazodanowego weryfikuje dostęp do danych. Są niezależnie testowalne, niezależnie wdrażalne.
Facade jest nadużywany jako plaster. Facade upraszcza złożony podsystem za pojedynczym interfejsem. Statyczne fasady Laravela (DB::table('users')) to opinionated implementacja tego wzorca. Poprawne użycie: gdy podsystem ma dziesięć klas, a wywołujący musi wchodzić w interakcję tylko z dwiema lub trzema operacjami. Nadużycie: owijanie pojedynczej klasy w facade, żeby uniknąć jej wstrzykiwania.
Proxy najczęściej spotykamy w PHP przez generatory proxy do lazy-loadingu (Doctrine, Symfony). Budowanie własnego proxy jest rzadkie i zazwyczaj błędne. Jeśli potrzebujesz przechwytywać wywołania metod dla logowania, cachowania lub kontroli dostępu, dekorator jest prawie zawsze lepszym narzędziem, bo jest eksplicytny. Proxy przechwytujące wywołania transparentnie jest trudniejsze do testowania i trudniejsze do rozumowania.
Wzorce behawioralne: tu dzieje się większość prawdziwej pracy projektowej
Observer / Event to wzorzec, który najczyściej skaluje się we współczesnym PHP, bo każdy framework ma dispatcher zdarzeń. Gdy Order przechodzi do stanu Paid, dispatchuje OrderPaid. Listener do emaila, listener do inventory i listener do analytics subskrybują niezależnie. Dodanie czwartego listenera nie wymaga zmian w Order ani w pozostałych trzech.
Tryb awarii: listenery zdarzeń z kaskadowymi efektami bez circuit breakera. Widziałem łańcuch, gdzie OrderPaid → ReserveInventory → InventoryLow → SendSupplierEmail → EmailDeliveryFailed → CreateAlertTask → AlertTaskCreated → pięć kolejnych listenerów. Oryginalne zdarzenie OrderPaid wyzwalało 47 zapytań bazodanowych w 9 klasach listenerów. Każde z osobna było rozsądne. Razem sprawiały, że każde zakończenie zamówienia zajmowało 800ms.
Strategy to wzorzec, który najwyraźniej oddziela "co zrobić" od "jak to zrobić." Kalkulator kosztów wysyłki, który wybiera między flat-rate, opartym na wadze i opartym na strefie na podstawie przewoźnika, używa Strategy poprawnie. Selekcja strategii powinna następować raz na request, nie wewnątrz gorącej ścieżki obliczeń.
Command to wzorzec leżący u podstaw każdego nowoczesnego systemu kolejkowego. ChargeCustomerCommand to serializowalne, samowystarczalne opisanie intencji. Nie wykonuje niczego, opisuje to, co powinno zostać wykonane. Command bus (kolejka) pobiera go i dispatchuje do handlera. Wartość: komendy można opóźniać, ponawiać, audytować i odtwarzać w sposób, którego bezpośrednie wywołanie metody nie umożliwia.
Template Method to wzorzec, po który sięgam najczęściej, nie zdając sobie z tego sprawy. Jeśli masz dwie klasy współdzielące 90% logiki i różniące się w jednym kroku (generator raportów formatujący identycznie, ale eksportujący do CSV lub PDF) klasa bazowa implementuje wspólną strukturę, a podklasa nadpisuje różniący się krok. To jest Template Method. Ostrzeżenie: dziedziczenie dla współdzielenia kodu jest w porządku; dziedziczenie dla polimorfizmu tam, gdzie sprawdziłaby się kompozycja, nie.
Te, dla których nie znalazłem prawdziwego zastosowania
Interpreter to wzorzec zakładający budowanie parsera i ewaluatora dla własnego języka w aplikacyjnym PHP. Widziałem go raz, w silniku reguł dla systemu cennikowego. Był tam właściwym narzędziem. W pozostałych dwudziestu przypadkach, gdy go proponowano, prostszy ewaluator wyrażeń lub biblioteka jak Symfony ExpressionLanguage byłaby mniejszą ilością kodu i łatwiejsza w utrzymaniu.
Mediator jest często opisywany jako "Observer, ale obserwatorzy znają się nawzajem przez centralny broker." W praktyce dodana złożoność względem standardowego event dispatchera nie była uzasadniona w żadnym systemie, z którym pracowałem.
Flyweight to optymalizacja pamięci dla dużej liczby drobnoziarnistych obiektów. Model pamięci PHP (ograniczony do requestu, izolowany procesami) sprawia, że rzadko jest konieczny. Widziałem go użyty poprawnie w parserze tworzącym tysiące obiektów tokenów, cachującym identyczne tokeny według wartości.
Pytanie, które zadaję przed zastosowaniem jakiegokolwiek wzorca
Co ten wzorzec ułatwia do zmiany?
Dekorator ułatwia dodawanie lub usuwanie zachowań przekrojowych (cachowanie, logowanie) bez modyfikowania dekorowanej klasy. Strategy ułatwia dodanie nowego algorytmu bez modyfikowania kontekstu. Adapter ułatwia zamianę zewnętrznej zależności.
Jeśli odpowiedź brzmi "nie jestem pewien, co ułatwia do zmiany, po prostu uważam, że to dobra architektura", wzorzec prawdopodobnie rozwiązuje problem, którego jeszcze nie masz. Koszt abstrakcji jest realny i natychmiastowy. Korzyść jest hipotetyczna. Płać ten koszt wtedy, gdy korzyść jest równie realna.