Как работает EventDispatcher в Symfony

от автора

Привет, Хабр!

Сегодня рассмотрим одну из самых сильных сторон Symfony — компонент EventDispatcher.

Если очень кратко, EventDispatcher позволяет создавать реактивную архитектуру: одни части приложения инициируют события, другие на них реагируют, не зная напрямую друг о друге.

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

Но чтобы использовать EventDispatcher правильно, мало просто вызвать dispatch() в коде. Нужно понимать:

  • как создавать свои события

  • как проектировать подписчиков

  • как управлять порядком вызовов

  • как останавливать цепочки событий

  • как тестировать их безопасно

  • как не наломать архитектуру плохим проектированием событий

И всё это мы сегодня коротко разберем.

Как устроен EventDispatcher

Когда мы диспатчим событие, происходит следующее:

  1. Мы создаём объект события, т.е Event или его наследник.

  2. Вызываем dispatch($event, $eventName).

  3. Внутри компонента по имени события ищутся все зарегистрированные слушатели.

  4. Каждый слушатель вызывается с этим объектом события.

  5. Если кто-то остановит распространение события stopPropagation(), оставшиеся слушатели не вызываются.

Именно так EventDispatcher реализует паттерны Observer и Mediator: слушатели подписаны на события, но не знают о других участниках цепочки.

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

Пример работы

Начнём с самого простого — диспатчинг события и один слушатель. Допустим, есть сайт, и нужно реагировать на регистрацию пользователя.

Создадим EventDispatcher, слушателя и диспатчинг:

use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Contracts\EventDispatcher\Event;  // Создаём диспетчер $dispatcher = new EventDispatcher();  // Регистрируем слушателя $dispatcher->addListener('user.registered', function (Event $event) {     echo "Пользователь зареган."; });  // Где-то в коде диспатчим событие $dispatcher->dispatch(new Event(), 'user.registered');

Отправить ‘пустое’ событие — это только начало. В реальных задачах почти всегда нужно передавать вместе с событием данные. Посмотрим, как это делается

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

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

Допустим, есть сущность User, и мы хотим передавать её внутри события.

Создадим свой Event-класс:

namespace App\Event;  use Symfony\Contracts\EventDispatcher\Event; use App\Entity\User;  class UserRegisteredEvent extends Event {     public function __construct(private User $user)     {     }      public function getUser(): User     {         return $this->user;     } }

Теперь, при диспатче события можно передать полноценного пользователя:

use App\Event\UserRegisteredEvent;  $user = new User('ivan.ivanov@example.com');  $event = new UserRegisteredEvent($user); $dispatcher->dispatch($event, UserRegisteredEvent::class);

И слушатель тоже получает доступ к объекту пользователя:

$dispatcher->addListener(UserRegisteredEvent::class, function (UserRegisteredEvent $event) {     $user = $event->getUser();     echo "Привет, " . $user->getEmail(); });

Таким образом, мы диспатчим смысленное событие, а не абстрактное что-то случилось.

Как подписываться на события правильно: Listener vs Subscriber

В Symfony есть два способа подписываться на события:

Слушатель

Просто отдельная функция или метод, который регистрируется через addListener().

Используем тогда, когда нужна быстрая реакция на одно событие или сама по себе малая логика

Пример:

$dispatcher->addListener(UserRegisteredEvent::class, [new SendWelcomeEmail(), 'handle']);

Подписчик

Класс, который реализует EventSubscriberInterface и явно описывает:

  • на какие события он подписан

  • какими методами реагировать

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

Пример подписчика:

