Opis
Chain Of Responsibility (Łańcuch Zobowiązań) należy do grupy wzorców behawioralnych. Gwarantuje obsługę żądania przez łańcuch wywołań. Każdy kolejny obiekt w danej sekwencji próbuje je obsłużyć oraz decyduje, czy przekazać je kolejnemu w łańcuchu, czy może je przerwać.
Idealna sytuacja ma miejsce, gdy kolejność wywołań nie ma znaczenia. W praktyce jednak, częściej do obsługi trafiają się sekwencyjne przypadki, gdzie wzorzec projektowy Chain of Responsibility ma zastosowanie. Popularne rozwiązania, które wykorzystują ten mechanizm to na przykład middleware w komponencie Symfony Messenger albo interfejsy PSR-15 do obsługi żądań z pomocą pośredników.
Problem i rozwiązanie
Dokładanie kolejnych warunków do logiki aplikacji występuje zawsze w projekcie. Każda taka zmiana często musi zostać odnotowana w wielu miejscach. Oczywiście, co bardziej doświadczony programista wie, żeby tego rodzaju logiki nie powielać i wydelegować ją do jednej klasy. Jeszcze trudniej jest, gdy warunki występują w różnych kombinacjach. Nawet odpowiednie podzielenie ich na klasy może prowadzić do zbyt złożonej obsługi. Będzie to wymagało wielu sprawdzeń, jaki rezultat wygenerowało wywołanie poprzedniej klasy.
Wzorzec Łańcuch Zobowiązań pozwala to w pewien sposób uporządkować. Przede wszystkim, każdy obiekt z łańcucha ma jedną odpowiedzialność. Dodatkowo, może on przerwać wywołanie i tym samym odciąć żądanie od kolejnych obiektów, które nie powinny reagować, jeżeli poprzednie warunki nie zostały spełnione.
W zasadzie, możliwe są dwie implementacje. Jedna zakłada, że żądanie może być obsłużone tylko przez jeden obiekt z łańcucha. Wówczas wywołanie w łańcuchu to tak naprawdę kolekcja obiektów i pierwszy, który jest w stanie go obsłużyć, przerywa ciąg. Kolejna za to nie definiuje, ile obiektów z łańcucha przyczyni się do obsługi żądania. Każda z części może dołożyć coś do obsługi lub nie i przekazać dalej żądanie lub nie. Pełna dowolność – w zależności od przypadku.
Pierwsze rozwiązanie może prowadzić do duplikacji warunków. Drugie brzmi dużo bardziej elastycznie i jest też bliższe oryginalnemu konceptowi tego wzorca. Każde z nich rozwiązuje nieco inny problem.
Plusy i minusy
Sekwencyjne wywołania, gdzie kolejność ma znaczenie to codzienność. Dlaczego więc mogą być wadą w przypadku użycia tego Łańcucha Zobowiązań? Problem w tym, że to na kliencie może spoczywać odpowiedzialność ułożenia ich w odpowiedniej kolejności. Samo to nie jest wadą, a nawet zamierzonym efektem gwarantującym elastyczność rozwiązania. Ważne jest jednak to, by klasy mogące występować w łańcuchu były jak najmniej zależne od innych i w dowolnej kolejności były w stanie się wykonać i nie powodować błędów.
Sporym kłopotem w debugowaniu aplikacji może okazać się fakt, że nie wiadomo, który obiekt z łańcucha faktycznie obsłuży żądanie i czy nie zatrzyma jego przetwarzania. W przypadku długiego łańcucha może to być problematyczne, szczególnie tam gdzie występuje wiele warunków.
Jak każde rozwiązanie, wzorzec projektowe Chain of Responsibility ma swoje wady, ale też zalety. Z racji tego, że klasy są dzielone na mniejsze i każda robi dokładnie jedną rzecz istnieje tutaj kilka korzyści. Po pierwsze, zgodność z regułą pojedynczej odpowiedzialności (single responsibility). Po drugie, spora elastyczność. No i w końcu, po trzecie – luźne powiązanie klas, które powinny być od siebie niezależne. Oczywiście ten mechanizm żyje też w zgodzie z regułą otwarte-zamknięte (open-closed), gdyż dodanie kolejnego obiektu do łańcucha jest rozszerzeniem, a nie modyfikacją istniejącego.
Przykładowa implementacja w PHP
Ten wzorzec projektowy jest stosunkowo łatwy w implementacji. W momencie, gdy zrozumiemy jego podstawowe założenia. Tak naprawdę wystarczy zapewnić prostą abstrakcję. Zostało to zobrazowane w klasie AbstractValidator
. Metoda next()
pozwoli ustawić kolejne oczko łańcucha. Metoda validate()
dostarcza już konkretną implementację i należy ją przesłonić w klasie potomnej wywołując na samym końcu logikę klasy bazowej parent::validate($data)
. Brak przesłonięcia metody spowoduje, że obiekt zostanie po prostu pominięty w łańcuchu. Brak odwołania do klasy rodzica spowoduje, że łańcuch się skończy. O ile nie wywoła ręcznie kolejnego.
Przeważnie dorzucam też pustą klasę, na zasadzie ValidatorChain
tak by bardziej czytelne było wywołanie samego łańcucha. Najpierw jest tworzony, a później dodawane są kolejne jego oczka. Wydaje mi się to dużo przyjemniejsze, niż zaczynanie od konkretnego oczka łańcucha typu new IsNotNull()
.
Z tak przygotowaną abstrakcją bardzo łatwo dodawać kolejne klasy walidacyjne. Naturalnie, być może w realnej aplikacji, kod wymagałby delikatnego podrasowania i dostosowania. Prawdopodobnie przydałoby się zwrócić też informację, dlaczego walidacja się nie udała. Dodatkowo, nie każdy walidator jest odporny na błędy. Gdyby jako pierwszy został wywołany IsGreaterThan
z parametrem innym, niż string to funkcja strlen()
zwróci wyjątek. Widać więc, że liczy się też tutaj kolejność, która w tym wypadku spoczywa na kliencie, co nie jest do końca optymalne. Rozwiązań pewnie jest kilka. Można także w tym miejscu dołożyć warunek sprawdzający, czy przekazany parametr jest stringiem albo też zamknąć wykonywanie kodu w bloku try catch
. Ewentualnie, można też zapewnić sprawdzenie typu jako obowiązkowe na początku łańcucha.
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\ChainOfResponsibility;
abstract class AbstractValidator
{
private ?self $next = null;
public function validate(mixed $data): bool
{
if (null === $this->next) {
return true;
}
return $this->next->validate($data);
}
public function next(self $next): self
{
$this->next = $next;
return $next;
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\ChainOfResponsibility;
final class ValidatorChain extends AbstractValidator
{
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\ChainOfResponsibility;
final class IsNotNull extends AbstractValidator
{
public function validate(mixed $data): bool
{
if (null === $data) {
return false;
}
return parent::validate($data);
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\ChainOfResponsibility;
final class IsString extends AbstractValidator
{
public function validate(mixed $data): bool
{
if (false === is_string($data)) {
return false;
}
return parent::validate($data);
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\ChainOfResponsibility;
final class IsLessThan extends AbstractValidator
{
public function __construct(
private int $length
) {}
public function validate(mixed $data): bool
{
if (strlen($data) >= $this->length) {
return false;
}
return parent::validate($data);
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\ChainOfResponsibility;
final class IsGreaterThan extends AbstractValidator
{
public function __construct(
private int $length
) {}
public function validate(mixed $data): bool
{
if (strlen($data) <= $this->length) {
return false;
}
return parent::validate($data);
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\ChainOfResponsibility\Test;
use DesignPatterns\Behavioral\ChainOfResponsibility\IsGreaterThan;
use DesignPatterns\Behavioral\ChainOfResponsibility\IsLessThan;
use DesignPatterns\Behavioral\ChainOfResponsibility\IsNotNull;
use DesignPatterns\Behavioral\ChainOfResponsibility\IsString;
use DesignPatterns\Behavioral\ChainOfResponsibility\ValidatorChain;
use PHPUnit\Framework\TestCase;
final class ChainTest extends TestCase
{
public function testCanRunValidationInChain(): void
{
$isNull = null;
$isNotString = 5;
$isNotLessThan10 = 'Test10test';
$isNotGreaterThan5 = 'Test5';
$isValid = 'Valid!';
$chainValidation = new ValidatorChain();
$chainValidation
->next(new IsNotNull())
->next(new IsString())
->next(new IsLessThan(10))
->next(new IsGreaterThan(5));
self::assertFalse($chainValidation->validate($isNull));
self::assertFalse($chainValidation->validate($isNotString));
self::assertFalse($chainValidation->validate($isNotLessThan10));
self::assertFalse($chainValidation->validate($isNotGreaterThan5));
self::assertTrue($chainValidation->validate($isValid));
}
}
Chain Of Responsibility – podsumowanie
Mechanizm ten przyda się w miejscach, gdzie logika ma wiele warunków i mogą one zależeć od siebie, ale nie muszą. Występują za to w różnych kombinacjach. Dodatkowo klientowi łatwo rozszerzyć istniejący kod i dołożyć własny obiekt do łańcucha.
W niektórych przypadkach można też zapewnić obowiązkową część łańcucha, bez której taki ciąg nie może powstać. Najczęściej na samym początku wywołań albo na jego końcu. Sporą elastyczność można też uzyskać przez zapewnienie możliwości zmiany łańcucha w trakcie wywoływania skryptu.
Ten wzorzec projektowy strukturą klas i tym jak jest realizowany przypomina Dekoratora. Chain of Responsibility może działać niezależnie i konkretne obiekty, mogą przerwać wywołanie, za to dekorator modyfikuje oryginalny obiekt.
Cześć, ciekawi mnie jak funkcja next zwracając wartość $next sprawia, że jest to referencja do wcześniej ustawionego jako prywatna wartość walidatora.
Nie do końca rozumiem pytanie, spróbuj proszę bardziej wyjaśnić.
Ale w skrócie, każde oczko łańcucha ma tylko relację do kolejnego. Jako pole prywatne, więc tylko ono ma do niego dostęp i wywołuje na nim metodę.
Fakt każde oczko ma relację do kolejnego – moje wątpliwości budzi jak kolejne oczka są ustawiane biorąc przykład z opisu:
$chainValidation = new IsNotNull();
$chainValidation
->next(new IsString())
->next(new IsLessThan(10))
->next(new IsGreaterThan(5));
A więc chainując po kolei funkcję next sprawiamy, że:
1. dla obiektu IsNotNull property $next ustawiane jest jako obiekt IsString
2. tutaj zaczyna się mój problem – kolejnym validatorem jest IsLessThan który jest ustawiany dla walidatora isString – tylko nie rozumiem, jak funkcja next realizuje to, że jest to dokładnie ten obiekt, który znajduje się w property $next validatora IsNotNull. (funkcja next ustawia jako property $next wartość podaną jako argument a następnie wartość tego argumentu zwraca – nie zwraca jednak „referencji” do pola $next)
Moje rozumowanie jest takie, że każdy kolejny validator jest zagnieżdżony w poprzednim:
IsNotNull {
private $next = IsString{
private $next = IsLessThan{
…
}
}
}
A wywołanie funkcji validate powoduje rekurencyjnie wywołanie każdego walidatora z łańcucha.
Nie wiem czy faktycznie dobrze nakreśliłem mój problem (prawdopodobnie jest trywialny i jednocześnie ciężko go opisać słownie 🙂 ).
No jest wewnątrz
return $next
, a to zwraca ten kolejny element łańcucha. Jeśli dobrze rozumiem Twoje pytanie to odpowiedź na to jest Fluent Interface – poczytaj o takim rozwiązaniu.