handbookНаписать

patterns/behavioral/observer/practice/README.md · практика · 4 языка · 31 тест

Практика: доменные события заказа (Domain event dispatcher) ​

Заказ создан или отменён, а почта, склад и аналитика узнают об этом через диспетчер событий: подписчики падают, отписываются на ходу и публикуют новые события, и рассылка должна это пережить.

Базовый пример: Наблюдатель. Там один тикер рассылает одно событие послушным подписчикам. Здесь событий несколько типов, один подписчик падает, другой отписывается во время рассылки, а склад в ответ на «заказ создан» публикует «заказ отменён».

Задача ​

Интернет-магазин начинал с метода placeOrder, который сам отправлял письмо, резервировал товар и писал в аналитику. Потом добавились отмены, потом маркетинг попросил ещё одно письмо, а склад научился отменять заказ, если товара нет. Метод оброс зависимостями от всех команд сразу, и когда почтовый сервер лёг, вместе с письмом не зарезервировался товар.

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

Что урезано ​

  • Почта — функция «кому, текст»: в тестах пишет в список или бросает smtp down.
  • Склад и аналитика — словари и списки в памяти; один товар на заказ, без цен и оплаты.
  • Ошибки подписчиков возвращаются из publish списком. В проекте их пишут в журнал и метрики, но решать, что с ними делать, должен вызывающий, а не диспетчер.
  • Всё синхронно и в одном потоке: без асинхронных обработчиков, приоритетов и подписки на «все события».
  • Нет надёжной доставки: если процесс упадёт посреди рассылки, событие потеряется (см. «Где граница»).

Решение ​

События OrderPlaced и OrderCancelled — неизменяемые объекты-факты в прошедшем времени: что случилось и с каким заказом.

Диспетчер EventDispatcher — издатель из базового примера, выросший в отдельный объект:

  • subscribe(тип, имя, обработчик) подписывает на события одного типа и возвращает функцию отписки. Имя нужно для ошибок и журналов.
  • Подписчики одного типа хранятся в порядке подписки: порядок рассылки детерминирован.
  • publish ловит исключение каждого обработчика, оборачивает его в SubscriberFailed (кто упал, на каком событии, исходная причина) и продолжает рассылку. Все ошибки возвращаются вызывающему.
  • Перед рассылкой события берётся снимок списка подписчиков: отписка изнутри обработчика вступает в силу со следующего события и не сдвигает соседей.
  • Если publish вызвали во время рассылки, событие ставится в очередь, а внешний publish разошлёт его, когда закончит текущее. Ошибки каскадных событий возвращает тоже внешний вызов.

Подписчики Mailer, Warehouse и Analytics подписываются сами методом subscribeTo(dispatcher) и друг о друге не знают.

Структура ​

returns

delivers

delivers

subscribes

subscribes, publishes

subscribes

OrderPlaced

+orderId: int

+email: string

+sku: string

+quantity: int

OrderCancelled

+orderId: int

+email: string

+reason: string

EventDispatcher

-subscribers: map

-queue: list

-dispatching: bool

+subscribe(type, name, handler) : unsubscribe

+publish(event) : SubscriberFailed[]

SubscriberFailed

+subscriber: string

+event

Mailer

-send: fn

+subscribeTo(dispatcher)

Warehouse

+stock: map

-reserved: map

+subscribeTo(dispatcher)

Analytics

+log: list

+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 через приведение типа: безопасно, потому что диспетчер вызывает его только для событий своего типа.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS