/ php

От А до Я про Event Dispatching

Впервые я столкнулся с событиями, когда работал с Symfony. Смотря обучающие уроки, я заметил, что большинство задач решаются с помощью создания (диспатчинга) и обработки определённых событий (слушателями). В то время мне показалось это сложной концепцией для понимания. Но, работая с разными проектами и фреймворками, эти вещи продолжали появляться, потому мне просто необходимо было с этим разобраться. И в итоге, я принял таблетку правды, и больше никогда не хочу обходиться без событий.

В этой статье я расскажу подробно про Event Dispatching: как работает, какие реализации существуют в разных фреймворках и помогу вам понять их основной подход. Обсудим, чем отличаются хуки от экшенов, и какие лучшие практики по именованию события.

Что такое диспетчеризация событий?

Представьте себе работницу на фабрике: она делает вещи, и у неё это хорошо получается. В какой-то момент её менеджер хочет, чтобы она вела инвентарный учет того, что она сделала, каждый раз, когда она заканчивает один из своих продуктов. Ей это не нравится, потому что она считает, что это не её ответственность, а скорее ответственность отдела инвентаризации. Её менеджер соглашается, и они решают, что она просто должна сообщать отделу инвентаризации, что она выполнила работу сразу же после ее окончания. Инвентаризация записывает, что она сделала, в какой день и время, а также любую другую необходимую информацию.

Всё работает отлично, но через некоторое время её просят также сообщать менеджеру, когда она заканчивает один из своих продуктов, чтобы он мог проверить её работу. Однако вместо того, чтобы согласиться, она понимает, что это будет отвлекать её от работы, поэтому предлагает альтернативу: "А что, если я просто скажу всем сразу, когда закончу свой продукт, чтобы любой, кто захочет, мог отреагировать и принять меры?".

Теперь она просто будет кричать "Я сделала один товар!" каждый раз, когда она заканчивает работу над одним продуктом - остальные будут принимать соответствующие меры. Теперь она может продолжать работать, и в будущем её больше ни о чём постороннем не будут просить. Менеджер сможет вести запись для своей статистики, а инвентарь обновлять свои записи.

Это практически та же концепция, что и диспетчеризация событий. Мы отправляем сообщение с определённым контекстом (опционально, но желательно), а все, кому важно наше событие, как-то обрабатывают его в соответствии со своей логикой.

Из каких компонентов состоит Event Dispatching?

Теперь, когда мы имеем некоторое представление о том, что такое диспетчеризация событий, давайте немного углубимся и познакомимся со всеми компонентами, учавствующими в диспетчеризации событий.

Event (событие)

Теоретически, событие может быть любым. Лишь бы у него было уникальное имя или ID. Это имя позволяет всем знать, на какое событие реагировать. Как мы увидим далее, события в некоторых случаях могут быть простым значением или даже вообще не иметь значения; но в большинстве современных фреймворков это простой объект класса, содержащий определённый контекст. Поскольку класс имеет уникальное имя (вместе с неймспейсом), его имя часто используется в качестве имени события.

Мы рассмотрим события более подробно позже на примерах из различных фреймворков.

Dispatcher

Поскольку код не может кричать, ему нужен аналог микрофона, чтобы все желающие могли его услышать. Этот микрофон называется диспетчером (Dispatcher), потому что он рассылает события всем желающим (слушателям).

Диспетчер является посредником в общении между событиями и слушателями. Поэтому диспетчер - это тот, кто получает событие от запускающего кода (иногда называемого эмиттером) - контроллера или сервиса. Поскольку диспетчер доставляет событие, ему также нужен список слушателей, которые ждут это событие, чтобы он мог передать это событие им всем. Этот список иногда называют Listener Provider-ом.

Примечание. так же, существуют термины - Emitter и Listener Provider. Это просто слова для обозначения: вашего существующего кода, который вызывает диспетчер, и конфигурации, которая при создании события находит нужного слушателя и передаёт ему весь контекст события. Последний будет отличаться и называться по-разному в различных фреймворках.

Listener (слушатель)

