Привет, Хабр!
Сегодня рассмотрим одну из самых сильных сторон Symfony — компонент EventDispatcher.
Если очень кратко, EventDispatcher позволяет создавать реактивную архитектуру: одни части приложения инициируют события, другие на них реагируют, не зная напрямую друг о друге.
В итоге проект получается гибким, расширяемым, легко тестируемым и не превращается в ужасный комок зависимостей.
Но чтобы использовать EventDispatcher правильно, мало просто вызвать dispatch()
в коде. Нужно понимать:
как создавать свои события
как проектировать подписчиков
как управлять порядком вызовов
как останавливать цепочки событий
как тестировать их безопасно
как не наломать архитектуру плохим проектированием событий
И всё это мы сегодня коротко разберем.
Как устроен EventDispatcher
Когда мы диспатчим событие, происходит следующее:
Мы создаём объект события, т.е
Event
или его наследник.Вызываем
dispatch($event, $eventName)
.Внутри компонента по имени события ищутся все зарегистрированные слушатели.
Каждый слушатель вызывается с этим объектом события.
Если кто-то остановит распространение события
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 |
Лишние слушатели продолжают работать, могут поломать процесс |
Смешивать бизнес-логику и отправку событий |
Нельзя. Диспатчинг — это сигнал, не место для тяжёлой логики. |
Мини-проект
Допустим, нам нужно нужно смоделировать мини-проект интернет-магазина, который продаёт корм для котиков. После оформления заказа должны произойти следующие действия:
Отправить покупателю письмо с благодарностью.
Уведомить склад о необходимости собрать заказ.
Начислить бонусные баллы клиенту.
В случае проблем остановить цепочку событий.
Все эти действия должны быть реализованы через цепочку событий, чтобы система оставалась гибкой и расширяемой.
Архитектура событий
Для начала определимся с основной схемой: событие у нас будет называться 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? Пройдите вступительное тестирование и узнайте, насколько уверенно вы себя чувствуете в теме.
Dhwtj
event driven architecture хорош для статической типизации, т.к. уменьшает зависимости
а зачем это тута?
Dhwtj
То есть, я понял бы пользу на C#/Java/Go
Но для динамически типизированных языков пользы от событийной архитектуры меньше, а затраты те же