Резюме
Целью этой статьи является попытка посмотреть, с иной точки зрения, на описание систем распространения событий.
На момент написания этой статьи большинство ведущих фреймворков на php реализуют систему событий, в основе которой лежит описание объекта события EventObject.
Это стало стандартом в php, что недавно было подтверждено принятием стандарта psr/event-dispatcher.
Но получается так, что описание объекта события мало помогает при разработке слушателя. За деталями под кат.
В чём проблема
Давайте рассмотрим роли и цели тех кто пользуется EventObject при разработке.
Разработчик (А), который закладывает в код возможность внедрять сторонние инструкции в свой процесс посредством генерации события.
Разработчик описывает объект EventObject или его сигнатуру посредством интерфейса.
Цель разработчика при описании EventObject дать остальным разработчикам описание объекта с данными, и в некоторых вариантах использования, описать механизм взаимодействия с основным потоком посредством этого объекта.
Разработчик (Б), который описывает "слушателя".
Разработчик подписывается на указанное событие. В большинстве случаев описание слушателя должно удовлетворять callable типу.
При этом разработчик не стеснён в именовании классов или методов слушателя. Но есть ограничение больше по соглашению, что обработчик в качестве аргумента получает EventObject.
При принятии psr/event-dispatcher рабочей группой, были проанализированы многие варианты использования систем распространения событий.
В описательной части стандарта psr упоминаются следующие варианты использования:
- одностороннее уведомление — «Я сделал что-то, если тебе интересно»
- улучшение объекта — «Вот вещь, пожалуйста, измените её, прежде чем, я что-то с ней сделаю»
- коллекция — «Дайте мне все ваши вещи, чтобы я мог что-то сделать с этим списком»
- альтернативная цепочка — «Вот вещь, первый из вас кто справится с этим, сделает это, затем остановиться»
При этом рабочей группой было высказано множество вопросов к подобному использованию систем распространения событий, связанных с тем, что каждый из вариантов использования имеет "неопределённость" которая зависит от реализации объекта Dispatcher.
В описанных выше ролях для разработчика (Б) нет удобного и хорошо читаемого способа понять, какой из вариантов использования системы событий выбрал разработчик (А). Разработчику всегда придётся смотреть в код описания не только объекта события EventObject, но и в код, где это событие генерируется.
В итоге сигнатура — описание объекта события, призванная облегчить работу разработчика (Б) с этой работой справляется плохо.
Другой проблемой является не всегда оправданное наличие отдельного объекта, который дополнительно описывает уже описанные в системах сущности.
namespace MyApp\Customer\Events;
use MyApp\Customer\CustomerNameInterface;
use MyFramevork\Event\SomeEventInterface;
class CustomerNameChangedEvent implements SomeEventInterface {
/**
* Возвращает идентификационный номер клиента
* @return int
*/
public function getCustomerId(): int;
/**
* Возвращает имя клиента
*/
public function getCustomerName(): CustomerNameInterface;
}
В приведённом выше примере объект CustomerNameInterface уже ранее был описан в системе.
Это напоминает излишнее введение новых понятий. Ведь когда нам требуется реализовать метод, к примеру, записи в лог изменения имени клиента мы не объединяем аргументы метода в отдельную сущность, мы пользуемся стандартным описанием метода вида:
function writeToLogCustomerNameChange(
int $customerId,
CustomerNameInterface $customerName
) {
// ...
}
В итоге мы видим следующие проблемы:
- плохая сигнатура кода слушателя
- неопределённость работы Dispatcher
- неопределённость возвращаемых типов
- введение множества дополнительных сущностей типа SomeEventObject
Посмотрим на это с иной точки зрения
Если одна из проблем плохое описание слушателя давайте рассмотрим систему распространения событий не с описания объекта события, а с описания слушателя.
Разработчик (А) описывает как должен быть описан слушатель.
namespace MyApp\Customer\Events;
interface CustomerNameChangedListener {
public function onCustomerNameChange(
int $customerId,
CustomerNameInterface $customerName
);
}
Отлично разработчик (А) смог передать описание слушателя и передаваемых данных для сторонних разработчиков.
Разработчик (Б) при написании слушателя набирает в среде implements CustomerNameChangedListener и IDE может добавить в его класс описание метода слушателя. Code completion прекрасен.
Давайте взглянем на новую сигнатуру метода слушателя. Даже беглого взгляда хватает, чтобы понять, что используемый вариант системы распространения событий это: "одностороннее уведомление".
Входные данные передаются не по ссылке, а значит нет возможности их модифицировать каким-либо способом, чтобы изменения попали в основной поток. Отсутствует возвращаемое значение нет какой-либо обратной связи с основным потоком.
А что с другими вариантами использования? Давайте поиграем описанием интерфейса слушателя события.
namespace MyApp\Customer\Events;
interface CustomerNameChangedListener {
public function onCustomerNameChange(
int $customerId,
CustomerNameInterface $customerName
): CustomerNameInterface;
}
Появилось требование о возвращаемом значении, значит, слушатель может (но не обязан) вернуть иное значение, если оно соответствует указанному интерфейсу. Вариант использования: "улучшение объекта".
namespace MyApp\Customer\Events;
interface CustomerNameChangedListener {
/**
* @return ItemInterface[]
*/
public function onCustomerNameChange(
int $customerId,
CustomerNameInterface $customerName
): array;
}
Появилось требование о возвращаемом значении определённого типа по которому можно понять, что это элемент коллекции. Вариант использования: "коллекция".
namespace MyApp\Customer\Events;
interface CustomerNameChangedListener {
public function onCustomerNameChange(
int $customerId,
CustomerNameInterface $customerName
): VoteInterface;
}
Вариант использования: "альтернативная цепочка, голосование".
namespace MyFramework\Events;
interface EventControllInterface {
public function stopPropagation();
}
namespace MyApp\Customer\Events;
interface CustomerNameChangedListener {
public function onCustomerNameChange(
int $customerId,
CustomerNameInterface $customerName,
EventControllInterface &$eventControll
);
}
Без обсуждения хорошо или плохо использовать остановку распространения событий.
Такой вариант читается вполне однозначно, основной поток предоставляет возможность слушателю остановить событие.
Итак, если мы перейдём на описание слушателя, мы получаем намного лучше читаемые сигнатуры методов слушателей при их разработке.
Дополнительно у нас появляется возможность основному потоку явно указывать на:
- приемлемость изменений поступающий данных
- возвращаемые типы отличные от поступающих типов
- явную передачу объекта, с помощью которого можно остановить распространение событие
Как реализовать подписку на событие
Варианты могут быть разные. Общий смысл всех вариантов сводится к тому, что нам нужно каким-либо способом сообщить объекту ListenerProvider (объект предоставляющие возможность подписаться на событие), какому событию принадлежит конкретный интерфейс.
Можно рассмотреть на примере преобразования переданного объекта в callable тип. При этом стоит понимать, что вариантов получения дополнительной метаинформации, может быть множество:
- могут передаваться явно, как в примере
- могут храниться в аннотациях интерфейсов слушателей
- можно использовать название интерфейса слушателя — как имя событий
Пример реализации подписки
namespace MyFramework\Events;
class ListenerProvider {
private $handlerAssociation = [];
public function addHandlerAssociation(
string $handlerInterfaceName,
string $handlerMethodName,
string $eventName
) {
$this->handlerAssociation[$handlerInterfaceName] = [
'methodName' => $handlerMethodName,
'eventName' => $eventName
];
}
public function addHandler(object $handler) {
$hasAssociation = false;
foreach( $this->handlerAssociation as $handlerInterfaceName => $handlerMetaData ) {
if ( $handler interfaceof $handlerInterfaceName ) {
$methodName = $handlerMetaData['methodName'];
$eventName = $handlerMetaData['eventName'];
$this->addListener($eventName, [$handler, $methodName]);
$hasAssociation = true;
}
}
if ( !$hasAssociation ) {
throw new \Exception('Unknown handler object');
}
}
}
Мы добавляем в объект подписки конфигурационный метод, который для каждого интерфейса слушателя описывает его метаданные, такие как, вызываемый метод и имя события.
По этим данным, в момент подписки преобразуем переданный $handler в объект callable с указанием вызываемого метода.
Если вы заметили, код подразумевает, что один $handler объект может реализовывать множество интерфейсов слушателя событий и будет подписан на каждое из них. Это аналог SubscriberInterface для массовой подписки объекта на несколько событий. Как видите, в приведённой выше реализации не требуется отдельный механизм как addSubscriber(SubscriberInterface $subscriber)
он у нас получился работающим из коробки.
Dispatcher
Увы, описанный подход идёт вразрез с интерфейсом, принятым как стандарт psr/event-dispatcher
Так как нам не требуется передавать в Dispatcher какой-либо объект. Да можно передать объект сахар вида:
class Event {
public function __construct(string $eventName, ...$arguments) {
// ...
}
public function getEventName(): string {
// ...
}
public function getArguments(): array {
// ...
}
}
И использовать его при генерации события по psr интерфейсу, но это просто некрасиво.
По-хорошему интерфейс Dispatcher лучше бы выглядел так:
interface EventDispatcherInterface {
public function dispatch(string $eventName, ...$arguments);
public function dispatchStopabled(string $eventName, ...$arguments);
}
Почему два метода? Сложно объединить все варианты использования в единую реализацию. Лучше для каждого варианта использования добавить свой метод, появится однозначность трактовки, как Dispatcher будет обрабатывать возвращаемые значения из слушателей.
На этом всё. Было бы интересно обсудить с сообществом имеет ли право на жизнь описанный подход.