Codice: system wtyczek

Jednym z kluczowych elementów, nad którym pracowałem (i nadal pracuję) przy tworzeniu Codice jest wbudowana obsługa dla rozszerzeń. Zagadnienie, na którym nie raz zjadałem sobie zęby lub, co jeszcze gorsze, cierpiałem przez odkładanie jego implementacji na wieczne nigdy. Do dziś po dysku wala mi się jakaś wczesna wersja Codice… w bodajże 3 odmianach, różniąch się drobnymi detalami. Niby drobiazg, ale nie chcę nawet zaczynać opisywania tego, jak męczące jest równoległe utrzymanie każdego z wariantów.

Skoro próbowałem już tak wiele razy i obecne podejście do Codice miało być wreszcie tym właściwym, musiałem wreszcie zmierzyć się z tym zagadnieniem. Jednocześnie musiałem dopuścić do głosu nieco pragmatyzmu i wybrać podejście, które będzie adekwatne do mojego doświadczenia w tym temacie i na którym, mówiąc wprost, nie polegnę po raz kolejny.

W tym wpisie przedstawię najbardziej podstawowe elementy systemu wtyczek w moim projekcie, mianowicie akcje i filtry. Wielu z Was zapewne zna ten koncept z innych aplikacji (na przykład WordPressa). Wszystko opiera się na zdefiniowanych w rdzeniu hookach, do których możemy podpiąć własne zdarzenia po to, by wykonały się w określonej sytuacji. Pierwszy z brzegu przykład: rejestrujemy akcję dodającą wiadomość powitalną dla zdarzenia (hooka) user.created - utworzenia nowego użytkownika.

Innym sposobem rozszerzalności mogą być filtry - ponownie, zdefiniowane w rdzeniu miejsca, w których istotne wartości możemy później modyfikować (filtrować) za pomocą podpiętych callbacków. W przypadku Codice jest to między innymi zapytanie używane przy wyszukiwaniu notatek. Wszystko z myślą o wtyczkach, które chciałyby je zmienić np. tak aby wykorzystywało inne pola lub też wyszukiwanie pełnotekstowe z MySQL.

Nie będę więcej przedłużał, przejdę do prezentowania stojącego za tym kodu. Jeśli jakiś koncept wciąż nie jest jasny, nie martwcie się, na samym końcu zalinkuję do testów dla tej części kodu, wydaje mi się, że one powinny idealnie zobrazować zamysł i planowany sposób ich wykorzystania.

<?php

namespace Codice\Support\Traits;

use Exception;
use Log;

trait Hookable
{
    /**
     * @var array Holds all currently registered hookables
     */
    protected static $hookables;

    /**
     * Register new hookable for given hook.
     *
     * @param string $hook Name of the hook
     * @param string $hookableName Name of the hookable, must be unique within a hookable of given type
     * @param callable $callable Callable containing code to run
     * @param int $priority Order of calling hookables, when priority is equal order is undefined
     * @return bool
     */
    public static function register($hook, $hookableName, callable $callable, $priority = 10)
    {
        $hookableType = self::getHookableType();

        if (self::isRegistered($hook, $hookableName)) {
            Log::warning("$hookableType '$hookableName' was already registered within '$hook' hook and has been overwritten.");
        }

        self::$hookables[$hook][$hookableName] = [
            'priority' => $priority,
            'callable' => $callable,
        ];

        return true;
    }

    /**
     * Check whether hookable of given name has been registered within a specified hook.
     *
     * @param string $hook Name of the hook
     * @param string $hookableName Name of the hookable within a hook
     * @return bool
     */
    public static function isRegistered($hook, $hookableName)
    {
        return isset(self::$hookables[$hook][$hookableName]);
    }

    /**
     * Deregister hookable from given hook.
     *
     * Deregistration must happen before call() method is run to have an effect.
     *
     * @param string $hook Name of the hook
     * @param string $hookableName Name of the hookable
     * @return bool
     */
    public static function deregister($hook, $hookableName)
    {
        unset(self::$hookables[$hook][$hookableName]);

        return true;
    }

    /**
     * Get (sorted) list of all hookables assigned to a hook.
     *
     * @param string $hook Name of the hook
     * @return array
     */
    private static function getHookables($hook)
    {
        $hookables = isset(self::$hookables[$hook]) ? self::$hookables[$hook] : [];

        return self::sortHookables($hookables);
    }

