Слушатели: как работают и как их использовать не по стандарту

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

Свой EventDispatcher

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

Чтобы разобраться в этом, предлагаю создать свой EventDispatcher. Для начала нам нужно определиться, из чего состоит этот компонент: событие, подписчик и сам диспетчер, который отправляет события конкретным подписчикам. Начнем описание переменных системы с интерфейса:

interface EventSubscriber
{
    /**
     * @return array
     */
    public function getSubscribedEvents(): array;
}

Что нам нужно знать от подписчика, так это только массив событий, на которые он подписан, остальное нас не касается. Метод getSubscribedEvents должен возвращать массив вида:

return [
     RequestEvent::class => 'onRequestEvent'
];

Где ключ массива - это название события (полное имя класса), второе - метод, куда мы инжектим объект события.

Чтобы в метод dispatch() инжектить конкретный тип данных, будем использовать базовый родительский класс события Event. Он может выглядеть так:

class Event
{
    /**
     * @var null
     */
    private $subject;

    /**
     * @var array
     */
    private array $arguments;

    public function __construct($subject = null, array $arguments = [])
    {
        $this->subject = $subject;
        $this->arguments = $arguments;
    }

    /**
     * @return null
     */
    public function getSubject()
    {
        return $this->subject;
    }

    /**
     * @return array
     */
    public function getArguments(): array
    {
        return $this->arguments;
    }
}

Теперь мы готовы перейти к сердцу нашей системы - диспетчеру:

class EventDispatcher
{
    /**
     * @var array
     */
    private array $subscribers = [];

    /**
     * @param EventSubscriber $subscriber
     *
     * @return EventDispatcher
     */
    public function addSubscriber(EventSubscriber $subscriber): self
    {
        $this->subscribers[] = $subscriber;

        return $this;
    }

    public function dispatch(Event $event)
    {
        $eventClass = \get_class($event);

        /** @var EventSubscriber $subscriber */
        foreach ($this->subscribers as $subscriber) {
            if (\in_array($eventClass, \array_keys($subscriber->getSubscribedEvents()))) {
                $method = $subscriber->getSubscribedEvents()[$eventClass];

                $subscriber->$method($event);
            }

            return;
        }
    }
}

Давайте разбираться. У нашего диспетчера есть приватное свойство, куда мы складываем всех подписчиков через метод addSubscriber. Когда мы вызываем метод dispatch(), передавая туда наше событие (которое обязательно должно быть подтипом класса Event), наш диспетчер перебирает всех подписчиков, проверяет, нет ли среди ключей массива каждого из подписчиков имя события (название класса), которое к нам пришло. Если есть, мы достаем метод (значение ключа) через имя события и вызываем у нашего подписчика этот метод, передавая туда объект события:

$subscriber->$method($event);

Если же мы ничего не нашли, просто выходим из метода. Это не должно быть ошибкой, если на событие нет подписчика.

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

Событие

final class RequestEvent extends Event
{
}

Подписчик

final class RequestSubscriber implements EventSubscriber
{
    public function onRequestEvent(RequestEvent $requestEvent)
    {
         var_dump(  
             $requestEvent->getSubject(),
             $requestEvent->getArguments()
         );
    }

    /**
     * @return array
     */
    public function getSubscribedEvents(): array
    {
        return [
            RequestEvent::class => 'onRequestEvent'
        ];
    }
}

Собираем все вместе

$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new RequestSubscriber());

$dispatcher->dispatch(new RequestEvent(new stdClass(), [
    'name' => 'Something was updated'
]));

Мы создали объект диспетчера, зарегистрировали подписчик и вызвали метод dispatch, куда передали наше событие (в качестве объекта в конструктор Event вы можете передать любой тип данных, начиная от примитивного int и заканчивая другим объектом). В этот момент происходит следующее:

  1. Мы получаем название класса события
$eventClass = \get_class($event); // RequestSubscriber
  1. Обходим все зарегистрированные подписчики:

    foreach ($this->subscribers as $subscriber) {           
    }
  2. Проверяем, нет ли среди ключей массива нашего подписчика текущего события:
if (\in_array($eventClass, \array_keys($subscriber->getSubscribedEvents()))) {
}
  1. Если мы обнаружили, что такое событие есть, достаем метод, который нужно вызвать, когда подписчик получит событие:
$method = $subscriber->getSubscribedEvents()[$eventClass];
  1. Вызываем его, передавая туда текущее событие:
$subscriber->$method($event);

Когда вы запустите этот код, вы получите следующее:

object(stdClass)#4 (0) {
}
array(1) {
  ["name"]=>
  string(21) "Something was updated"
}

Магия? Конечно нет. Вы вызываете подписчики и слушатели ровно в тот же момент, когда вызываете метод dispatch! Это самый обычный императивный подход, только неявный и слабосвязанный.

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

Полный пример кода можете посмотреть по ссылке.

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

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

  1. Нам нужен интерфейс, который будет возвращать массив событий:
interface EventsRoot
{
    public function releaseEvents(): array;
}
  1. Трейт, который реализует этот метод и метод добавления в массив событий:
trait EventsRootBehaviour
{
    private array $events = [];

    /**
     * @param $event
     *
     * @return void
     */
    protected function fireEvent($event): void
    {
        $this->events[] = $event;
    }

    /**
     * @return array
     */
    public function releaseEvents(): array
    {
        [$events, $this->events] = [$this->events, []];

        return $events;
    }
}
  1. Обертка над $em->flush(), которая сначала зафиксирует изменения в базу данных, а потом отправит события:
class Flusher
{
    /**
     * @var EntityManagerInterface
     */
    private EntityManagerInterface $em;

    /**
     * @var EventDispatcherInterface
     */
    private EventDispatcherInterface $dispatcher;

    public function __construct(EntityManagerInterface $em, EventDispatcherInterface $dispatcher)
    {
        $this->em = $em;
        $this->dispatcher = $dispatcher;
    }

    /**
     * @param EventsRoot ...$roots
     *
     * @return void
     */
    public function flush(EventsRoot ...$roots): void
    {
        $this->em->flush();

        foreach ($roots as $root) {
            foreach ($root->releaseEvents() as $event) {
                $this->dispatcher->dispatch($event);
            }
        }
    }
}

Теперь события отправятся только в том случае, если flush отработает без ошибок. Пример использования:

/**
 * @ORM\Entity()
 * @ORM\Table(name="articles"})
 */
class Article implements EventsRoot
{
   use EventsRootBehaviour;

   public function publish(\DateTimeImmutable $date)
   {
      // действия публикации
      $this->fireEvent(new ArticlePublished($this->id));
   }
}

Дальше вы реализуете подписчик на событие ArticlePublished. Теперь при публикации статьи вы должны передать в вашу обертку Flusher вашу сущность: $flusher->flush($article). flush сначала отправит изменения в базу, а потом вызовет необходимые слушатели. Вот и все, теперь вы застрахованы от неожиданных последствий работы слушателей. Вместо EventDispatcherInterface вы можете использовать новый компонент Messenger, который позволяет ставить ваши события в очередь.

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

loader
Комментарии
Новый комментарий

Логические задачи с собеседований