Jeżeli masz dość if-ologii w swoim kodzie, to konieczne sprawdź czym jest czynnościowy wzorzec projektowy strategia. Pozwala on mądrze obsługiwać różne scenariusze w procesie i jednocześnie być fancy pod względem zasad SOLID.
Do czego służy wzorzec strategii?
Wzorzec strategia jest behawioralnym wzorcem projektowym, pozwalającym w łatwy sposób obsługiwać różne scenariusze w przebiegu programu. Oczywiście, możemy używać „drabinki” if-else if-else, jednak nie będziemy wtedy spełniać zasad SOLID, a nasz kod stanie się nieutrzymywalny, z uwagi na konieczność dodawania kolejnych warunków, wraz z rozwojem systemu.
Implementacja strategy pattern
Wzorzec strategii zwykle implementowalny jest formie wielu klas strategii, czyli miejsc w których zawarta jest konkretna logika, dla danego warunku, a także klasy kontekstu. Kontekst służy do uruchomienia odpowiedniej strategii, dostarczając przy tym spójny interfejs. Taką klasę możemy następnie wstrzyknąć wszędzie tam, gdzie zachodzi potrzeba obsłużenia logiki, której dotyczy strategia.
Wzorzec strategia przykład
Powiedzmy, że musimy zaimplementować moduł wysyłki towaru. System pozwala klientom wybrać jedną z wielu możliwości dostawy. Na podstawie wybranej przez klienta opcji, musimy odpowiednio obsłużyć żądanie, np. poprzez wysłanie zapytania do API firmy kurierskiej. Stwórzmy więc spójne pod względem interfejsu klasy strategii, dla poszczególnych opcji wysyłki.
interface IDeliveryRegistration {
register(dto: RegisterDeliveryDto);
}
class PostDeliveryRegister implements IDeliveryRegistration {
constructor(
private readonly polishPostClient: IPolishPostClient
) {}
register(dto: RegisterDeliveryDto) {
// call polish post API
}
}
class XYZMessengerDeliveryRegister implements IDeliveryRegistration {
constructor(
private readonly xyzMessengerClient: IXYZMessengerClient
) {}
register(dto: RegisterDeliveryDto) {
// call messenger API
}
}
class SelfPickupDeliveryRegister implements IDeliveryRegistration {
constructor(
private readonly sendgridClient: ISendgridClient
) {}
register(dto: RegisterDeliveryDto) {
// send notification email to client
}
}
Następnie potrzebować będziemy klasę kontekstu, którą w tym przypadku nazwę DeliveryRegisterer.
class DeliveryRegisterer implements IDeliveryRegisterer {
constructor(
private registerStrategy: IDeliveryRegistration
private data: RegisterDeliveryDto
) {}
register() {
this.registerStrategy.register(this.data);
}
}
Tworzenie kontekstu strategii
Teraz pozostaje kwestia utworzenia obiektu klasy kontekstowej, z odpowiednim obiektem strategicznym. Do tegu celu idealnie nada się omawiany już przeze mnie wzorzec fabryki. Możemy w niej na podstawie przesłanego typu wysyłki zainicjalizować kontekst.
class DeliveryRegistererFactory implements IDeliveryRegistererFactory {
constructor(
private readonly resolver: DependencyResolver
) {}
create(dto: RegisterDeliveryDto): IDeliveryRegisterer {
switch(dto.type) {
case DeliveryType.polishPost:
return new DeliveryRegisterer(this.resolver.get(PostDeliveryRegister.name), dto);
case DeliveryType.xyzMessenger:
return new DeliveryRegisterer(this.resolver.get(XYZMessengerDeliveryRegister.name), dto);
case DeliveryType.selfPickup:
return new DeliveryRegisterer(this.resolver.get(SelfPickupDeliveryRegister.name), dto);
default:
throw new Error('Incorrect delivery type.');
}
}
}
Powyższy kod niestety nie mieści się w Open/Closed, dlatego zamiast switcha można użyć map lub tablic asocjacyjnych, aby uniknąć konieczności dodawania kolejnego case’a w przyszłości. W takiej mapie, bądź tablicy, można zapisać już gotowe obiekty kontekstowe i tylko wybierać je za pomocą stosownej metody i unikalnej nazwy kontekstu. Wtedy, w przypadku nowej implementacji wystarczy dodać element do takiego zbioru.
const deliveryRegistererStrategies = new Map([
[DeliveryType.polishPost, PostDeliveryRegister],
// ...
]);
class DeliveryRegistererFactory implements IDeliveryRegistererFactory {
constructor(
private readonly resolver: DependencyResolver
) {}
create(dto: RegisterDeliveryDto): IDeliveryRegisterer {
const strategyClass = deliveryRegistererStrategies.get(dto.type);
if (!strategyClass) throw new Error('Incorrect delivery type.');
const strategy = this.resolver.get(strategyClass);
return new DeliveryRegisterer(strategy, dto);
}
}
Stosowanie strategy pattern
Strategie pozwalają nam na posiadanie czytelnego kodu, który spełnia zasady SOLID. Łatwość rozszerzania o kolejne implementacje pozwala na szybkie wprowadzanie nowych funkcjonalności. Osobiście, konteksty strategii używam najczęściej w serwisach lub innych elementach procesowych. Wstrzykuję do nich odpowiednie fabryki, które tworzą obiekty kontekstu, a następnie wywołuję na nich stosowne metody.
class DeliveryService {
constructor(
private readonly deliveryRegistererFactory: IDeliveryRegistererFactory
// ...
) {}
register(delivery: Delivery) {
// ...
const deliveryRegisterer = this.deliveryRegistererFactory.create(dto);
deliveryRegisterer.register();
}
}
Podsumowanie
- Strategia pozwala sprytnie zarządzać różnymi scenariuszami procesu.
- Stosując strategy pattern jesteśmy otwarci na nową logikę w przyszłości, bez konieczności modyfikacji istniejącego kodu.
- Trzeba pamiętać, że nie każdy algorytm z ifami wymaga implementacji strategii.
Dodaj komentarz