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ń. 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.

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