Wzorce projektowe – łancuch odpowiedzialności (chain of responsibility)

Dzisiejsze spotkanie z Zend’owym Front Controllerem i pluginami natchnęło mnie do kilku przemyśleń. Jednym z nich jest sposób jego działania. Aby w jakiś sposób ingerować w działanie FC należy albo napisać własną implementację jednej z koniecznych do życia przez FC klas (Dispatcher, Router etc) albo w przypadku mniejszych potrzeb stworzyć nowy plugin. Plugin’y są klasami, które reagują na określone zdarzenia typu preDispatch, postDispatch etc. Rozwiązanie to funkcjonuje i ma się dobrze. Wiem również ze słyszenia, że w innych frameworkach stosuje się podobne rozwiązania ale oparte np na sygnałach czy event handler’ach.

Ja natomiast chciałbym przedstawić trochę alternatywne podejście do problemu oparte na wzorcu chain of responsibility. Czym jest ten wzorzec ? Generalnie jest on podobny do wzorca obserwator.

class ObserverOne implements SplObserver {

    public function update(SplSubject $subject) {
        echo $subject->getMessage().'One';
    }

}

class ObserverTwo implements SplObserver {

    public function update(SplSubject $subject) {
        echo $subject->getMessage().'Two';
    }

}

class Subject implements SplSubject {
    /**
     *
     * @var array<SplObserver> 
     */
    protected $_observers;
    
    public function attach(SplObserver $observer) {
        $this->_observers[] = $observer;
    }

    public function detach(SplObserver $observer) {
        $mKey = array_search($observer, $this->_observers);
        if($mKey !== false){
            unset($this->_observers[$mKey]);
        }
    }

    public function notify() {
        foreach($this->_observers as $observer){
            $observer->update($this);
        }
    }
    
    public function getMessage(){
        return 'Aloha !';
    }

}

$oSubject = new Subject();
$oSubject->attach(new ObserverOne());
$oSubject->attach(new ObserverTwo());
$oSubject->notify();
//Aloha!One Aloha!Two

Oba wzorce dają szanse kilku handler’om na obsłużenie jakiegoś zdarzenia. We wzorcu obserwatora po każdym wystąpieniu zdarzenia wszystkie zarejestrowane handler’y są powiadamiane równocześnie i decydują czy obsłużyć zdarzenie czy nie. Natomiast we wzorcu łańcucha odpowiedzialności powiadamiane są one w określonej kolejności. W klasycznej odmianie tego wzorca tylko jeden handler obsługuje event:

abstract class ChainElement {

    protected $_next;

    public function setNext(ChainElement $element) {
        $this->_next = $element;
        return $this;
    }

    public abstract function run($event);
}

class HandlerOne extends ChainElement {

    public function run($event) {
        if ($event == 1) {
            echo 'matched one!';
            return;
        }
        if (isset($this->_next)) {
            $this->_next->run($event);
        }
    }

}

class HandlerTwo extends ChainElement {

    public function run($event) {
        if ($event == 2) {
            echo 'matched two!';
            return;
        }
        if (isset($this->_next)) {
            $this->_next->run($event);
        }
    }

}

class HandlerThree extends ChainElement {

    public function run($event) {
        if ($event == 3) {
            echo 'matched three!';
            return;
        }
        if (isset($this->_next)) {
            $this->_next->run($event);
        }
    }

}

$oElement1 = new HandlerOne();
$oElement2 = new HandlerTwo();
$oElement3 = new HandlerThree();

$oElement1->setNext($oElement2);
$oElement2->setNext($oElement3);

$oElement1->run(3);
//matched three!

