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.
Nie bardzo kumam czemy wywołujesz 2x determineStrategies w funkcji onPasswordExpired. Poza tym całkiem spoko !
Dzięki za informację. Już wczoraj to poprawiłem, bo przez przypadek zduplikowałem niepotrzebnie. Prawdopodobnie cache Ci trzyma jeszcze starą wersję kodu.
To nie jest poprawne użycie patterna strategii.
Ciężko się ustosunkować. Jakbyś podał jakieś argumenty to może wtedy.
UserNotification nie powinien decydować o wyborze strategii, a tworzenie tam obiektów to rak. Interfejs powinien wymusić metodę supports, a ChainNotificationStrategy powinien zawierać foreacha z UserNotification i odpalać po kolei metodę supports na wstrzykniętych strategiach i odpalać jak dana implementacja supportuje strategię.
No i UserNotification jest praktycznie nietestowalny. W twoim wirtualnym przykładzie jest ok, ale tylko dlatego że przykład jest nieżyciowy. Niech EmailNotification zacznie wysyłać maile a SystemNotification zacznie np. gadać z bazą i dodawać rekordy, ciekaw jestem jak wtedy będą wyglądały testy.
A no będą wyglądały tak, że test wysyłki emaila czy powiadomienia systemowego to test konkretnej strategii, a nie test UserNotification stąd też te funkcjonalności w UserNotification mogą oprzeć się o mocka.
I jak zmockujesz cokolwiek tworząc obiekty wewnątrz klasy?
We wpisie sygnalizuje ten problem i piszę, że należy skorzystać z jakiegoś wzorca kreacyjnego np. fabryki.
Jak w Twoim przykładzie przekazać nadawce notyfikacji?
Dla każdej strategii może być inny nadawca: email, identyfikator UUID, numer telefonu. Dodatkowo moglibyśmy dla poszczególnych notyfikacji chcieć dać więcej parametrów np. email jako CC. Nie powinniśmy w takim wypadku użyć jeszcze interfejsu dla poszczególnych strategii?
Przykład oczywiście jest konceptualny, więc żeby go nie zaciemniać to jest jeden parametr message. Zawsze można rozszerzyć interfejs o potrzebne parametry, najlepiej jakieś DTO jeśli jest ich tak dużo. Jeżeli parametry różnią się między poszczególnymi strategiami to jest kilka sposobów:
– rezygnacja z takiej abstrakcji, bo może nie da się tego uwspólnić na siłę;
– stworzenie luźniejszej struktury pozwalającej przekazać różne parametry, coś ala metadane;
– stworzenie nowej struktury, gdzie część wspólna jest ogarnięta jedną abstrakcją, a różnice są ogarniane osobno;
– próba schowania szczegółów implementacyjnych za interfejsami np. stworzenie jakiegoś NotifyInterface, czy RecipientInterface.