Chain Of Responsibility (Łańcuch zobowiązań)

chain of responsibility uml

Opis

Chain Of Responsibility (Łańcuch Zobowiązań) należy do grupy wzorców behawioralnych. Gwarantuje obsługę żądania przez łańcuch wywołań, gdzie 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 go 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, która będzie wymagała 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 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 (SRP). 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 (OCP), gdyż dodanie kolejnego obiektu do łańcucha jest rozszerzeniem, 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ę jak 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 wywołując kolejny. Brak odwołania do klasy rodzica spowoduje, że łańcuch się skończy, chyba że ręcznie zostanie wywołany kolejny.

Z tak przygotowaną abstrakcją bardzo łatwo dodawać kolejne klasy walidacyjne. Oczywiście, być może w realnej aplikacji kod wymagałby delikatnemu podrasowaniu i dostosowaniu. 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 bądź 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 ($this->next === null) {
            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;

class IsNotNull extends AbstractValidator
{
    public function validate(mixed $data): bool
    {
        if ($data === null) {
            return false;
        }

        return parent::validate($data);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\ChainOfResponsibility;

class IsString extends AbstractValidator
{
    public function validate(mixed $data): bool
    {
        if (is_string($data) === false) {
            return false;
        }

        return parent::validate($data);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\ChainOfResponsibility;

class IsLessThan extends AbstractValidator
{
    private int $length;

    public function __construct(int $length)
    {
        $this->length = $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;

class IsGreaterThan extends AbstractValidator
{
    private int $length;

    public function __construct(int $length)
    {
        $this->length = $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 PHPUnit\Framework\TestCase;

final class ChainTest extends TestCase
{
    public function testCanRunValidationInChain(): void
    {
        $isNull = null;
        $isNotString = 5;
        $isNotLessThan10 = 'Test10test';
        $isNotGreaterThan5 = 'Test5';
        $isValid = 'Valid!';

        $chainValidation = new IsNotNull();
        $chainValidation
            ->next(new IsString())
            ->next(new IsLessThan(10))
            ->next(new IsGreaterThan(5));

        $this->assertFalse($chainValidation->validate($isNull));
        $this->assertFalse($chainValidation->validate($isNotString));
        $this->assertFalse($chainValidation->validate($isNotLessThan10));
        $this->assertFalse($chainValidation->validate($isNotGreaterThan5));
        $this->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ć. Na przykład na samym początku albo na końcu wywołań. 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.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.