Strategy (Strategia)

strategy uml

Opis

Wzorzec projektowy Strategy (Strategia) należy do grupy wzorców behawioralnych. Pozwala odseparować do konkretnych klas właściwe algorytmy z pewnej grupy i dynamicznie korzystać z tego, który akurat jest potrzebny. Świetnie enkapsuluje zachowania, gwarantując że klasa kliencka nie wie, który algorytm jest wykonywany. Wystarczy, że spełnia właściwy interfejs. Chyba, że decyzja o strategii nie jest automatyczna i to klient w klasie kontekstowej decyduje z której skorzystać – taka implementacja też jest możliwa.

W nowszej literaturze często określany jako wzorzec Policy (Polityka). W tym wpisie używam nazw zamiennie, ale w praktyce częściej sięgam po oryginalną nazwę. Szczególnie w kodzie.

Problem i rozwiązanie

Skąd wzięła się taka nagonka na ifologię? Przecież to jedna z podstawowych konstrukcji w programowaniu. Okazało się, że kod pisany w ten sposób jest trudny w zarządzaniu. Już nie mówiąc o rozszerzeniach. I nie mam na myśli omijania na siłę konstrukcji warunkowych. Zresztą ciężko by się bez nich żyło, ale w niektórych miejscach lepiej skorzystać z dobrodziejstw mechanizmów obiektowych – chociażby polimorfizmu.

Problem wystąpi wszędzie, gdzie pewne zachowanie może być realizowane na różne sposoby. Jak to może wyglądać w praktyce? Do zaimplementowania był prosty mechanizm powiadomień użytkownika o występujących zdarzeniach. Pierwsza iteracja zakładała tylko powiadomienia emailowe – standard w aplikacjach webowych. No to jazda – jedna klasa z obsługą danych zdarzeń, które generują powiadomienie. Jak dobrze poszło to chociaż usługa do wysyłki emaili jako osobna klasa – fajnie, bo przyda się pewnie w innych miejscach.

Przychodzi kolejna iteracja i nagle biznes twierdzi, że przydałoby się żeby również bezpośrednio w aplikacji zalogowany użytkownik był informowany o danych zdarzeniach. Wydaje się, że ma to sens. No więc następuje implementacja samego mechanizmu powiadomień oraz klasa odpowiadająca za informowanie użytkownika zostaje rozszerzona. Wszystko fajnie działa, ale niektórzy użytkownicy są sfrustrowani tym, że dostają podwójne powiadomienia. Najpierw odczytują w systemie, a później to samo mają w skrzynce pocztowej.

W grę wchodzą preferencję użytkownika. Niech sam zadecyduje, czy chce być informowany i w jaki sposób. No więc modyfikowana zostaje klasa do notyfikacji o jakiegoś ifa. Pierwszy problem – czy tę klasę interesuje w jaki sposób ma informować użytkownika? Może wystarczy jej informacja, że zdarzenie wystąpiło i trzeba powiadomić użytkownika w preferowany przez niego sposób.

Inne założenie przy okazji – trzeba dorzucić SMS jako nowy typ powiadomień. Nie wszyscy przeglądają skrzynkę pocztową regularnie, a też rzadko logują się do systemu. Natomiast o niektórych faktach muszą zostać powiadomieni jak najszybciej. SMS wydaje się najskuteczniejszą opcją dla tego rodzaju problemu. Dodanie trzeciego rodzaju algorytmu okaże się problematyczne. Jakiś elseif albo przerobienie na switcha. Klasa spuchnie dwukrotnie, a przy okazji dodawania nowego typu powiadomień można zepsuć coś w starych, bo klasa wymaga modyfikacji.

Oczywiście do zrobienia i może dramatyzuję, ale jest to na pewno trudniejsze i zajmie dłużej, niż gdyby skorzystać z wzorca projektowego Strategy. Nie wspominając o tym, że rezultat będzie mniej czytelny. No nic, zostaje żyć w wierze, że już nie dojdzie kolejny typ informowania użytkownika, a w obecnie zaimplementowanych nic się nie zmieni. Co ciekawe, nawet rezygnacja z algorytmu np. SMS też nie będzie aż tak trywialna jak mogłoby się wydawać. A znając życie w kolejnej iteracji czekają powiadomienia Slack i Push Notification.

