Перейти к содержимому
Фёдор Башинский

Наблюдатель

5 min read
Обложка статьи «Наблюдатель»

Наблюдатель (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() может неожиданно замедлить основную операцию, если один из наблюдателей выполняет что-то тяжёлое. Порядок оповещения обычно совпадает с порядком подписки, и неявная зависимость от этого порядка — источник трудноуловимых багов, если один наблюдатель на самом деле зависит от того, что уже сделал другой.

Итог

Наблюдатель нужен, когда на одно событие должно реагировать произвольное и заранее не известное число независимых получателей, а объект-источник события не должен знать о них ничего, кроме общего интерфейса подписки. Плата за эту развязку — источник события теряет контроль над тем, что и как долго делают подписавшиеся на него наблюдатели.