Наблюдатель (Observer)
Объект сообщает об изменениях всем, кто подписался, и не знает, кто они: подписчиков можно добавлять и убирать, не трогая источник.
Проблема
Биржевой тикер получает новую цену акции. Её нужно показать на табло, проверить по порогу оповещения, а завтра ещё записать в график и отправить в мобильное приложение. Если тикер сам вызывает board.update(), alert.check() и chart.add(), он знает обо всех потребителях: каждый новый — правка тикера, тест тикера тянет за собой табло и почту, а отключить оповещения на время без if не получится.
Решение
Перевернуть зависимость. Источник — издатель StockTicker — хранит список подписчиков и при изменении цены обходит его. О подписчиках он знает одно: у них есть метод из контракта PriceObserver — priceChanged(symbol, price).
Подписчики PriceBoard и PriceAlert реализуют контракт и сами решают, что делать с новостью: табло запоминает последнюю цену, оповещение срабатывает выше порога. Подписка и отписка — методы издателя, их вызывает тот, кто собирает систему. Новый потребитель — новый класс и одна строка subscribe, тикер не меняется.
Структура
Издатель держит подписчиков в порядке подписки и рассылает уведомление, только если цена действительно изменилась: повтор той же цены никого не будит. В уведомлении — символ акции и цена (модель «push»): одному табло можно подписаться на несколько тикеров и различать их по символу. Цены — целые центы, без float.
Как это собирается
Систему собирает терминал при старте: по списку символов из избранного создаётся StockTicker на каждый, к каждому подписывается одно общее PriceBoard и своё PriceAlert с порогом из настроек пользователя. Тикеры складываются в карту "символ -> тикер", и дальше обработчик биржевого потока только находит тикер по символу и зовёт setPrice. Кто и что сделает с ценой, обработчик не знает.
Поток приходит как есть: котировка неизвестного символа XYZ не роняет обработку, обработчик её пропускает и пишет в лог. Повтор той же цены ACME никого не будит, поэтому оповещение не срабатывает дважды. В конце табло показывает последние цены обоих тикеров, а оповещения - все пересечения порогов.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Подписчики не знают ни друг о друге, ни о том, сколько их всего. У каждого своё состояние: табло — словарь «символ -> цена», оповещение — порог и список сработок. Один и тот же объект может слушать разные тикеры.
| Язык | Особенность |
|---|---|
| PHP | Интерфейс PriceObserver; подписчики хранятся по spl_object_id, поэтому повторная подписка того же объекта не дублирует его |
| Go | Subscribe возвращает функцию отписки: подписка помнит свой номер, сравнивать подписчиков не нужно (функции в Go несравнимы) |
| Python | Подписчик — любой вызываемый объект Callable[[str, int], None]; подписывают связанный метод board.price_changed |
| TypeScript | Set<PriceObserver> хранит порядок вставки и удаляет по ссылке; subscribe возвращает функцию отписки |
Запуск: make test T=patterns/behavioral/observer
Плюсы и минусы
Тесты одинаковы во всех языках и проверяют пять случаев: табло показывает последнюю цену; оба подписчика получают одно и то же изменение (табло — 12000, оповещение с порогом 11000 — одна сработка); после отписки табло застывает на старой цене; одна и та же цена дважды даёт одно уведомление; одно табло следит за двумя тикерами.
- ✅ Издатель не зависит от конкретных подписчиков: новые добавляются без правки источника.
- ✅ Подписку можно менять во время работы: включить оповещение, отключить табло.
- ✅ Издатель и подписчики тестируются отдельно.
- ❌ Порядок и время уведомлений неявны: из кода тикера не видно, кто и что сделает при
setPrice. - ❌ Ошибка одного подписчика здесь прерывает рассылку остальным; отписка и новая публикация изнутри уведомления — отдельные ловушки. Всё это разбирает практика.
- ❌ Забытая отписка держит подписчика в памяти (утечка «висящих слушателей»).
Когда не применять
- Потребитель один и не меняется: прямой вызов понятнее.
- Нужен ответ от получателя (вернуть значение, отказать): это не уведомление, а вызов. Подойдёт Стратегия или цепочка обработчиков.
- Подписчики зависят друг от друга и должны идти в строгом порядке с общим результатом: это сценарий, его лучше написать явно.
- Событие должно пережить падение процесса или дойти до другого сервиса: внутрипроцессный наблюдатель этого не гарантирует, нужна очередь сообщений или outbox.
Отличия между языками
- PHP — классический вариант: интерфейс и объекты. В SPL есть готовые
SplSubjectиSplObserver, но ихupdate(SplSubject $subject)передаёт сам издатель (модель «pull») и не типизирует данные, поэтому свой узкий интерфейс удобнее. Массив с ключомspl_object_idдаёт порядок и отписку за O(1). - Go — интерфейс с одним методом; часто подписчиком делают просто
func(symbol string, price int). Функции в Go несравнимы, поэтому отписка по значению невозможна, и идиома — вернуть изSubscribeфункцию отмены (какcontext.WithCancel). Для асинхронной рассылки в Go берут каналы, но это уже другой контракт: буфер, закрытие, горутины. - Python — интерфейс не нужен: подписчик — любой вызываемый объект. Связанные методы
board.price_changedпри каждом обращении создаются заново, но сравниваются по==(тот же объект и та же функция), поэтомуlist.removeих находит. Лямбду так не отпишешь, её надо сохранить в переменную. - TypeScript —
Setсохраняет порядок вставки и не допускает дублей; функция отписки изsubscribe— привычная идиома (какremoveEventListenerнаоборот). В браузере и Node есть готовыеEventTargetиEventEmitter— это тот же паттерн с событиями по имени.
Практика
- Учебный тикер рассылает одно событие подписчикам, которые не падают и ничего не публикуют в ответ. В жизни так не бывает: практика: доменные события заказа показывает диспетчер с подпиской по типу события, детерминированным порядком, сбором ошибок подписчиков, безопасной отпиской во время рассылки и очередью вместо рекурсии, когда склад отменяет заказ в ответ на его создание.
- Учебный подписчик не помнит прошлых событий и не знает о времени. Практика: поток показаний приборов учёта показывает подписчиков с состоянием: тревогу по порогу, контроль связи, который видит тишину только по явному тику часов, суточный расход по UTC и монтажника, который отписывается сам посреди рассылки.
Связанные темы
- Практика: доменные события заказа — диспетчер, ошибки подписчиков, отписка во время рассылки, очередь каскадных событий
- Практика: поток показаний приборов учёта - подписчики с состоянием, явное время и тики, отписка во время рассылки
- Стратегия (Strategy) — тоже подставляет поведение через интерфейс, но один вызов и с ответом, а не рассылка