namespace App\EventSubscriber;  use App\Event\UserRegisteredEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface;  class WelcomeEmailSubscriber implements EventSubscriberInterface {     public static function getSubscribedEvents(): array     {         return [             UserRegisteredEvent::class => 'onUserRegistered',         ];     }      public function onUserRegistered(UserRegisteredEvent $event): void     {         $user = $event->getUser();         // отправляем email     } }

И в конфигурации:

services:     App\EventSubscriber\WelcomeEmailSubscriber:         tags:             - { name: 'kernel.event_subscriber' }

Подписчики — это более организованный способ работы, и в проектах всегда стоит их использовать.

Приоритеты вызова слушателей

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

Symfony позволяет задавать приоритет слушателю. Больше приоритет = раньше вызов.

Пример в подписчике:

public static function getSubscribedEvents(): array {     return [         UserRegisteredEvent::class => [             ['sendWelcomeEmail', 10],             ['logUserRegistration', 5],             ['notifyAdmin', -10],         ],     ]; }

Сначала отправится email, потом залогируется регистрация, а далее будет уведомление админа

Остановка цепочки событий

Иногда нужно прервать дальнейшее распространение события.

Для этого в классе события можно вызвать stopPropagation():

public function onUserRegistered(UserRegisteredEvent $event): void {     if ($event->getUser()->isBanned()) {         $event->stopPropagation();     } }

После вызова stopPropagation(), остальные слушатели для этого события уже не будут вызваны.

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

Хорошая архитектура предполагает наличие тестов.

Самый простой способ протестировать работу события — это поймать диспатчинг события в юнит-тестах.

Пример теста:

use Symfony\Component\EventDispatcher\EventDispatcher; use PHPUnit\Framework\TestCase; use App\Event\UserRegisteredEvent;  class UserEventTest extends TestCase {     public function testUserRegisteredEventDispatched()     {         $dispatcher = new EventDispatcher();          $called = false;          $dispatcher->addListener(UserRegisteredEvent::class, function (UserRegisteredEvent $event) use (&$called) {             $called = true;         });          $user = new User('test@example.com');         $dispatcher->dispatch(new UserRegisteredEvent($user), UserRegisteredEvent::class);          $this->assertTrue($called);     } }

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

Типичные ошибки

Немного опыта:

Ошибка

Почему плохо

Использовать голый Event без данных

Потом непонятно, что передавать и как обрабатывать

Не использовать отдельные классы для событий

Логика становится нечитаемой и сложно расширяемой

Игнорировать stopPropagation

Лишние слушатели продолжают работать, могут поломать процесс

Смешивать бизнес-логику и отправку событий

Нельзя. Диспатчинг — это сигнал, не место для тяжёлой логики.

Мини-проект

Допустим, нам нужно нужно смоделировать мини-проект интернет-магазина, который продаёт корм для котиков. После оформления заказа должны произойти следующие действия:

  1. Отправить покупателю письмо с благодарностью.

  2. Уведомить склад о необходимости собрать заказ.

  3. Начислить бонусные баллы клиенту.