Слушатель содержит код, который будет выполнен при срабатывании события. Слушатель прослушивает только одно событие. Однако у события может быть неограниченное количество слушателей. Таким образом, несколько слушателей могут слушать одно и то же событие и делать совершенно разные вещи.

Слушатель - это либо анонимная функция, либо метод определенного класса, либо даже вызываемый (invokable) класс. Эта вызываемая функция получает объект события или значения, которые были предоставлены в качестве аргумента - контекст события. Иногда имя события является достаточной информацией для слушателя, но чаще всего ему требуется объект или набор значений события определённого контекста.

Subscriber (подписчик)

Возможно, вы слышали о подписчиках в связке с событиями. Может показаться, что подписчики и слушатели - это одно и то же, но это не так. Как было сказано ранее, слушатель слушает только одно событие. С другой стороны, подписчик - это класс, который регистрирует несколько слушателей различных событий. Подписчики в основном используются для группировки слушателей, которые необходимы для добавления одной функции или функциональности.

Итак, вкратце: события - это (обычно) объекты с уникальным идентификатором, которые посылаются диспетчером вызываемым слушателям, желающим отреагировать на это событие.

Факт: знали ли вы, что существует список рекомендаций для PHP по работе с определенными концепциями? Это PHP Standard Recommendations, или же просто PSR. Одной из таких рекомендаций является PSR-14: Event Dispatcher. Эта рекомендация, в технических терминах, объясняет как может работать диспетчеризация событий.

События: значения vs объекты, как правильно

Есть два варианта отправки события: вы можете отправить слушателю с набором параметров или объект события. Давайте рассмотрим эти варианты подробнее.

Значения

Если и есть фреймворк, в котором события со значениями возведены в ранг искусства, то это WordPress. Этот фреймворк переполнен событиями, которые могут изменить практически всё, что вы захотите. Это происходит путем отправки события с именем, сопровождаемого переменным набором параметров. Когда событие отправляется, слушатель фактически получает все эти параметры в качестве аргументов. Однако первый аргумент - это значение, которое может быть изменено. Такой тип события называется хуком (подробнее об этом позже). Остальные аргументы доступны слушателю в качестве контекста. Они могут быть абсолютно любыми.

PHP может передать скалярное значение (string, bool, int или float) или даже массив, которые потом получает принимающая функция. Это означает, что вы не можете изменить предоставленную исходную переменную, чтобы значение мутировало и в остальном коде. Таким образом, обработав событие, слушатели возвращают новое значение диспетчеру.

Примечание: хотя можно изменить значение переменной через функцию, если она передана по ссылке, это непростое дело. И это не всегда то, что вам нужно; например, когда вы захотите использовать ту же переменную позже и будете ожидать, что она по-прежнему будет иметь первоначальное значение.

Подход с использованием значения полезен для простых, скалярных значений: булевых или строк. В WordPress даже есть небольшие функции типа __return_true(), которые всегда возвращают true. Они удобны для быстрого изменения настроек через событие. Недостатком этого подхода является то, что по мере роста количества подобных аргументов, слушатель также должен принимать их все в качестве аргументов. У меня были ситуации, когда мне требовался только 11-й(!) параметр в чтобы изменить контекст из первого аргумента.

Пример событий в WordPress:

// Где-то в плагине диспетчеризируется событие `get_title`.
$post = get_post();
$title = 'Original title';
$title = apply_filters('get_title', $title, $post);
 
// Например, где-нибудь в файле functions.php:
// Добавление идентификатора поста перед заголовком.
add_filter('get_title', function(string $title, ?WP_Post $post): string {
    return ($post ? $post->ID . ': ' : '') .  $title;
}, 10, 2);

Объекты

Почти каждый фреймворк или реализация диспетчера событий работает с объектами событий. Этот объект создаётся внутри кода, порождающего событие, а затем передаётся Event Dispatcher-у. Поскольку объект является классом, вы можете добавить к нему любые методы. Методы для получения или добавления значений, если это необходимо. И в отличие от скалярных значений или массивов, объекты передаются в функцию по ссылке.

Это означает, что слушатель работает не с копией, а с тем же экземпляром объекта. Таким образом, обработчик события может ничего не возвращать через return, а может просто работать с объектом через предоставленные методы и модифицировать его значения.

