Pull to refresh

Диспетчер событий с фильтрацией по шаблону

Reading time5 min
Views5.5K
Недавно у меня появилась необходимость в простом и функциональном диспетчере событий. После непродолжительных поисков на Packagist-е я нашел пакет Evenement, который почти полностью подходил под мои требования. Но все же отбор он не прошел из-за двух параметров:
  • была нужна возможность порождать события по шаблону;
  • интерфейс библиотеки визуально не понравился.

Конечно же, я принял решение доделать и причесать библиотеку «под себя».

Порождение событий по шаблону


Мне нужна была возможность с помощью шаблона порождать нужные события, имена которых представляют собой иерархические ключи (foo.bar.baz).
Например, для такого списка событий:
  • some.event
  • another.event
  • yet.another.event
  • something.new

Нужно породить все события, заканчивающиеся на «event». Или начинающиеся на «yet» и заканчивающиеся на «event», и не важно, что в середине.

Eventable


После небольших размышлений я принялся к реализации библиотеки, основываясь на ранее найденном Evenement.

Диспетчер событий

Думая над интерфейсом, я поглядывал на jQuery и его методы работы с событиями: on(), one(), off(), trigger(). Такой подход пришелся мне по душе по большей части из-за краткости и лаконичности.

В итоге получился следующий интерфейс:
Dispatcher {
    public Dispatcher on(string $event, callable $listener)
    public Dispatcher once(string $event, callable $listener)
    public Dispatcher off([string $event [, callable $listener ]])
    public Dispatcher trigger(string $event [, array $args ])
    public Dispatcher fire(string $event [, array $args ])
}

Так, метод off() может принимать два параметра, и тогда будет удален конкретный обработчик указанного события. Один параметр — в этом случае будут удалены все обработчики события. Или не принимать никаких параметров, что означает удаление всех событий и подписанных на них обработчиков.

trigger() принимает шаблон ключа события, и порождает все подходящие события.
fire() в свою очередь порождает одно, конкретно заданное событие.

Если обработчик должен быть выполнен единожды, он вешается на событие методом once()

Dispatcher.php
namespace Yowsa\Eventable;

class Dispatcher
{
    protected $events = [];

    public function on($event, callable $listener)
    {
        if (!KeysResolver::isValidKey($event)) {
            throw new \InvalidArgumentException('Invalid event name given');
        }

        if (!isset($this->events[$event])) {
            $this->events[$event] = [];
        }

        $this->events[$event][] = $listener;

        return $this;
    }

    public function once($event, callable $listener)
    {
        $onceClosure = function () use (&$onceClosure, $event, $listener) {
            $this->off($event, $onceClosure);
            call_user_func_array($listener, func_get_args());
        };
        $this->on($event, $onceClosure);

        return $this;
    }

    public function off($event = null, callable $listener = null)
    {
        if (empty($event)) {
            $this->events = [];
        } elseif (empty($listener)) {
            $this->events[$event] = [];
        } elseif (!empty($this->events[$event])) {
            $index = array_search($listener, $this->events[$event], true);

            if (false !== $index) {
                unset($this->events[$event][$index]);
            }
        }

        return $this;
    }

    public function trigger($event, array $args = [])
    {
        $matchedEvents = KeysResolver::filterKeys($event, array_keys($this->events));

        if (!empty($matchedEvents)) {
            if (is_array($matchedEvents)) {
                foreach ($matchedEvents as $eventName) {
                    $this->fire($eventName, $args);
                }
            } else {
                $this->fire($matchedEvents, $args);
            }
        }

        return $this;
    }

    public function fire($event, array $args = [])
    {
        foreach ($this->events[$event] as $listener) {
            call_user_func_array($listener, $args);
        }

        return $this;
    }
}


Разбор ключей

Половина работы сделана — диспетчер реализован и работает. Следующий шаг — добавить фильтрацию событий по шаблону.
Шаблоны представляют собой все те же ключи, но с метками для фильтрации:
  • * — один сегмент, до разделителя;
  • ** — любое количество сегментов.

