Wzorzec strategia (strategy pattern)

Jeżeli masz dość ifologii 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




Polecane wpisy:

KISS: keep it simple, stupid!

Zasada KISS: "Keep it simple, stupid", może zostać dosłownie przetłumaczona na: "rób to prosto, głupku". Mówi ona o tym, abyśmy tworzyli kod w jak najprostszy i najbardziej czytelny sposób. Już dziś sprawdź, czego się wystrzegać, aby spełniać KISS.

Sprawdź ten wpis

Wzorce projektowe

Wzorce projektowe zostały stworzone po to, aby nie wymyślać przysłowiowego koła na nowo. Znajomość wzorców projektowych i umiejętność ich stosowania pozwala na szybkie rozwiązywanie problemów. Wpis ten radzi, jakie wzorce zastosować u siebie.

Sprawdź ten wpis

Wzorzec singleton

Kolejny kreacyjny wzorzec projektowy omawiany na łamach tego bloga: singleton. Wzorzec dookoła którego narosło wiele mitów i legend. Dziś o tym dlaczego singleton jest antywzorcem, jakie problemy powoduje oraz kiedy warto po niego sięgnąć.

Sprawdź ten wpis

Wzorzec stan (state)

Dziś prezentuję kolejny wzorzec projektowy, który pozwala uniknąć drabinek if-else if. Mowa tutaj o wzorcu state, który idealnie nada się, jeśli posiadasz różne stany w swoim systemie oraz chcesz mieć możliwość płynnego przechodzenia pomiędzy nimi.

Sprawdź ten wpis

Wzorzec pełnomocnik (virtual proxy)

Wzorzec virtual proxy służy do tworzenia obiektów pośrednich, które nadzorują dostęp do obiektów dla których są pełnomocnikami. Dzięki temu, że pełnomocnik i klasa bazowa udostępniają jednakowy interfejs, możemy ukryć w pośredniku dodatkową logikę.

Sprawdź ten wpis

Autor wpisu:

Gabriel Ślawski

Fanatyk czystego i prostego kodu. Zwolennik podejść DDD oraz Modular Monolith. Na codzień pracuje jako programista i architekt. Po godzinach spełnia się w projektach open source, udziela się na blogach oraz czyta książki o kosmosie i astrofizyce.