Pluginizowanie aplikacji PHP

28
gru/09
8

Są 3 główne cechy programisty: ambicja, niecierpliwość i lenistwo. Chcemy podejmować się wspaniałych wyzwań, tworzyć aplikacje szybko i się przy tym nie napracować. Nie brzmi to zbyt dobrze szczerze powiedziawszy. Gdybym miał narażać życie innych będąc chirurgiem, po wypowiedzeniu tych słów już płonął bym na stosie. Na szczęście w inżynierii oprogramowania mamy takie cuda jak pluginy. I nikt przy tym nie umiera.

Pozwalając sobie na drugi wstęp powiem, że jeżeli istnieje gdzieś Święty Graal programowania to jest nim inteligentne generowanie kodu. Pisanie bez używania palców, myślami – przy kawce, czy porannej gazecie (o puszczaniu dymka z drewnianej fajeczki nie wspominając). Jeśli jest jakiś prawie-prawie Święty Graal, taki realny, to w tym wypadku jest nim sprawny systemu pluginów, który pozwala na składanie aplikacji z klocków. Tak by można było konstruować aplikację na zasadzie: tutaj system zarządzania użytkownikami, tu logowania, tam zarządzanie stronami, plikami, sklep, blogi i długo wymieniać. Wszystko w postaci perfekcyjnie autonomicznych klocków, łatwo rozszerzalnych, z prostym sposobem wprowadzania drobnych zmian do wybranych fragmentów. I oczywiście z zarządzaniem zależnościami. I wersjonowanie. I auto-update. I śniadanie do łóżka.

Zejdźmy na ziemię i siądźmy przy jakimś PHPowym IDE. Jest kilka prostych „patentów” na zbudowanie sobie takiego prostego zestawu komponentów, które rozszerzą bądź podmienią funkcjonalność. Oczywiście rzecz bez przestrzeni nazw wprowadzonej dopiero w 5.3 jest dość utrudniona lecz nie będziemy się tym faktem zbytnio przejmować. Użyjemy najpierw osławionego, powszechnie znanego i szeroko stosowanego (no, może trochę podkoloryzowałem) wzorca wstrzykiwania zależności. Szczegółowe informacji na jego temat uzyskasz w dłuższym cyklu mu poświęconym na blogu xoft.pl (główne skrzypce gra tam Java) lub w długim wstępie na stronie Symfony Components. Choć myślę, że nie jest on na tyle złożonym rozwiązaniem by zarzucać na swoją głowę te opasłe w akapity artykuły.

Zacznijmy od tego, że używamy podziału MVC. Decydentem jest jakiś Dispatcher, który ładuje klasę kontrolera i uruchamia wybraną akcję kryjącą się w jednej z metod:

class NewsController {
  public function listAction() {}
}

class Dispatcher {
  public function dispatch( $controller, $action, array $params ) {
    $class = $controller.'Controller';
    $method = $action.'Action';
    $obj = new $class();
    call_user_func_array(array($obj,$method),$params);
  }
}

Przykładowy fragment kodu można krytykować na wiele sposobów, szczególnie jeśli chodzi o walidację i chciałem już z góry zaznaczyć, że ten i kolejne będą fragmentami czysto poglądowymi. Jeśli chodzi o nasz cel to chcielibyśmy zaprogramować aplikację w taki sposób, by zmiany implementacji w poszczególnych akcjach były realizowane w ramach pluginów. Przykładowo chcielibyśmy zmienić zachowanie akcji „listAction” by ta miała możliwość paginacji czy dodać inny sposób sortowania newsów. Niestety kilka rozwiązań odrzucamy już z góry i nie możemy:

  • Nadpisać istniejących klas i dodać do nich nowego kodu, gdyż z góry zakładamy, że nowa funkcjonalność ma być dodawana i odbierana dynamicznie.
  • Manipulować „include_path” ustawiając ścieżkę z nową wersją klasy przed ścieżką z wersją aktualną. PHP znajdowałby by plik z nową wersją zamiast starej, ale to uniemożliwiłoby by dziedziczenie (bo jak dziedziczyć „NewsController” po „NewsController” tylko z innego pliku?). Cały stary kod należało by przenieść metodą kopiuj-wklej do klasy naszego „pluginu”. Raczej nie brzmi to DRY, KISS i inne tam literki.