Generalnie można by powiedzieć, że klasyczny chain of responsibility zastępuje wyrażenie switch w sposób bardziej wyrafinowany. Pewnie już do tego momentu co uważniejsi z Was policzyli że użyłem słowa klasyczny co najmniej 2 razy. Oznacza to, że musi istnieć też wersja “nieklasyczna”. Jak już mówiłem wcześniej, w wersji klasycznej przechodzimy do następnego elementu łańcucha tylko kiedy aktualny stwierdza, że nie potrafi obsłużyć żądania, w innym przypadku łańcuch kończy się na aktualnym elemencie. Klasycznym przykładem takiej implementacji wzorca jest Zend_Router, w którym route’y można spinać w łańcuchy, dopasowanie trwa do pierwszego dobrego wzorca.

Są generalnie dwa alternatywne podejścia, pierwsze, różni się tym, że zawsze przelatywany jest cały łańcuch. Natomiast drugie, jest wariacją pierwszego i polega na przeniesieniu logiki sterowania łańcuchem na zewnątrz – zewnętrzny kod decyduje o przebiegu łańcucha.

Moje alternatywne podejście do FrontControllera będzie opierać się na tej drugiej wersji implementacji alternatywnej wzorca łańcucha odpowiedzialności. W podejściu tym będziemy mieli klasę RequestHandler:

class RequestHandler {
    /**
     *
     * @var array
     */
    protected $_chain;

    public function addElement(IChainElement $element) {
        $this->_chain[] = $element;
    }

    public function handle($request, $server) {

        if (empty($this->_chain)) {
            throw new Exception('No processing elements defined');
        }

        $aData = array(
            'request' => array(
                'input' => $request,
                'server' => $server
            ),
            'response' => array(
                'headers' => array(),
                'content' => ''
            )
        );

        foreach ($this->_chain as $element) {
            $aData = $element->process($aData);
        }

        return $aData['response'];
    }
}

Obiekt tej klasy jest nadzorcą łańcucha. Do dodawania elementów do łańcucha służy metoda addElement, która przyjmuje argumenty typu IChainElement, jest to interfejs, który muszą implementować wszystkie elementy łańcucha:

interface IChainElement {
    public function process($input);
}

Następnie gdy dodamy już elementy, wywołujemy metodę handle($request, $server). Jak się pewnie domyślacie $request zwykle będzie superglobalną zmienną $_REQUEST natomiast $server to $_SERVER. Dlaczego tak to zaimplementowałem zamiast po prostu używać tych zmiennych wewnątrz metody ? Zwiększa to testowalność tej klasy i czytelność kodu. Metoda handle przelatuje po wszystkich elementach łańcucha, które przetwarzają w kolejności dodania nasz ‘request’ generując ‘response’.

Dla przykładu konkretnego działania stworzyłem dwa elementy łańcucha, które są niezbędne by osiągnąć minimalną funkcjonalność Front Controller’a. Są to router:

class SampleRouter implements IChainElement {

    protected $_mapping;

    public function __construct() {
        $aMapping = array(
            'foo*' => 'foobar',
            'baz*' => 'foobaz'
        );

        $this->_mapping = $aMapping;
    }

    public function process($input) {
        $sHref = $input['request']['server']['REQUEST_URI'];

        foreach ($this->_mapping as $pattern => $viewId) {
            if (preg_match("/$pattern/i", $sHref)) {
                $input['request']['viewId'] = $viewId;
                return $input;
            }
        }
        $input['request']['viewId'] = '404';
        return $input;
    }

}

oraz Dispatcher:

class SampleDispatcher implements IChainElement {

    public function process($input) {
        //If the content exists - cache etc
        if(!empty($input['response']['content'])){
            return $input;
        }

        if($input['request']['viewId'] == '404'){
            $input['response']['headers'][] = 'HTTP/1.0 404 Not Found';
            $input['response']['content'] = 'Sorry page not found';
            return $input;
        }

        if($input['request']['viewId'] == 'foobar'){
            $input['response']['content'] = 'Foobar page!';
            return $input;
        }

        if($input['request']['viewId'] == 'foobaz'){
            $input['response']['content'] = 'FoobaZZZ page!';
            return $input;
        }

        return $input;
    }

}