  4. В случае проблем остановить цепочку событий.

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

Архитектура событий

Для начала определимся с основной схемой: событие у нас будет называться OrderPlacedEvent, а реагировать на него будут сразу три слушателя —SendThankYouEmailListener (отправить письмо с благодарностью), NotifyWarehouseListener (уведомить склад о заказе) и AccrueBonusPointsListener (начислить клиенту бонусные баллы). Вся координация действий будет происходить через EventDispatcher.

Создание события

Создадим класс события OrderPlacedEvent, который будет содержать данные о заказе.

namespace App\Event;  use Symfony\Contracts\EventDispatcher\Event; use App\Entity\Order;  class OrderPlacedEvent extends Event {     public function __construct(private Order $order)     {     }      public function getOrder(): Order     {         return $this->order;     } }

Событие несёт внутри себя объект заказа. Через метод getOrder() слушатели могут получить доступ к данным.

Сущность заказа

Для полноты картины сделаем упрощённую модель заказа:

namespace App\Entity;  class Order {     private int $id;     private string $customerEmail;     private bool $stockAvailable;      public function __construct(int $id, string $customerEmail, bool $stockAvailable = true)     {         $this->id = $id;         $this->customerEmail = $customerEmail;         $this->stockAvailable = $stockAvailable;     }      public function getId(): int     {         return $this->id;     }      public function getCustomerEmail(): string     {         return $this->customerEmail;     }      public function isStockAvailable(): bool     {         return $this->stockAvailable;     } }

Флаг stockAvailable показывает, есть ли товар на складе. Если его нет — событие должно быть остановлено, чтобы не слать письма и не начислять бонусы зря.

Реализация слушателей

Теперь создаём три слушателя.

Отправка письма благодарности

namespace App\Listener;  use App\Event\OrderPlacedEvent;  class SendThankYouEmailListener {     public function __invoke(OrderPlacedEvent $event): void     {         $order = $event->getOrder();         $email = $order->getCustomerEmail();          // Имитируем отправку письма         echo "Отправляем письмо на {$email}: Спасибо за заказ для вашего котика!\n";     } }

__invoke, чтобы слушателя можно было регистрировать компактно.

Уведомление склада

namespace App\Listener;  use App\Event\OrderPlacedEvent;  class NotifyWarehouseListener {     public function __invoke(OrderPlacedEvent $event): void     {         $order = $event->getOrder();          if (!$order->isStockAvailable()) {             echo "Ошибка: нет товара на складе. Останавливаем событие.\n";             $event->stopPropagation();             return;         }          echo "Уведомляем склад: собрать заказ №{$order->getId()}.\n";     } }

Если товара нет, то сразу вызываем stopPropagation(), чтобы прекратить дальнейшую обработку.

Начисление бонусов

namespace App\Listener;  use App\Event\OrderPlacedEvent;  class AccrueBonusPointsListener {     public function __invoke(OrderPlacedEvent $event): void     {         $order = $event->getOrder();         echo "Начисляем бонусные баллы покупателю с email: {$order->getCustomerEmail()}.\n";     } }

Бонусы начисляются только если событие не было остановлено ранее.

Сборка всего вместе

Создадим диспетчер и зарегистрируем слушателей.

use Symfony\Component\EventDispatcher\EventDispatcher; use App\Event\OrderPlacedEvent; use App\Listener\SendThankYouEmailListener; use App\Listener\NotifyWarehouseListener; use App\Listener\AccrueBonusPointsListener; use App\Entity\Order;  // Инициализируем диспетчер $dispatcher = new EventDispatcher();  // Регистрируем слушателей $dispatcher->addListener(OrderPlacedEvent::class, new NotifyWarehouseListener(), 20); $dispatcher->addListener(OrderPlacedEvent::class, new SendThankYouEmailListener(), 10); $dispatcher->addListener(OrderPlacedEvent::class, new AccrueBonusPointsListener(), 0);  // Создаём заказ $order = new Order(101, 'catlover@example.com', true);  // Диспатчим событие $dispatcher->dispatch(new OrderPlacedEvent($order));

Что увидим на выходе?

Если товар есть на складе:

Уведомляем склад: собрать заказ №101. Отправляем письмо на catlover@example.com: Спасибо за заказ для вашего котика! Начисляем бонусные баллы покупателю с email: catlover@example.com.

Если товара нет:

Ошибка: нет товара на складе. Останавливаем событие.

И всё — никакого письма и бонусов.

В итоге

EventDispatcher — мощная штука, если пользоваться с умом: чёткие события, отдельные классы, нормальные подписчики и порядок вызовов. А как у вас с событиями в проектах? Часто ими пользуетесь или пока мимо?


В заключение рекомендую посетить открытый урок по локализации текстов в Symfony, который пройдет 15 мая в 20:00 в OTUS. Разберём локализацию как статичных, так и динамических текстов, хранимых в базе данных, с использованием компонента symfony/translation. Узнаете, как эффективно работать с переводами и нестандартным маппингом.

Готовы проверить свои знания по Symfony? Пройдите вступительное тестирование и узнайте, насколько уверенно вы себя чувствуете в теме.


ссылка на оригинал статьи https://habr.com/ru/articles/905682/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *