besnovatyj/yii2-cms-smart-domain-events
Composer 安装命令:
composer require besnovatyj/yii2-cms-smart-domain-events
包简介
Реализация паттерна Domain Events с поддержкой асинхронности и транзакций для Yii2 CMS
关键字:
README 文档
README
Архитектурная реализация паттерна Domain Events с поддержкой асинхронности и транзакционной безопасности для Yii2.
Содержание
- Обзор архитектуры
- Компоненты системы
- Поток данных
- Создание событий
- Создание слушателей
- Конфигурация
- Работа с транзакциями
- Преимущества системы
Обзор архитектуры
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. ENTITY (Aggregate Root) │
│ Contact::create() → recordEvent(new ContactMessageSent($contact)) │
│ Событие записывается в приватный массив $events │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2. REPOSITORY │
│ ContactRepository::save($contact) │
│ → $contact->save() │
│ → $dispatcher->dispatchAll($contact->releaseEvents()) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. DISPATCHER CHAIN (Decorator Pattern) │
│ │
│ EventDispatcher (interface) │
│ │ │
│ ├── DeferredEventDispatcher (откладывает события) │
│ │ │ │
│ │ └── AsyncEventDispatcher (помещает в Queue) │
│ │ │ │
│ │ └── AsyncEventJob → Queue │
│ │ │
│ └── SimpleEventDispatcher (вызывает listeners синхронно) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 4. QUEUE WORKER (отдельный процесс) │
│ AsyncEventJob::execute() │
│ → AsyncEventJobHandler::handle() │
│ → SimpleEventDispatcher::dispatch(event) │
│ → [Listener1::handle(), Listener2::handle(), ...] │
└─────────────────────────────────────────────────────────────────────────────┘
Компоненты системы
Интерфейсы и базовые классы
| Файл | Назначение |
|---|---|
AggregateRoot.php |
Интерфейс для сущностей, генерирующих события |
EventTrait.php |
Трейт с реализацией записи и освобождения событий |
EntityEvent.php |
Базовый класс событий с безопасной сериализацией |
TransactionManager.php |
Обёртка для транзакций с отложенными событиями |
Диспетчеры (dispatchers/)
| Файл | Назначение |
|---|---|
EventDispatcher.php |
Интерфейс диспетчера |
SimpleEventDispatcher.php |
Синхронный вызов слушателей по карте событий |
DeferredEventDispatcher.php |
Декоратор для отложенной отправки (транзакции) |
AsyncEventDispatcher.php |
Помещает события в очередь для асинхронной обработки |
Задачи очереди (jobs/)
| Файл | Назначение |
|---|---|
Job.php |
Базовый класс задач с автоматическим разрешением обработчика |
AsyncEventJob.php |
Задача очереди, содержащая сериализованное событие |
AsyncEventJobHandler.php |
Обработчик задачи, вызывающий SimpleEventDispatcher |
Поток данных
Пример: отправка контактной формы
// 1. Контроллер вызывает сервис $contactService->send($form); // 2. Сервис создаёт сущность $contact = Contact::create($name, $email, $phone, $body, $notify_flag = true); // Внутри: $this->events[] = new ContactMessageSent($contact); // 3. Репозиторий сохраняет и диспатчит события $this->repo->save($contact); // Внутри: // $contact->save() → запись в БД, получение ID // $dispatcher->dispatchAll($contact->releaseEvents()) → отправка событий // 4. DeferredEventDispatcher // defer=false → сразу передаёт в AsyncEventDispatcher // defer=true → копит в очередь до вызова release() // 5. AsyncEventDispatcher $queue->push(new AsyncEventJob($event)); // Событие сериализуется и попадает в yii2-queue // 6. Queue Worker (./yii queue/listen) // AsyncEventJob::execute() // → AsyncEventJobHandler::handle($job) // → SimpleEventDispatcher::dispatch($job->event) // → ContactMessageEmailSentListener::handle($event) // → ContactMessageTGSentListener::handle($event)
Создание событий
Базовый класс EntityEvent
Для безопасной сериализации событий, содержащих ActiveRecord сущности, используйте базовый класс EntityEvent:
<?php namespace Besnovatyj\Contact\entities\events; use Besnovatyj\DomainEvents\EntityEvent; use Besnovatyj\Contact\entities\Contact; class ContactMessageSent extends EntityEvent { public function __construct(Contact $contact) { parent::__construct($contact); } protected function findEntity(int $id): ?Contact { return Contact::findOne($id); } public function getContact(): Contact { return $this->getEntity(); } }
Как это работает:
- При создании события передаётся объект сущности (ID может быть ещё
null) - При сериализации (помещении в очередь) сохраняется только
$entity->id - При десериализации (в воркере) сущность загружается из БД по ID
Сущность с событиями (Aggregate Root)
<?php namespace Besnovatyj\Contact\entities; use Besnovatyj\DomainEvents\AggregateRoot; use Besnovatyj\DomainEvents\EventTrait; use Besnovatyj\Contact\entities\events\ContactMessageSent; use yii\db\ActiveRecord; class Contact extends ActiveRecord implements AggregateRoot { use EventTrait; public static function create($name, $email, $phone, $body, $notify): self { $contact = new static(); $contact->name = $name; $contact->email = $email; $contact->phone = $phone; $contact->body = $body; $contact->date = time(); if ($notify) { $contact->recordEvent(new ContactMessageSent($contact)); // TODO: Вынос в сервис `$entity->recordEvent()`. Там после этого `$repo->save();` } return $contact; } }
Создание слушателей
Слушатель — это класс с методом handle(), принимающим событие:
<?php namespace Besnovatyj\Contact\listeners; use Besnovatyj\Contact\entities\events\ContactMessageSent; use yii\mail\MailerInterface; class ContactMessageEmailSentListener { private MailerInterface $mailer; public function __construct(MailerInterface $mailer) { $this->mailer = $mailer; } public function handle(ContactMessageSent $event): void { $contact = $event->getContact(); $this->mailer ->compose(['html' => 'contact/body-html'], ['contact' => $contact]) ->setTo('admin@example.com') ->setSubject('Новое сообщение с сайта') ->send(); } }
<?php namespace Besnovatyj\Contact\listeners; use Besnovatyj\Contact\entities\events\ContactMessageSent; class ContactMessageTGSentListener { public function handle(ContactMessageSent $event): void { $contact = $event->getContact(); // Отправка в Telegram... } }
Конфигурация
Регистрация в DI-контейнере (\Besnovatyj\DomainEvents\Bootstrap)
<?php use Besnovatyj\DomainEvents\dispatchers\AsyncEventDispatcher; use Besnovatyj\DomainEvents\dispatchers\DeferredEventDispatcher; use Besnovatyj\DomainEvents\dispatchers\EventDispatcher; use Besnovatyj\DomainEvents\dispatchers\SimpleEventDispatcher; use Besnovatyj\DomainEvents\jobs\AsyncEventJobHandler; use Besnovatyj\Contact\entities\events\ContactMessageSent; use Besnovatyj\Contact\listeners\ContactMessageEmailSentListener; use Besnovatyj\Contact\listeners\ContactMessageTGSentListener; use yii\di\Container; use yii\di\Instance; use yii\queue\Queue; class SetUp implements BootstrapInterface { public function bootstrap($app): void { $container = \Yii::$container; // 1. По умолчанию используется DeferredEventDispatcher $container->setSingleton(EventDispatcher::class, DeferredEventDispatcher::class); // 2. DeferredEventDispatcher оборачивает AsyncEventDispatcher (Decorator) $container->setSingleton(DeferredEventDispatcher::class, function (Container $container) { $queue = $container->get(Queue::class); return new DeferredEventDispatcher(new AsyncEventDispatcher($queue)); }); // 3. Карта событий → слушатели $container->setSingleton(SimpleEventDispatcher::class, function (Container $container) { return new SimpleEventDispatcher($container, [ ContactMessageSent::class => [ ContactMessageEmailSentListener::class, ContactMessageTGSentListener::class, ], // Другие события... ]); }); // 4. Обработчик задач получает SimpleEventDispatcher $container->setSingleton(AsyncEventJobHandler::class, [], [ Instance::of(SimpleEventDispatcher::class) ]); } }
Добавление нового слушателя
Чтобы добавить слушателя к событию, достаточно добавить класс в массив:
ContactMessageSent::class => [
ContactMessageEmailSentListener::class,
ContactMessageTGSentListener::class, // ← добавлено
ContactMessageSlackListener::class, // ← ещё один
],
Работа с транзакциями
Проблема
При сохранении нескольких связанных сущностей в одной транзакции события могут быть отправлены до успешного коммита:
// Плохо: событие уходит, а транзакция может откатиться $order->save(); $dispatcher->dispatchAll($order->releaseEvents()); // Email "Заказ создан" уже ушёл! $payment->save(); // Ошибка! Rollback! А email уже отправлен...
Решение: TransactionManager
<?php use Besnovatyj\DomainEvents\TransactionManager; class OrderService { private TransactionManager $transaction; private OrderRepository $orders; private PaymentRepository $payments; public function __construct( TransactionManager $transaction, OrderRepository $orders, PaymentRepository $payments ) { $this->transaction = $transaction; $this->orders = $orders; $this->payments = $payments; } public function createOrder(OrderForm $form): void { $this->transaction->wrap(function () use ($form) { $order = Order::create($form); $this->orders->save($order); // События откладываются $payment = Payment::create($order); $this->payments->save($payment); // События откладываются }); // После успешного commit() все события отправляются // При rollback() события очищаются } }
Как работает TransactionManager
public function wrap(callable $function): void { $transaction = Yii::$app->db->beginTransaction(); try { $this->dispatcher->defer(); // Включить режим накопления $function(); $transaction->commit(); $this->dispatcher->release(); // Отправить все накопленные события } catch (Exception $e) { $transaction->rollBack(); $this->dispatcher->clean(); // Очистить события без отправки throw $e; } }
Преимущества системы
| Преимущество | Описание |
|---|---|
| Domain Events | События генерируются в бизнес-логике (Entity), а не в инфраструктуре |
| Транзакционная безопасность | События отправляются только после успешного commit |
| Асинхронность | Тяжёлые операции (email, Telegram) не блокируют HTTP-запрос |
| Расширяемость | Добавить слушателя = одна строка в конфигурации |
| Decorator Pattern | Можно менять поведение (sync/async/deferred) без изменения кода |
| Безопасная сериализация | EntityEvent решает проблему сериализации ActiveRecord |
| Testability | Легко мокать EventDispatcher в тестах |
| Слабая связанность | Сущности не знают о слушателях, слушатели не зависят друг от друга |
Запуск воркера очереди
# Непрерывное прослушивание (для production с supervisor) ./yii queue/listen # Однократная обработка (для cron) ./yii queue/run # С указанием количества воркеров ./yii queue/listen --isolate=1
Отладка
Проверка, что события создаются
$contact = Contact::create(...); var_dump($contact->releaseEvents()); // Должен быть массив с событием
Проверка очереди
# Посмотреть статус очереди (зависит от драйвера)
./yii queue/info
Логирование в слушателе
public function handle(ContactMessageSent $event): void { Yii::info('Processing ContactMessageSent for ID: ' . $event->getContact()->id); // ... }
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 0
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-06