Наблюдатель

Наблюдатель (Observer) — это поведенческий паттерн проектирования, который позволяет объекту оповещать произвольный список подписчиков о своих событиях, не зная заранее, кто на него подписан и что эти подписчики будут делать.
Проблема
По завершении диагностической сессии нужно оповестить сразу несколько систем: отправить клиенту SMS с результатом, обновить панель мастера-приёмщика и отправить событие в аналитику. DiagnosticSession делает это напрямую:
final class DiagnosticSession
{
public function __construct(
private readonly SmsNotifier $sms,
private readonly AdvisorDashboard $dashboard,
private readonly CrmAnalytics $analytics,
) {}
public function complete(DiagnosticReport $report): void
{
// ... завершение сессии
$this->sms->send($report);
$this->dashboard->push($report);
$this->analytics->track($report);
}
}Маркетинг просит добавить ещё одного получателя — сервис email-рассылок с рекомендациями по ремонту на основе результатов диагностики. Через месяц добавляется ещё и Telegram-бот для мастеров. Каждое такое требование означает правку конструктора и метода complete() в DiagnosticSession — классе, чья задача заключается в проведении диагностики, а не в рассылке уведомлений о её результатах. Заодно любой тест DiagnosticSession, которому вообще не важны уведомления, вынужден создавать или подменять все четыре зависимости сразу, лишь бы класс собрался.
Решение
Наблюдатель избавляет DiagnosticSession от знания о конкретных получателях. Она хранит список подписчиков за общим интерфейсом и оповещает их, не зная, кто и как на это отреагирует:
interface DiagnosticObserver
{
public function onSessionCompleted(DiagnosticReport $report): void;
}
final class DiagnosticSession
{
/** @var DiagnosticObserver[] */
private array $observers = [];
public function subscribe(DiagnosticObserver $observer): void
{
$this->observers[] = $observer;
}
public function complete(DiagnosticReport $report): void
{
// ... завершение сессии
foreach ($this->observers as $observer) {
$observer->onSessionCompleted($report);
}
}
}Каждый получатель — отдельный класс, реализующий тот же интерфейс:
final class SmsNotifier implements DiagnosticObserver
{
public function onSessionCompleted(DiagnosticReport $report): void
{
// отправить SMS клиенту
}
}
final class AdvisorDashboard implements DiagnosticObserver
{
public function onSessionCompleted(DiagnosticReport $report): void
{
// обновить панель мастера-приёмщика
}
}
final class CrmAnalytics implements DiagnosticObserver
{
public function onSessionCompleted(DiagnosticReport $report): void
{
// отправить событие в аналитику
}
}Подписка происходит на стороне клиента, при сборке:
$session = new DiagnosticSession();
$session->subscribe(new SmsNotifier());
$session->subscribe(new AdvisorDashboard());
$session->subscribe(new CrmAnalytics());
$session->complete($report);Требование про email с рекомендациями теперь решается новым классом и одной строкой подписки — DiagnosticSession не меняется ни строчкой:
final class RepairRecommendationEmail implements DiagnosticObserver
{
public function onSessionCompleted(DiagnosticReport $report): void
{
// отправить письмо с рекомендациями по ремонту
}
}
$session->subscribe(new RepairRecommendationEmail());Из чего состоит паттерн
- Субъект (
DiagnosticSession) — хранит список наблюдателей и оповещает их о событии, не зная их конкретных классов. - Наблюдатель (
DiagnosticObserver) — общий интерфейс реакции на событие субъекта. - Конкретный наблюдатель (
SmsNotifier,AdvisorDashboard,CrmAnalytics,RepairRecommendationEmail) — своя, независимая от остальных, реакция на событие. - Клиент — подписывает конкретных наблюдателей на субъект при сборке.
Наблюдатель vs Посредник
Оба паттерна развязывают объекты, которым иначе пришлось бы хранить прямые ссылки друг на друга, но развязывают в разные стороны. Наблюдатель развязывает только отправителя события: DiagnosticSession не знает ничего о своих подписчиках, кроме общего интерфейса DiagnosticObserver, а вот сами подписчики знают о субъекте — реализуют его контракт события и решают реакцию каждый самостоятельно. Посредник (см. отдельную статью) устроен наоборот: он сам знает всех участников и их конкретные методы и явно решает, что должно произойти в ответ на событие, — участники развязаны друг с другом, но не с посредником.
| Наблюдатель | Посредник | |
|---|---|---|
| Направление связи | Субъект не знает наблюдателей, наблюдатели знают его интерфейс события | Посредник знает всех участников и их конкретные методы |
| Кто решает реакцию | Каждый наблюдатель сам решает, как реагировать | Посредник решает, что произойдёт, в одном месте |
| Типичная форма | Один субъект — много независимых подписчиков на одно событие | Несколько разнородных участников, взаимно влияющих друг на друга по правилам |
Когда применять
- Список получателей события заранее не известен или должен расширяться без изменения источника события — как в примере с новыми каналами уведомлений.
- Получатели независимы друг от друга и не должны знать друг о друге, только об источнике события.
- Нужно оповещать сразу несколько разнородных систем (SMS, дашборд, аналитика) об одном и том же событии, не разрастая класс-источник новыми зависимостями под каждую из них.
Плюсы и минусы
Наблюдатель слабо связывает субъект и его подписчиков — новый наблюдатель добавляется без единой правки субъекта, а число подписчиков не ограничено и определяется во время выполнения, а не на этапе написания кода.
Минус — субъект не знает и не контролирует, что именно делают его наблюдатели и сколько времени это займёт: синхронный перебор подписчиков в complete() может неожиданно замедлить основную операцию, если один из наблюдателей выполняет что-то тяжёлое. Порядок оповещения обычно совпадает с порядком подписки, и неявная зависимость от этого порядка — источник трудноуловимых багов, если один наблюдатель на самом деле зависит от того, что уже сделал другой.
Итог
Наблюдатель нужен, когда на одно событие должно реагировать произвольное и заранее не известное число независимых получателей, а объект-источник события не должен знать о них ничего, кроме общего интерфейса подписки. Плата за эту развязку — источник события теряет контроль над тем, что и как долго делают подписавшиеся на него наблюдатели.