Резюме


Целью этой статьи является попытка посмотреть, с иной точки зрения, на описание систем распространения событий.
 
На момент написания этой статьи большинство ведущих фреймворков на php реализуют систему событий, в основе которой лежит описание объекта события EventObject.
 
Это стало стандартом в php, что недавно было подтверждено принятием стандарта psr/event-dispatcher.
 
Но получается так, что описание объекта события мало помогает при разработке слушателя. За деталями под кат.


В чём проблема


Давайте рассмотрим роли и цели тех кто пользуется EventObject при разработке.


  1. Разработчик (А), который закладывает в код возможность внедрять сторонние инструкции в свой процесс посредством генерации события.


    Разработчик описывает объект EventObject или его сигнатуру посредством интерфейса.


    Цель разработчика при описании EventObject дать остальным разработчикам описание объекта с данными, и в некоторых вариантах использования, описать механизм взаимодействия с основным потоком посредством этого объекта.


  2. Разработчик (Б), который описывает "слушателя".


    Разработчик подписывается на указанное событие. В большинстве случаев описание слушателя должно удовлетворять callable типу.


    При этом разработчик не стеснён в именовании классов или методов слушателя. Но есть ограничение больше по соглашению, что обработчик в качестве аргумента получает EventObject.



При принятии psr/event-dispatcher рабочей группой, были проанализированы многие варианты использования систем распространения событий.


В описательной части стандарта psr упоминаются следующие варианты использования:


  1. одностороннее уведомление — «Я сделал что-то, если тебе интересно»
  2. улучшение объекта — «Вот вещь, пожалуйста, измените её, прежде чем, я что-то с ней сделаю»
  3. коллекция — «Дайте мне все ваши вещи, чтобы я мог что-то сделать с этим списком»
  4. альтернативная цепочка — «Вот вещь, первый из вас кто справится с этим, сделает это, затем остановиться»

При этом рабочей группой было высказано множество вопросов к подобному использованию систем распространения событий, связанных с тем, что каждый из вариантов использования имеет "неопределённость" которая зависит от реализации объекта 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
    ) {
        // ...
    }

В итоге мы видим следующие проблемы:


  1. плохая сигнатура кода слушателя
  2. неопределённость работы Dispatcher
  3. неопределённость возвращаемых типов
  4. введение множества дополнительных сущностей типа 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
        );

    }

Без обсуждения хорошо или плохо использовать остановку распространения событий.


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


Итак, если мы перейдём на описание слушателя, мы получаем намного лучше читаемые сигнатуры методов слушателей при их разработке.


Дополнительно у нас появляется возможность основному потоку явно указывать на:


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

Как реализовать подписку на событие


Варианты могут быть разные. Общий смысл всех вариантов сводится к тому, что нам нужно каким-либо способом сообщить объекту ListenerProvider (объект предоставляющие возможность подписаться на событие), какому событию принадлежит конкретный интерфейс.


Можно рассмотреть на примере преобразования переданного объекта в callable тип. При этом стоит понимать, что вариантов получения дополнительной метаинформации, может быть множество:


  1. могут передаваться явно, как в примере
  2. могут храниться в аннотациях интерфейсов слушателей
  3. можно использовать название интерфейса слушателя — как имя событий



Пример реализации подписки


    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 будет обрабатывать возвращаемые значения из слушателей.


На этом всё. Было бы интересно обсудить с сообществом имеет ли право на жизнь описанный подход.

Комментарии (5)


  1. oxidmod
    13.08.2019 10:56

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


  1. Nemutaisama
    15.08.2019 19:51

    На мой взгляд получилось очень странно и мимо.

    В описательной части стандарта psr упоминаются следующие варианты использования:

    1. одностороннее уведомление — «Я сделал что-то, если тебе интересно»
    2. улучшение объекта — «Вот вещь, пожалуйста, измените её, прежде чем, я что-то с ней сделаю»
    3. коллекция — «Дайте мне все ваши вещи, чтобы я мог что-то сделать с этим списком»
    4. альтернативная цепочка — «Вот вещь, первый из вас кто справится с этим, сделает это, затем остановиться»


    Там же, чуть дальше, рабочая группа в результате обсуждения пришла к следующему:
    — коллекция, это тоже вещь, потому пункты 2 и 3 это одно и то же.
    — поскольку объекты всегда передаются по ссылке, пункт 1 это лишь частный случай пункта 2
    — для пункта 4 добавить интерфейс StoppableEventInterface

    В результате разработчик (Б) по сигнатуре видит все необходимое.


    1. customlabs Автор
      16.08.2019 09:02

      Поскольку, уже второй комментарий с упоминанием, передачи объекта по ссылке.

      поскольку объекты всегда передаются по ссылке, пункт 1 это лишь частный случай пункта 2


      Это верно, только когда передаваемые объекты содержат методы изменения данных. В случае простого DTO объекта, изменить, его нельзя.

      Пример:

      interface CustomerNameInterface {
      
          public function firstName(): string;
      
          public function lastName(): string;
      
          public function middleName(): ?string;
      }
      


      interface customerNameChangedListener {
      
          public function onCustomerNameChange(
              int $customerId, 
              CustomerNameInterface $customerName
          );
      }
      


      Это только оповещение, без возможности изменения переданного объекта.

      interface customerNameBeforeChangeListener {
      
          public function onBeforeCustomerNameChange(
              int $customerId, 
              CustomerNameInterface $customerName
          ): CustomerNameInterface
      
      }
      


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

      И повторюсь, интерфейс CustomerNameInterface при разработке уже есть в системе, в описанном способе дополнительный объект «сахар» CustomerEvent не нужен.


      1. Nemutaisama
        16.08.2019 12:08

        Это верно, только когда передаваемые объекты содержат методы изменения данных. В случае простого DTO объекта, изменить, его нельзя.

        Наверное я еще не проснулся, и ннада больше кофе. Или вы действительно предлагаете «для изменения неизменяемых объектов просто используем подмену объектов»? Тогда не понятно, зачем у вас вообще методы у объекта — сделайте просто все свойства пабликами.
        При этом все еще не ясно, каким образом вы собираетесь изменять этот объект внутри листенера. Максимум что вы можете — вытащить имя другого кастомера и вернуть его вместо оригинала.

        И повторюсь, интерфейс CustomerNameInterface при разработке уже есть в системе, в описанном способе дополнительный объект «сахар» CustomerEvent не нужен.

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

        interface LogChangesEvent {
        
            public function getObjectIdentifier(): string;
        
            public function getOldValue(): JsonSerializable;
        
            public function getNewValue(): JsonSerializable;
        }


        interface SmsNotifyEvent {
        
            public function getRecipient(): string;
        
            public function getText(): string;
        }


        interface CustomerNameChangedEvent implements SmsNotifyEvent, LogChangesEvent {
            public function getCustomerName(): CustomerNameInterface;
        }


        Ну и сообветственно набор листенеров, которые слушают тот же SmsNotifyEvent — они ничего не знают ни про кастомера, ни про его CustomerNameInterface.


        1. customlabs Автор
          16.08.2019 12:48

          По моему мнению, это обсуждение начинает скатываться в «холивар» по реализациям. Что, в свою очередь уведет от сути. Я специально в статье обходил, моменты, которые могут свести все к разнообразным «холиварам» не по теме.

          В приведенном Вами примере, больше вопросов чем ответов. «Холиварных», вопросов.

          Вопросы:

          1. Интерфейс CustomerNameChangedEvent, какому пакету (модулю, банду, скопу) принадлежит? Сервису, который отвечает за изменение данных клиента?
          2. Интерфейс SmsNotifyEvent, какому пакету (модулю, банду, скопу) принадлежит? Сервису, который отвечает за sms отправку?
          3. Интерфейс LogChangesEvent, какому пакету (модулю, банду, скопу) принадлежит? Сервису, который отвечает за логирование?


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

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

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