Wstrzykiwanie zależności, czym zajmuje się wykorzystywany przez nas wzorzec, umożliwia nam zmiany zależności między klasami w trakcie działania naszej aplikacji. Zamiast tworzenia instancji za pomocą słowa kluczowego „new”‚ wykorzystamy kontener zależności, który będzie tworzył obiekty w naszym imieniu. Najważniejszą zaletą takiego rozwiązana jest fakt iż rezygnujemy z bezpośredniego tworzenia obiektów i możemy zarządzać jakie klasy zostaną wstrzykiwane do naszej aplikacji, a jakie nie.

Przykładowo ustalamy, że chcemy w naszej aplikacji zastąpić klasę „DbStorage” klasą „CacheStorage”:

  1. W pewnym miejscu naszego kodu żądamy od kontenera utworzenia obiektu klasy „DbStorage”,
  2. Kontener znajduje informację,że ta klasa ma być podmieniana na inną,
  3. Zwraca transparentnie obiekt klasy „CacheStorage” zamiast tej żądanej.

Ten złośliwy sposób pozwala na szybką i łatwą podmianę funkcjonalności, co znajdzie zastosowanie w naszym przypadku. Prosty kontener może wyglądać tak:

class DiContainer {
  // Mapa "klasa_stara => nowa_klasa"
  static protected $_map = array();

  static public function setMap( $oldClass, $newClass ) {
    self::$_map[$oldClass] = $newClass;
  }

  static public function getNew( $class ) {
    if( isset(self::$_map[$class]) ) {
      $class = self::$_map[$class];
    }
    return new $class;
  }
}

// A poniżej zmodyfikowany dyspozytor
class Dispatcher {
  public function dispatch( $controller, $action, array $params ) {
    $class = $controller.'Controller';
    $method = $action.'Action';
    $obj = DiContainer::getNew($class);
    return call_user_func_array(array($obj,$method),$params);
  }

Już wcześniej mieliśmy jedno uniwersalne miejsce, gdzie tworzyły się instancje kontrolerów, więc kontener zintegrował się znakomicie. Jednak fakt, że sposób pluginizowania zintegrował się sprawnie wcale nie oznacza, że jest idealny. Jest on bardzo prymitywny, gdyż zakłada, że będziemy modyfikować funkcjonalność jednej całej metody, a nie drobnej jej części. Wiąże się to bezpośrednio z faktem dziedziczenia po klasie, której będziemy rozszerzać. Nie możemy „wgryźć się” w poszczególne linie czy fragmenty kodu wewnątrz metod i zmienić ich działania. Nawet jeśli wykorzystamy dobrą praktykę i będziemy tworzyć jak największą ilości atomowych metod czy funkcji, to problem leży gdzie indziej. Nie możemy dziedziczyć wielokrotnie co oczywiście wiąże się z ograniczeniami samego języka. Z drugiej strony klasy pluginów nie wiedzą o swoim istnieniu, chcemy by były autonomiczne, więc dziedziczyć po sobie nie mogą i nie możemy ich krzyżować. Dziedziczyć możemy więc tylko po klasie bazowej, której funkcjonalność modyfikujemy. Gdybyśmy chcieli zastąpić różne metody jednej klasy jednocześnie, nie uda nam się tego zrobić z wykorzystaniem klasycznego dziedziczenia PHP. Na marginesie: tak, wiem, że możemy stworzyć sobie mechanizm, który zależnie od żądanej akcji wczytywał by odpowiednią klasę kontrolera, ale pragniemy stworzyć rozwiązanie uniwersalne, a nie nakładać „łaty” w każdym komponencie, który kiedykolwiek napiszemy.

Znając ograniczenia postaramy się rozwiązać problem. Pomimo luźnego wiązania między klasami, na sztywno ustaliliśmy, że pewna funkcja ma uruchamiać się w określonej sytuacji. Musimy z tego zrezygnować i zastosować programowanie zdarzeniowe. Brzmi groźnie, ale w ostatecznym rozrachunku jest bardzo intuicyjne. Przyjmujemy w naszej aplikacji, że wykonywanie funkcji, metod czy akcji to odpowiedź na pewne zdarzenia, które występują w systemie lub pochodzą z zewnątrz. Najbardziej podstawowe zdarzenie to wywołanie HTTP, dzięki któremu nasza aplikacja się uruchamia. Kolejne to wejście w zdarzenie rozpoznania jaki kontroler chcemy wybrać, która zwraca sam kontroler. Zagłębiając się w warstwę logiki możemy wskazać także inne, tu przykładowo:

  1. Dodania, modyfikacji, usunięcia treści, np. newsa, komentarza czy zdjęcia.
  2. Modyfikacji ustawień, przełączenia opcji konfiguracyjnych.

Musimy pozwolić by reakcje na te zdarzenia, którymi są wywołania funkcji czy metod, mogły rejestrować się w jakimś globalnym kontenerze. Docelowo chcemy by do, np. zdarzeń usunięcia newsa można było podpiąć:

  1. Usunięcie wszystkich plików powiązanych.
  2. Wyczyszczenia cache.
  3. Zalogowania informacji w logach.
  4. Wysłania informacji do administratora.

Każda z tych reakcji ma być zupełnie autonomiczna. Z każdej z nich chcemy móc zrezygnować i każdą nową podpiąć w dowolnym momencie. To się nazywa architektura typu plug-in! Prosty kontener zdarzeń wyglądałby pewnie tak:

class Events
{
    // lista wywołań przypisanych do poszczególnych nazw zdarzeń
    static private $_data = array();

    // przypisujemy wywołanie jako reakcję na pewne zdarzenie
    static public function connect( $eventName, $callback ) {
        self::$_data[$eventName][] = $callback;
    }

    // odpalamy zdarzenie, wywołujemy wszystkie reakcje chyba,
    // a jeśli, któreś z wywołań zwróci wartość przerywamy
    // łańcuch i zwracamy wartość zwróconą przez to wywołanie
    static public function trigger( $eventName, $params ) {
        if( !isset(self::$_data[$eventName]) ) {
            return null;
        }
        // sprytnie przekażemy nazwę zdarzenia jako
        // pierwszy parametr
        $params = array_unshift($params,$eventName);
        foreach( self::$_data[$eventName] as $callback ) {
            $result = call_user_func_array($callback,$params);
            if( $result !== null ) {
                return $result;
            }
        }
    }
}

I tak oto mamy klasę statyczną z dwiema metodami do łączenia wywołań z pewnymi zdarzeniami i odpalania zdarzeń. Gdzieś wyżej pisałem o usunięciu newsa jako o przykładowym zdarzeniu, tak więc:

// nasze wywołania
function delete_cache( $eventName, $newsId ) {}
function log_event( $eventName, $itemId ) {}
function mail_event( $eventName, $itemId ) {}

// łączymy wywołania ze zdarzeniami
Events::add('news.deleted','delete_cache');
Events::add('news.deleted','log_event');
Events::add('news.deleted','mail_event');

// tu usuwamy wpis newsa z bazy i odpalimy nasze zdarzenie
Events::trigger('news.deleted',array( $newsId ));

Rozwiązanie, który stworzyliśmy pozwala nam na uruchamianie pewnych reakcji na zdefiniowane zdarzenia, ale może nam także służyć jako elastyczny substytut samego wywołania jakiejś funkcji. Wróćmy do naszego głównego dyspozytora akcji i wprowadźmy do niego zmiany:

class Dispatcher {
  public function defaultControllerLoad( $eventName, $controllerName )
  {
    $class = $controllerName.'Controller';
    return DiContainer::getNew($class);
  }