W dalszej części tego wpisu prezentuję ten sam przykład jako implementację w PHP z wykorzystaniem wzorca Strategii. Dodanie nowego rodzaju notyfikacji w tym przypadku nie będzie stanowiło żadnego problemu. Po prostu nowa strategia, bez ingerencji w klasę, która jest odpowiedzialna za wysyłkę powiadomień. Coś pięknego! Tego typu problemy występują w każdej aplikacji. I to bardzo często. Może nie wszystkie rozwiązuje się tym wzorcem projektowym, ale znaczną część z nich można i to ze sporą korzyścią.

Plusy i minusy

Jak to zwykle we wzorcach projektowych, minusy są szukane trochę na siłę. Strategia często tworzona jest przez new zamiast użyć wstrzykiwania zależności (dependency injection). Z prostego powodu. Jeżeli strategii jest dużo to należałoby przekazać do klasy kontekstowej je wszystkie jako argumenty, co nie rzadko mija się z celem. Przecież to też jedna z zalet, że tylko potrzebny obiekt jest tworzony.

Wzorzec projektowy Policy pasuje w wielu miejscach, co może okazać się wadą i zaletą. Uznałem to za minus, jako że nie wszędzie okaże się zgodny z KISS i może niepotrzebnie komplikować, chociaż akurat ten pattern jest dość prosty w rozumieniu.

Plusów można doszukać się więcej. Przestrzegane są dwie pierwsze zasady SOLID – pojedyczna odpowiedzialność (single responsibility) i otwarte-zamknięte (open-closed). Eliminacja dziedziczenia, a zamiast tego wykorzystywana kompozycja. Ograniczenie, bądź pozbycie się zbędnych instrukcji warunkowych, co wpływa na czytelność i łatwiejszą zarządzalność. Mechanizm gwarantuje również enkapsulację konkretnych algorytmów izolując szczegóły implementacyjne od klasy i zapewnia elastyczność.

Przykładowa implementacja w PHP

Struktura klas we wzorcu projektowym Strategia zawsze będzie taka sama. W implementacji może różnić się natomiast konkretna klasa korzystająca z dobrodziejstw tego mechanizmu. Gdzieś musi dojść do decyzji, która Polityka akurat w tym miejscu ma być wykorzystana. Widziałem sporo implementacji, ale jeszcze nigdy nie znalazłem w pełni odpowiadającej na wszystkie problemy. Warto znać możliwe opcje i dopasować tę najlepszą.

Istnieją trzy popularne rozwiązania. Pierwsze pozwala klientowi wybrać właściwą dla operacji Strategię przyjmując ją jako parametr. Drugie umożliwia dynamiczne jej określenie na podstawie innego argumentu. Trzecie za to zakłada dostęp do wszystkich strategii i decyzji, której użyć na podstawie metody supports(). Za to momenty do ustalenia Polityki są co najmniej trzy:

  • Konstruktor – przy tworzeniu obiektu dla wszystkich metod klasy;
  • Dedykowana metoda – wywoływana w innej metodzie przed akcją;
  • Parametr metody – interfejs jako typ.

Ja wziąłem na warsztat nieco trudniejszą opcję, bo pozwalającą na wiele strategii dla jednego zachowania. Metoda determineStrategies() jest więc fajną opcją do określenia potrzebnych klas tyle że ma jedną ogromną wadę – tworzenie Strategii przez new. W moim uproszczonym przykładzie – nie ma z tym problemu. W rzeczywistości często klasa może potrzebować zależności. Żeby to rozwiązać można użyć któregoś wzorca kreacyjnego, albo przekazać do konstruktora klasy Notifier jako zależności gotowe już obiekty strategii. Zadziała, ale obiekt mocno puchnie przy kolejnych politykach, a klasa ma sporo zależności. Być może potrzebuje tylko jednej z nich.

Dodatkowa uwaga: uprościłem i notify() zwraca stringa, a normalnie pewnie byłaby to metoda void i wykonywała faktycznie swoje zadanie, czyli wysyłka email lub powiadomienie systemowe. To samo metoda onPasswordExpired() pewnie nie zbierałaby rezultatów, a test wyglądałby zupełnie inaczej. Tak jak zawsze wspominam ma to być konceptualne. Myślę, że jak ktoś kojarzy temat to bez problemu dostosuje sobie do własnych potrzeb.

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

