Wzorzec strategia (strategy pattern)

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.
Autor wpisu

blog@orbisbit.com

Komentarze

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Sprawdź również
  • Wzorzec strategia (strategy pattern)

    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.

    Zobacz wpis

  • SOLID z przykładami w TypeScript

    SOLID (Single responsibility principle, Open/closed principle, Liskov substitution principle, Interface segregation principle, Dependency inversion principle), czyli pięć zasad programowania obiektowego, które każdy powinien przestrzegać.

    Zobacz wpis

  • Prawo Demeter (The Law of Demeter)

    Omawiam dzisiaj mało znaną zasadę, której stosowanie skutkuje posiadaniem łatwo utrzymywalnego i testowalnego kodu. Mowa o prawie demeter, które w najprostszym ujęciu zakłada, że obiekty powinny operować jedynie na najbliższym im otoczeniu.

    Zobacz wpis