  public function defaultActionCall( $eventName, $controller, $action )
  {
    $method = $action.'Action';
    return call_user_func_array(array($controller,$method), $params);
  }

  public function dispatch( $controller, $action, array $params ) {
    // dopisujemy do zdarzenia domyślną funkcję znajdującą kontroler
    Events::add('controller.load',array($this,'defautControllerLoad'));
    // i domyślną funkcję odpalającą akcję
    Events::add('action.call',array($this,'defaultActionCall'));
    // dopisane reakcje mają zwrócić instancję kontrolera
    $obj = Events::trigger('controller.load', array($controller));
    // i uruchomić akcję
    return Events::trigger('action.call', array($obj,$action));
  }
}

Namieszaliśmy strasznie. Szczególnie widać to, gdy spojrzymy na pierwszą wersję klasy z początku posta. Jednak jak już coś robić to porządnie. Doszliśmy do tego, że:

  1. Zamknęliśmy tworzenie instancji kontrolera w funkcji, która domyślnie jest metodą dyspozytora, ale może być podmieniona.
  2. Zamknęliśmy wywołanie akcji na przekazanym obiekcie kontrolera i jak wyżej, ustawiliśmy też metodę domyślną.

Uważam, że mamy już pełną swobodę w podmienianiu funkcjonalności. W temacie pluginów pozostały nam jeszcze filtry, czyli wywołania, które udekorują bądź zmodyfikują przekazane parametry. Chcielibyśmy by przygotowaną przez nas porcje danych dowolna funkcja mogła zmodyfikować. Przykładowo mając przygotowaną tablicę z danymi newsa, od treści po daty dodania, chcemy ją udostępnić pluginom do modyfikacji by, np. mogły przeparsować BBcode czy zamienić linki na tagi HTML.

Czym filtry różnią się od aktualnej implementacji zdarzeń? Pętla, która uruchamia wywołania przypisane do zdarzenia nie zostaje przerwana, gdy na wyjściu funkcji znajdzie się jakaś wartość. W przypadku filtrów na wyjściu każdej z przypisanych wywołań zawsze musi być zmodyfikowana wartość z wejścia – na tym polega cała zabawa w filtrację. Do naszego kontenera zdarzeń wystarczy dopisać odpowiednią metodę, nie będziemy robili tego tutaj, chciałem wskazać dziś tylko zastosowanie, problem i rozwiązanie:

Events::addFilter('news.data','news_content_link_to_a');
$news = array(
    'content' => 'My http://google.pl link',
    'added_at' => time()
    );
$news = Events::filter('news.data',$news);

Czy temat już wyczerpaliśmy? Nie. Jest to tylko jedno z podejść implementacji architektur oprogramowania, które mogą ewoluować bez ingerencji w kod podstawowy. Jest mi najbliższe ze względu na naturalność wykorzystania wbudowanych własności w PHP, które mogę bez wyrzutów sumienia używać. Paradygmat zbudowany wokół takich idei programowania to programowanie aspektowe, do którego zapoznania zachęcam serdecznie. Jeśli temat Was zainteresował i szukacie narzędzi na których chcielibyście oprzeć swoją kolejną aplikację to polecam obserwować projektowanie Symfony 2.0 i już istniejące komponenty na, których bazować będzie nowe wydanie tego frameworka.

Komentarze (8) Odniesienia (0)
  1. Marian
    10:06 on Grudzień 29th, 2009

