Практика: доменные события заказа (Domain event dispatcher)
Заказ создан или отменён, а почта, склад и аналитика узнают об этом через диспетчер событий: подписчики падают, отписываются на ходу и публикуют новые события, и рассылка должна это пережить.
Базовый пример: Наблюдатель. Там один тикер рассылает одно событие послушным подписчикам. Здесь событий несколько типов, один подписчик падает, другой отписывается во время рассылки, а склад в ответ на «заказ создан» публикует «заказ отменён».
Задача
Интернет-магазин начинал с метода placeOrder, который сам отправлял письмо, резервировал товар и писал в аналитику. Потом добавились отмены, потом маркетинг попросил ещё одно письмо, а склад научился отменять заказ, если товара нет. Метод оброс зависимостями от всех команд сразу, и когда почтовый сервер лёг, вместе с письмом не зарезервировался товар.
Наблюдатель выносит реакции в подписчиков: заказ публикует событие OrderPlaced, а почта, склад и аналитика подписываются на него сами. Но простой цикл из базового примера в жизни ломается в четырёх местах: ошибка одного подписчика обрывает остальных; если подписчик отписывается внутри рассылки, цикл по массиву перепрыгивает соседа; если подписчик публикует новое событие, рассылка уходит в рекурсию и порядок событий переворачивается; а порядок подписчиков должен быть одинаковым при каждом запуске, иначе тесты и журналы не сойдутся.
Что урезано
- Почта — функция «кому, текст»: в тестах пишет в список или бросает
smtp down. - Склад и аналитика — словари и списки в памяти; один товар на заказ, без цен и оплаты.
- Ошибки подписчиков возвращаются из
publishсписком. В проекте их пишут в журнал и метрики, но решать, что с ними делать, должен вызывающий, а не диспетчер. - Всё синхронно и в одном потоке: без асинхронных обработчиков, приоритетов и подписки на «все события».
- Нет надёжной доставки: если процесс упадёт посреди рассылки, событие потеряется (см. «Где граница»).
Решение
События OrderPlaced и OrderCancelled — неизменяемые объекты-факты в прошедшем времени: что случилось и с каким заказом.
Диспетчер EventDispatcher — издатель из базового примера, выросший в отдельный объект:
subscribe(тип, имя, обработчик)подписывает на события одного типа и возвращает функцию отписки. Имя нужно для ошибок и журналов.- Подписчики одного типа хранятся в порядке подписки: порядок рассылки детерминирован.
publishловит исключение каждого обработчика, оборачивает его вSubscriberFailed(кто упал, на каком событии, исходная причина) и продолжает рассылку. Все ошибки возвращаются вызывающему.- Перед рассылкой события берётся снимок списка подписчиков: отписка изнутри обработчика вступает в силу со следующего события и не сдвигает соседей.
- Если
publishвызвали во время рассылки, событие ставится в очередь, а внешнийpublishразошлёт его, когда закончит текущее. Ошибки каскадных событий возвращает тоже внешний вызов.
Подписчики Mailer, Warehouse и Analytics подписываются сами методом subscribeTo(dispatcher) и друг о друге не знают.
Структура
Диспетчер держит три вещи: подписчиков по типу события (каждому присвоен номер, по нему работает отписка), очередь событий и флаг «идёт рассылка». Флаг и очередь вместе заменяют рекурсию циклом: вложенный publish только кладёт событие в очередь и сразу возвращается.
Каскадные события
Склад при нехватке товара публикует OrderCancelled прямо из обработчика OrderPlaced. Подписчики идут в порядке «почта, склад, аналитика». С прямым вложенным вызовом аналитика сначала записала бы «cancelled 1», а потом «placed 1»: отмена обогнала бы создание, хотя случилась позже. С очередью все подписчики сначала получают OrderPlaced, потом все получают OrderCancelled: журнал аналитики placed 1, cancelled 1, письма приходят в том же порядке.
Цена очереди: вложенный publish не знает, чем кончится рассылка его события, и возвращает пустой список. Ошибки каскада видит тот, кто опубликовал первое событие. Это честно: обработчик склада не может исправить падение почты.
Как это собирается
Диспетчер создаётся один раз при старте приложения, и каждый модуль подписывается сам: Mailer, Warehouse и Analytics получают EventDispatcher и вызывают subscribeTo. Касса о них не знает: она создаёт OrderPlaced и вызывает publish, а в ответ получает список упавших подписчиков.
В примере на складе 3 штуки SKU-1. Первый заказ резервирует 2, на второй товара не хватает, и склад сам публикует отмену: письмо об отмене и запись cancelled 2 встают в очередь после placed 2. У третьего заказа почтовый ящик недоступен: почта падает, но склад и аналитика отрабатывают. Касса не откатывает заказ, а пишет ошибку в лог и отдаёт письмо на повтор - решение остаётся за вызывающим кодом, а не за диспетчером.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Подписчики одинаковы по смыслу везде: почта пишет на оба события, склад резервирует товар или отменяет заказ, а при отмене возвращает зарезервированное, аналитика ведёт журнал воронки. Числа одни и те же: на складе 5 штук SKU-1, заказ на 2.
| Язык | Особенность |
|---|---|
| PHP | Тип события — имя класса (OrderPlaced::class); foreach идёт по копии массива, поэтому снимок получается бесплатно |
| Go | Обобщённая функция Subscribe[E] и ключ reflect.TypeFor[E](); ошибки собираются в errors.Join, SubscriberError поддерживает errors.Is |
| Python | События — frozen dataclass, ключ — сам класс; dict хранит порядок вставки, снимок — list(handlers.values()) |
| TypeScript | События — размеченное объединение, subscribe("OrderPlaced", ...) сужает тип события в обработчике; Error с cause |
Запуск: make test T=patterns/behavioral/observer/practice
Что проверяют тесты
Тесты одинаковы во всех языках, семь случаев:
- Порядок: четыре подписки вперемешку на два типа;
OrderPlacedполучают только свои три, в порядке подписки. - Обычный заказ: письмо отправлено, на складе осталось 3, в аналитике
placed 1, ошибок нет. - Отмена: после
OrderCancelledна складе снова 5, резерв вернулся. - Падение почты: в результате одна ошибка
mailer failed on OrderPlaced: smtp downс исходной причиной внутри, а склад и аналитика отработали. - Отписка во время рассылки: подписчик
onceотписывается в своём обработчике; соседafterвсё равно получает первое событие, второе событиеonceуже не видит. - Каскад: на складе 1 штука, заказ на 2. Склад публикует отмену, аналитика записывает
placed 1, cancelled 1именно в этом порядке, письма тоже, остаток не тронут. - Ошибки каскада: почта лежит, и внешний
publishвозвращает обе ошибки — по созданию и по отмене.
Где граница
- Диспетчер работает в памяти процесса. Если письмо или запись в аналитику должны дойти обязательно, даже после падения сервера, событие сначала сохраняют в базу в той же транзакции, что и заказ, а потом доставляют отдельным процессом. Это Outbox: наблюдатель отвечает за «кому разослать», outbox — за «не потерять».
- Если подписчик медленный (письмо по сети), синхронная рассылка задерживает заказ. Тогда обработчик кладёт задачу в очередь сообщений, а не делает работу сам.
- Если подписчиков нужно запускать в определённом порядке, потому что один зависит от результата другого, это уже не наблюдатель, а явный сценарий: порядок подписки — слишком хрупкое место для бизнес-правила.
- Если тип события один и подписчиков два, хватит базового примера: диспетчер с очередью и сбором ошибок окупается, когда событий и команд-подписчиков становится много.
Отличия между языками
- PHP —
foreachпо массиву работает с копией (copy-on-write), поэтому отписка внутри обработчика не ломает текущий обход без явного снимка. ЛовитсяException, а неThrowable:TypeErrorи прочиеError— баги, они должны остановить рассылку. В Symfony и Laravel этоEventDispatcherс тем же устройством плюс приоритеты. - Go — обработчик возвращает
error, паники не перехватываются. Срез подписчиков копируетсяslices.Cloneперед обходом:slices.DeleteFuncпри отписке сдвигает элементы в том же массиве, и без копии цикл пропустил бы соседа. Диспетчер не потокобезопасен; для горутин нужен мьютекс, и тогда очередь становится ещё важнее: держать мьютекс во время вызова обработчика нельзя. - Python — менять
dictво время обхода нельзя (RuntimeError: dictionary changed size during iteration), поэтому снимок обязателен.SubscriberFailedхранит причину в__cause__, какraise ... from error. - TypeScript — обход
Map«живой»: удалённые во время обхода записи не посещаются, добавленные посещаются. Снимок[...map.values()]делает поведение таким же, как в других языках. Обработчик хранится как(event: OrderEvent) => voidчерез приведение типа: безопасно, потому что диспетчер вызывает его только для событий своего типа.
Связанные темы
- Наблюдатель (базовый пример)
- Практика Фасада: оформление заказа — тот же заказ, но шаги вызываются явно и по порядку, с откатом
- Практика Декоратора: клиент курсов валют — ретраи и журнал вокруг одного вызова, а не рассылка многим