Для ключа application.user.signin.error можно составить такие корректные шаблоны:
  • application.**.error
  • **.error
  • application.user.*.error
  • application.user.**

Для реализации такой фильтрации, понадобилось три метода:
KeysResolver {
    public static int isValidKey(string $key)
    public static string getKeyRegexPattern(string $key)
    public static mixed filterKeys(string $pattern [, array $keys ])
}


Ничего военного: валидация ключа, преобразование шаблона в регулярное выражение и фильтрация массива ключей.
KeysResolver.php
namespace Yowsa\Eventable;

class KeysResolver
{
    public static function isValidKey($key)
    {
        return preg_match('/^(([\w\d\-]+)\.?)+[^\.]$/', $key);
    }

    public static function getKeyRegexPattern($key)
    {
        $pattern = ('*' === $key)
                ? '([^\.]+)'
                : (('**' === $key)
                    ? '(.*)'
                    : str_replace(
                        array('\*\*', '\*'),
                        array('(.+)', '([^.]*)'),
                        preg_quote($key)
                    )
                );

        return '/^' . $pattern . '$/i';
    }

    public static function filterKeys($pattern, array $keys = array())
    {
        $matched = preg_grep(self::getKeyRegexPattern($pattern), $keys);
        if (empty($matched)) {
            return null;
        }
        if (1 === count($matched)) {
            return array_shift($matched);
        }

        return array_values($matched);
    }
}


Весь пакет вмещается в два простых класса, легко тестируем и оформлен composer-пакетом.

Does it work


Для демонстрации, как Eventable работает и в каких случаях это может быть полезно, ниже приведен простой пример.
require_once __DIR__ . '/../vendor/autoload.php';

$dispatcher = new Yowsa\Eventable\Dispatcher();
$teacher    = 'Mrs. Teacher';
$children   = ['Mildred', 'Nicholas', 'Kevin', 'Bobby', 'Anna',
               'Kelly', 'Howard', 'Christopher', 'Maria', 'Alan'];

// teacher comes in the classroom
// and children welcome her once 
$dispatcher->once('teacher.comes', function($teacher) use ($children) {
    foreach ($children as $kid) {
        printf("%-12s- Hello, %s!\n", $kid, $teacher);
    }
});

// every kid answers to teacher once
foreach ($children as $kid) {
    $dispatcher->once("children.{$kid}.says", function() use ($kid) {
        echo "Hi {$kid}!\n";
    });
}

// boddy cannot stop to talk
$dispatcher->on('children.Bobby.says', function() {
    echo "\tBobby: I want pee\n";
});


// trigger events

echo "{$teacher} is entering the classroom.\n\n";
$dispatcher->trigger('teacher.comes', [$teacher]);

echo "\n\n{$teacher} welcomes everyone personally\n\n";
$dispatcher->trigger('children.*.says');

for ($i = 0; $i < 5; $i++) {
    $dispatcher->trigger('children.Bobby.says');
}

Резюльтат
Mrs. Teacher is entering the classroom.

Mildred — Hello, Mrs. Teacher!
Nicholas — Hello, Mrs. Teacher!
Kevin — Hello, Mrs. Teacher!
Bobby — Hello, Mrs. Teacher!
Anna — Hello, Mrs. Teacher!
Kelly — Hello, Mrs. Teacher!
Howard — Hello, Mrs. Teacher!
Christopher — Hello, Mrs. Teacher!
Maria — Hello, Mrs. Teacher!
Alan — Hello, Mrs. Teacher!

Mrs. Teacher welcomes everyone personally

Hi Mildred!
Hi Nicholas!
Hi Kevin!
Hi Bobby!
	Bobby: I want pee
Hi Anna!
Hi Kelly!
Hi Howard!
Hi Christopher!
Hi Maria!
Hi Alan!
	Bobby: I want pee
	Bobby: I want pee
	Bobby: I want pee
	Bobby: I want pee
	Bobby: I want pee


Возможно полезные ссылки

Вдохновился:

Получилось:
Tags:
Hubs:
+10
Comments7

Articles