Слушатели получают объект события в качестве аргумента, поэтому вы можете добавлять дополнительный контекст к самому классу событию без необходимости изменения сигнатуру функции слушателя.

В отличие от реализации WordPress, код диспетчера событий на основе объектов может быть более простым и понятным в использовании. Хотя, справедливости ради, WordPress прячет свой диспетчер в глобальной переменной, к которой обращаются вышеупомянутые функции.

Пример объектных событий в thephpleage/event:

Этот диспетчер событий является реализацией PSR-14.

// Где-то вы подписались на событие.
$dispatcher->subscribeTo(PostCreatedEvent::class, function(PostCreatedEvent $event): void {
    $post = $event->getPost();
    $newTitle = sprintf('%d: %s', $post->getId(), $post->getTitle());
    $post->setTitle($newTitle);
});
 
// Где-то в коде, где создаётся событие.
$post = $this->createPost('Original title');
$event = $dispatcher->dispatch(new PostCreatedEvent($post));
$title = $event->getPost()->getTitle(); // Тут будет выведен новый заголовок

Поэтому, хотя события значений имеют свое место и применение, рекомендуется всегда использовать объекты событий, поскольку они более гибкие и легко расширяемые в будущем.

Примечание: хотя использование объектов событий в плагинах и темах WordPress не является обычной практикой, я считаю, что это действительно необходимо, поскольку это делает работу разработчика более приятной. Просто создайте объект события в вашем плагине и создавайте его всегда, когда в этом есть смысл.

Как работает диспетчеризация событий?

Теперь, когда вы имеете базовое представление о том, как происходит диспетчеризация событий, пришло время копнуть немного глубже. Что именно происходит, когда вы отправляете событие?

Слушатели являются последовательными

Когда диспетчер передаёт событие в связанные с ним слушатели, он не может послать его всем слушателям сразу. Как и весь код на PHP, события должны вызываться последовательно, то есть они вызываются одно за другим. Они так же получают один и тот же объект события или значения.

Это похоже на регистрационный лист: отправитель передает лист первому человеку. Этот человек добавляет свое имя и передает лист второму человеку; тот тоже записывается, передавая его дальше, пока все не закончат. После этого весь список возвращается отправителю. Не все должны записываться, но они будут передавать лист дальше.

То же самое происходит и с событиями. Событие создается кодом, а затем через диспетчер отправляется первому слушателю. Однако, в отличие от Middleware, слушатели не вызывают следующего слушателя; вместо этого задача диспетчера - передать событие каждому слушателю. Этот процесс передачи события называется распространением события (Event propagation).

Приоритетность слушателей

Поскольку диспетчеризация событий похожа на игру в "горячую картошку", порядок вызова слушателей может быть важным. Поэтому во всех реализациях диспетчера событий есть понятие приоритета. Почти в каждой реализации этот приоритет устанавливается путем добавления значения веса слушателю. Затем диспетчер сортирует слушателей по этому весу, прежде чем начать вызывать слушателей. Laravel имел такую возможность до версии 5.4, но решил убрать эту функциональность.

// задаем приоритет вызова слушателя в WordPress. Чем ниже номер, тем раньше он будет вызван.
add_filter('the_event_name', '__return_true', 10);
 
// Symfony работает наоборот. Чем выше номер, тем больше приоритет.
$dispatcher->addListener(SomeEvent::class, [$listener, 'onBeforeSave'], 30); // 0 - значение по умолчанию.
 
// `thephpleage/event` в этой библиотеке также, чем больше номер, тем раньше обработчик будет вызван.
$dispatcher->subscribeTo(SomeEvent::class, [$listener, 'onBeforeSave'], PHP_INT_MAX); // должно вызваться первым

Останавливаемые события

Иногда слушатель настолько важен, что он должен предотвратить вызов любых других слушателей для этого события. По сути, в этот момент он должен замыкаться. Именно поэтому в большинстве фреймворков есть концепция останавливаемых событий. Это означает, что событие (объект) имеет функцию, которая может быть вызвана, чтобы дать сигнал диспетчеру прекратить вызов дальнейших событий.

Примеры:

Не все фреймворки имеют одинаковый подход. И на данный момент WordPress не имеет поддержки останавливаемых событий.

// В Symfony есть базовый класс `Event`, который содержит метод `stopPropagation()`.
// То же самое справедливо и для библиотеки `thephpleage/event`, но там вы расширяете `StubStoppableEvent`.
// или должны реализовать интерфейс `StoppableEventInterface` (PSR-14).
public function listener(SomeEvent $event): void {
    // тут что-то делаем
    $event->stopPropagation();
}
 
// Laravel остановит выполнение, если слушатель вернет `false`.
public function listener(SomeEvent $event) {
    // тут что-то делаем
    return false;
}

Хуки и экшены

Существует два типа событий. Вы можете создавать хуки или экшены. WordPress фактически нигде не ссылается на "события" в своей документации. Они полностью разделяют эти два типа, предоставляя функции apply_filter() (хук) и do_action() (экшен). Так в чем же разница? Все зависит от того, какой результат нужен вашему коду.

Хуки

Хук - это событие, которое также используется кодом диспетчера после его диспетчеризации и до того, как он выполнит какое-то действие. Например: у вас есть функция, которая устанавливает заголовок для объекта. Этот заголовок имеет логичное значение по умолчанию, но вы хотите, чтобы пользователи могли его переписать. Вы можете отправить событие, содержащее метод setTitle(), чтобы изменить его. В коде будет использоваться значение из события.

Однако его также можно использовать для получения некоторых данных, над которыми будет работать функция. Например: у вас есть какая-то функция импорта, которая работает каждый час. Эта функция может работать с любым классом, который реализует определённый интерфейс. Вы отправляете событие, чтобы определить, какие классы он должен использовать для импорта. Событие имеет метод addDataSource(), который слушатель может использовать для добавления источника данных. Это означает, что у вас может быть несколько источников данных, которые срабатывают только тогда, когда им это нужно. Таким образом, даже если ваш импортер запускается каждый час, источник данных не обязательно должен включаться каждый час.

// стиль Laravel эвент-диспатчинга
$event = DetermineDataSourcesEvent::dispatch();
 
foreach($event->getImporters() as $importer) {
    $this->importFrom($importer);
}

Примечание: поскольку хук используется кодом до того, как срабатывает определённое действие, принято называть их в настоящем времени. Например, DetermineDataSourcesEvent или BeforeSaveEvent.

Экшены

Экшен (читай действие) - это событие, которое отправляется только для того, чтобы слушатели отреагировали на него каким-то образом. Сам код не использует событие после его диспетчеризации. Поэтому экшены обычно создаются после выполнения какого-то действия. Событие создается в контексте определённой операции. Например, если была создана запись в блоге, обычно к событию прикрепляется эта запись в качестве контекста, чтобы слушатели могли что-то с ней сделать.

Примечание: поскольку событие отправляется после выполнения какого-либо действия, принято называть их в прошедшем времени: например, BlogPostCreatedEvent или AfterSaveEvent.

// Стиль создания событий в Symfony
use Symfony\Component\EventDispatcher\EventDispatcher;
 
$post = $this->createBlogPost(); // какой-то код, что создаёт пост
 
$dispatcher = new EventDispatcher();
$dispatcher->dispatch(new BlogPostCreatedEvent($post));

Обобщу: хуки можно рассматривать как предшествующие события, а экшены - как последующие события.

Являются ли вебхуки событиями?

Возможно, вы слышали о вебхуках и задаётесь вопросом, не являются ли они просто другим термином для хуков. Это не так. Хотя вебхуки - это события, но события, которые запускаются по HTTP-запросу. Это означает, что "слушателями" этих событий являются URL-адреса (других) веб-сайтов. Поэтому слушателем не обязательно должен быть PHP-скрипт. Это может быть любое веб-приложение. Оно просто должно понимать запрос.

Заключение

События - это мощный инструмент, позволяющий структурировать ваш код и сделать его более читаемым, разделяя приложение на различные зоны ответственности. Они дают возможность расширить существующий код, не меняя бизнес-логику. Большинство фреймворков имеют ту или иную форму диспетчеризации событий, и все они имеют свои плюсы и минусы.