    /**
     * Sort hookables by their priority.
     *
     * @param array $hookables Array of unsorted hookables
     * @return array
     */
    protected static function sortHookables($hookables)
    {
        usort($hookables, function ($a, $b) {
            if ($a['priority'] == $b['priority']) {
                return 0;
            }

            return $a['priority'] < $b['priority'] ? -1 : 1;
        });

        return $hookables;
    }

    /**
     * Return human readable name of hookable
     *
     * @throws Exception
     * @return string
     */
    protected static function getHookableType()
    {
        throw new Exception('getHookableType() must be implemented');
    }
}

Zaczynamy od… traita? Dokładnie tak. Ponieważ główną różnicą między akcjami a filtrami jest to, że pierwsze nie mogą zwracać wartości, a drugie muszą to robić, możemy śmiało stwierdzić że spora część kodu będzie się powtarzać. Dlatego też wszystkie wspólne funkcjonalności, takie jak rejestrowanie konkretnych zdarzeń, wywoływanie ich czy potrzebne w obu wypadkach metody pomocnicze zostały umieszone w jednym miejscu, jak pokazano wyżej.

Jak sami zobaczycie, dzięki temu kod odpowiedzialny za działanie akcji i filtrów będzie naprawdę krótki i prosty. Jest to kolejno:

<?php

namespace Codice\Plugins;

use Codice\Support\Traits\Hookable;

class Action
{
    use Hookable;

    /**
     * Call all actions registered for a given hook.
     *
     * @param string $hook Name of the hook
     * @param array $parameters Parameters which will be passed to callback
     */
    public static function call($hook, array $parameters = [])
    {
        $actions = self::getHookables($hook);

        foreach ($actions as $action) {
            call_user_func($action['callable'], $parameters);
        }
    }

    /**
     * @inheritdoc
     */
    protected static function getHookableType()
    {
        return 'Action';
    }
}

oraz

<?php

namespace Codice\Plugins;

use Codice\Support\Traits\Hookable;

class Filter
{
    use Hookable;

    /**
     * Call all filters registered for a given hook.
     *
     * @param string $hook Name of the hook
     * @param mixed $value Value to run filters on
     * @param array $parameters Parameters which will be passed to callback
     * @return mixed Filtered value
     */
    public static function call($hook, $value, array $parameters = [])
    {
        $filters = self::getHookables($hook);

        foreach ($filters as $filter) {
            $value = call_user_func($filter['callable'], $value, $parameters);
        }

        return $value;
    }

    /**
     * @inheritdoc
     */
    protected static function getHookableType()
    {
        return 'Filter';
    }
}

Jak na dłoni widać teraz różnice w działaniu obu hookowalnych (zaczepialnych?) elementów. Tak więc raz jeszcze - w obu wypadkach wykonujemy wszystkie zarejestrowane (przez pluginy bądź samą aplikację) zdarzenia, czyniąc to według określonego priorytetu. Akcje po prostu wykonują kod, a filtry kolejno modyfikują przekazaną wartość.

Jak już mówiłem, najszybszym sposobem na zobaczenie ich w akcji jest spojrzenie na testy - osobno dla akcji oraz filtrów. Przykłady użycia podstawowego API do wtyczek przez samą aplikację znajdują się tutaj, nazwy plików powinny być raczej jasne ;)


Powyższy wpis przedstawia część doświadczeń zdobytych przy tworzeniu projektu Codice. Po więcej szczegółów zapraszam do pierwszego wpisu oraz na codice.eu. Poza planowaną od dawna serią jest to też realizacja wymogów konkursu "Daj Się Poznać". Więcej o nim możecie przeczytać w tym wpisie. Po nowe materiały poświęcone tworzeniu projektu Codice zapraszam w każdy wtorek. Ponadto więcej wpisów, poświęconych temu projektowi lub ogólnej tematyce bloga przeczytacie co sobotę. Miejmy nadzieję, że tym razem już zgodnie z harmonogramem!

Komentarze wyłączone

Możliwość komentowania na blogu została wyłączona. Zapraszam do kontaktu na Twitterze, Facebooku lub poprzez formularz, o ten tutaj. Do usłyszenia!