Użycie:

$oHandler = new RequestHandler();
$oHandler->addElement(new SampleRouter());
$oHandler->addElement(new SampleDispatcher());
$aResponse = $oHandler->handle($_REQUEST, $_SERVER);

var_dump($aResponse);

Generalnie są to najbanalniejsze przykłady tych komponentów. Router dopasowuje dwie przykładowe ścieżki po wyrażeniach regularnych foo* i baz* i zwraca id widoku, który powinien zostać wyświetlony. Jeżeli adres nie pasuje do żadnego z wyrażeń zwracane jest id widoku 404. Dispatcher natomiast na podstawie id widoku ustawia odpowiednie nagłówki i content który będzie ‘wyechowany’. Oczywiście w rzeczywistości Dispatcher wybrał by odpowiedni kontroler, który dobrał by się do modeli i wyciągną dane i na tej podstawie stworzył odpowiedź.

Podsumowując – metoda działania zaprezentowanego komponentu jest generalnie prosta jak budowa czołgu t-52 😉 jednak moim zdaniem bardzo efektywna i dużo lżejsza od sposobu działania Zendowego FC. Unifikuje ona wszystkie komponenty, które mogą brać udział w tworzeniu odpowiedzi na żądanie użytkownika. Można sobie wyobrazić inne elementy łańcucha takie jak Cache, czy sprawdzanie uprawnień w ACL-u. Co więcej, polepsza ona testowalność kodu oraz promuje niezależność klas (w sensie polegania na innych klasach). Każda część łańcucha bierze tylko odpowiedzialność za swoją część obsługi żądania.

Tak na koniec, jeżeli chcielibyście przetestować te przykłady to pamiętajcie o odpowiednim pliku .htaccess:

RewriteEngine on
RewriteRule !public index.php

Jestem bardzo ciekaw Waszej opinii na temat takiej formy implementacji Front Controller’a.

  1. Alan Gabriel Bem says:

    To co przedstawiłeś to raczej wzorzec Intercepting filter. Z powodzeniem funkcjonuje on w PHPowych frameworkach opartych o Mojavi – Symfony i Agavi.

    Rożnica jest taka, że w chain of resposiblity można sterować workflowem.

  2. Wojciech Soczyński says:

    Po głębszym research’u muszę się z tobą zgodzić, jednak tak czy inaczej uważam, że jest to jakaś odmiana chain of responsibility. Mógłbyś podać jakieś linki do zastosowania tego wzorca przy przetwarzaniu request’u w tych frameworkach ? Niestety ja wygoglowałem tylko przykłady, z obu frameworków, które zajmują się czymś innym…

  3. Alan Gabriel Bem says:

    Co nie co o filtrach w Symfony.

    Agavi niestety nadal cierpi na brak rozbudowanej dokumentacji, więc mogę odesłać tylko tutaj – action_filters.xml i global_filters.xml

  4. Wojciech Soczyński says:

    Poczytałem coś o tych filtrach w Symfony i na pierwszy rzut oka działają podobnie do mojego rozwiązania. Jednak z tego co widzę, to jest już bardziej taki właśnie chain of responsibility – filter może przerwać łańcuch, poza tym te filtry dobierają się do infrastruktury frameworka (getContext etc) przez co nie są tak niezależne jak u mnie.

  5. wookieb says:

    Pomysł na router dobry, ponieważ w roli obsługi zdarzeń wolę standardowe goEventDispatchera.

  6. js says:

    mała uwaga techniczna:
    $mKey = array_search($observer, $this->_observers);
    if($mKey !== false)
    – “Począwszy od PHP 4.2.0, zamiast FALSE, array_search() zwraca NULL w przypadku niepowodzenia.” // manual

  1. There are no trackbacks for this post yet.

Leave a Reply

Notify me of followup comments via e-mail. You can also subscribe without commenting.