    Osobiście uważam tworzenie obiektów w postaci new $class(); za dość brzydkie.
    O wiele ładniej wygląda coś takiego:
    $reflection = new ReflectionClass( $class);
    $obj = $reflection->newInstance( $args);
    do tego można się upewnić czy dana klasa na pewno jest tym co chcemy (np przez $reflection->implementsInterface( ‚Controller’); ).

    dla tych którzy lubią jednoliniowe rozwiązania:
    $obj = call_user_func_array( array( new ReflectionClass( $class), ‚newInstance’), $args);

    Poza tym artykuł ciekawy.

  2. Adawo
    10:22 on Grudzień 29th, 2009

    A zawsze zastanawiałem się jak to w WordPressie zrobili, teraz już wiem :) Ale jedna uwaga: w pierwszej wersji klasy DiContainer nie powinno być czasem:

    static protected $_map = array();

    zamiast:

    static protected function $_map = array();

    ;)

  3. Zyx
    13:53 on Grudzień 29th, 2009

    Odpowiednia architektura nie rozwiąże nam wszystkich problemów. Możemy mieć narzędzia takie, jak mechanizm filtrów, wtyczek itd., ale trzeba pamiętać, że wciąż są to jedynie narzędzia, które trzeba jeszcze umieć wykorzystać. Rzadko kiedy zdarza się, by wtyczki były zupełnie niezależne od całego środowiska i swego najbliższego otoczenia, dlatego na etapie ich projektowania musimy przewidzieć możliwe/spodziewane interakcje i korzystając z omówionych we wpisie narzędzi, zaprogramować je. Dopiero po uwzględnieniu tego możemy mówić o prawdziwie modularnej aplikacji.

    Ciekawym pomysłem, jeśli chodzi o same wzorce projektowe, jest hierarchiczny MVC. Raczej nie spotyka się go we frameworkach PHP, lub też rozwiązuje się ten problem w inny sposób (łańcuchy akcji itd.), ale miałem okazję pisać aplikację z jego wykorzystaniem i dało to naprawdę fajny rezultat.

  4. Damian Tylczyński
    13:27 on Grudzień 30th, 2009

    Jednym ze szkieletów modularnych aplikacji jest na pewno FLOW3 (http://flow3.typo3.org). Przeglądałem jego dokumentację i na pierwszy rzut oka przeraził mnie kompleksowością rozwiązań AOP. Zaryzykuję stwierdzenie, że to nowa jakość wśród frameworków dla PHP.
    Literówki poprawiłem, dziękuję za ich wskazanie :)

  5. cojack
    11:40 on Grudzień 31st, 2009

    Ciekawe, a nie wiem czy kolega widział moje podejście do problemu, może nie tak szczegółowo opisane, ale podeśle linka: http://cojack.os-cms.pl/dispatcher-dyspozytor/241

    Pozdrawiam.

    P.S. raz piszesz funkcje w CaMeLL pattern, a raz jak Ci się podoba, trochę usystematyzować się proszę ;)

  6. Damian Tylczyński
    11:58 on Grudzień 31st, 2009

    Ładne i proste rozwiązanie, może trochę nazbyt proste, ale na pewno elegancko spełnia swoje zadanie. Co do post scriptum, muszę nadmienić, że nazwy są jednak usystematyzowane: klasy i ich metody są pisane „wielbłądzim”, funkcje globalne zaś pisane małymi literami z użyciem podkreślenia, podobnie jak w samej bibliotece standardowej PHP.

  7. Tomasz Kowalczyk
    18:25 on Styczeń 3rd, 2010

    Miło wygląda, trzeba będzie sprawdzić, chociaż pewnie i tak zrobię po swojemu. ;]

  8. Damian Tylczyński
    13:36 on Styczeń 18th, 2010

    Szczerze polecam komponenty Symfony, kawał świetnego softu.

Niestety, skomentowanie tego wpisu jest niemożliwe.

No trackbacks yet.

Optimization WordPress Plugins & Solutions by W3 EDGE