interface NotifyInterface
{
    public function notify(string $message): string;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

final class EmailNotifier implements NotifyInterface
{
    public function notify(string $message): string
    {
        return sprintf('Email: %s', $message);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

final class SystemNotifier implements NotifyInterface
{
    public function notify(string $message): string
    {
        return sprintf('System: %s', $message);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

final class NotificationPreference
{
    public function __construct(
        private bool $email,
        private bool $system
    ) {}

    public function shouldNotifyByEmail(): bool
    {
        return $this->email;
    }

    public function shouldNotifyBySystem(): bool
    {
        return $this->system;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

final class UserNotification
{
    public const string PASSWORD_EXPIRE_MESSAGE = 'Your password will expire in 3 days. Please change it as soon as possible.';

    public function onPasswordExpired(NotificationPreference $notificationPreference): array
    {
        $messages = [];
        $notifyStrategies = $this->determineStrategies($notificationPreference);
        /** @var NotifyInterface $notifyStrategy */
        foreach ($notifyStrategies as $notifyStrategy) {
            $messages[] = $notifyStrategy->notify(self::PASSWORD_EXPIRE_MESSAGE);
        }

        return $messages;
    }

    private function determineStrategies(NotificationPreference $notificationPreference): array
    {
        $notifyStrategies = [];

        if ($notificationPreference->shouldNotifyByEmail()) {
            $notifyStrategies[] = new EmailNotifier();
        }

        if ($notificationPreference->shouldNotifyBySystem()) {
            $notifyStrategies[] = new SystemNotifier();
        }

        return $notifyStrategies;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy\Test;

use DesignPatterns\Behavioral\Strategy\NotificationPreference;
use DesignPatterns\Behavioral\Strategy\UserNotification;
use PHPUnit\Framework\TestCase;

final class UserNotificationTest extends TestCase
{
    public function testCanNotifyUserByEmailAboutPasswordExpire(): void
    {
        $userNotificationPreference = new NotificationPreference(true, false);
        $userNotification = new UserNotification();

        $messages = $userNotification->onPasswordExpired($userNotificationPreference);

        self::assertCount(1, $messages);
        self::assertContains(sprintf('Email: %s', UserNotification::PASSWORD_EXPIRE_MESSAGE), $messages);
    }

    public function testCanNotifyUserBySystemAboutPasswordExpire(): void
    {
        $userNotificationPreference = new NotificationPreference(false, true);
        $userNotification = new UserNotification();

        $messages = $userNotification->onPasswordExpired($userNotificationPreference);

        self::assertCount(1, $messages);
        self::assertContains(sprintf('System: %s', UserNotification::PASSWORD_EXPIRE_MESSAGE), $messages);
    }

    public function testCanNotifyUserByEmailAndSystemAboutPasswordExpire(): void
    {
        $userNotificationPreference = new NotificationPreference(true, true);
        $userNotification = new UserNotification();

        $messages = $userNotification->onPasswordExpired($userNotificationPreference);

        self::assertCount(2, $messages);
        self::assertContains(sprintf('Email: %s', UserNotification::PASSWORD_EXPIRE_MESSAGE), $messages);
        self::assertContains(sprintf('System: %s', UserNotification::PASSWORD_EXPIRE_MESSAGE), $messages);
    }

    public function testCannotNotifyUserWithoutPreferenceAboutPasswordExpire(): void
    {
        $userNotificationPreference = new NotificationPreference(false, false);
        $userNotification = new UserNotification();

        $messages = $userNotification->onPasswordExpired($userNotificationPreference);

        self::assertCount(0, $messages);
    }
}

Strategy – podsumowanie

Ten wzorzec projektowy jest łatwy w implementacji, a daje wymierne korzyści. Tak jak wspominałem jest on prosty w swoich założeniach. Jeśli programujecie obiektowo już od dłuższego czasu i nie mieliście okazji go zaimplementować to chyba coś poszło nie tak. Praktycznie w każdej aplikacji są miejsca, gdzie sprawdzi się idealnie. Zresztą sam używam go chyba najczęściej. Polecam zastosować odpowiednią strategię nauki wzorców projektowych i okiełznać go jako pierwszego, zamiast mniej sławnego Singletona.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.