Паттерн проектирования «Наблюдатель»: объект под прицелом

В книге “Приемы объектно-ориентированного проектирования: паттерны проектирования” Эриха Гамма описываются 23 классических паттерна, которые предлагают решения часто встречающихся задач в разработке ПО.  

В данной статье речь пойдет о паттерне “Наблюдатель”, принципах его работы и случаях применения. 

“Наблюдатель”: основная идея 

Согласно определению в Википедии: 

Это паттерн проектирования, в котором объект, именуемый “субъектом” (subject), обслуживает список своих “подчиненных”, так называемых “наблюдателей” (observer), автоматически сообщая им о любых изменениях состояния, как правило, через вызов одного из их методов.

С другой стороны, в первоисточнике предлагается следующее толкование: 

Определяет зависимость “один-ко-многим” между объектами так, что при изменении состояния одного из них все зависящие от него объекты уведомляются и обновляются автоматически.

Часто требуется взаимодействовать с системными компонентами, не привязывая их к коду или принципу коммуникации. Для того, чтобы наладить общение между группой объектов (наблюдателей), обязанных быть в курсе состояния другого объекта (наблюдаемого), существуют различные техники. Ниже перечислим самые известные из них: 

  1. Активное ожидание. Суть этого процесса состоит в систематической проверке состояния. В нашем случае наблюдатель постоянно бы проверял, изменилось ли состояние наблюдаемого объекта. В некоторых ситуациях данная стратегия себя полностью оправдывает, но непосредственно к нашей она не подходит. Дело в том, что такой подход предполагает наличие нескольких процессов (наблюдателей) потребляющих ресурсы, но при этом ничего не выполняющих, тем самым вызывая экспоненциальное снижение производительности существующих наблюдателей. 
  2. Периодический опрос. Данная техника подразумевает выполнение запроса через небольшие временные промежутки между операциями. Ее можно рассматривать как попытку синхронизировать процессы. Однако и здесь мы снова можем наблюдать снижение быстродействия системы. Кроме того, находясь в прямой зависимости от установленных интервалов между запросами, данные могут задержаться настолько, что станут недостоверными, приводя к трате ресурсов. 

Следующие фрагменты кода демонстрируют реализации данных техник: 

  • Активное ожидание:
while(!condition){
   // Запрос 
   if(isQueryValid) condition = true;
}
  • Периодический опрос:
function refresh() {
    setTimeout(refresh, 5000);
    // Запрос 
}
// Начальный вызов или просто вызов refresh напрямую
setTimeout(refresh, 5000);

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

  • Активное ожидание: 
while(resourceIsNotReady()){
  // Ничего не выполняет 
}
  • Периодический опрос:
while(resourceIsNotReady()){
     Sleep(1000); // 1000 или любое другое время 
 }

Паттерн “Наблюдатель” позволяет добиться большей эффективности и меньшего зацепления, поскольку он обходит упомянутую ранее проблему. Еще одно его преимущество связано с обслуживанием кода. Ниже представлена UML-диаграмма этого паттерна:  

UML-диаграмма из книги “ Приемы объектно-ориентированного проектирования: паттерны проектирования”

Данный паттерн включает в себя следующие классы: 

  • Subject —  это интерфейс, реализуемый каждым наблюдаемым классом. Он содержит методы attach и detach, позволяющие добавлять и удалять наблюдателей из класса. В него также входит метод notify, ответственный за оповещение всех наблюдателей об изменении, произошедшем в наблюдаемом классе. Помимо этого, все subject хранят ссылки объектов, которые за ними наблюдают (observers).
  • Observer —  это интерфейс, реализуемый всеми ConcreteObserver. Помимо определенного в нем метода update, он содержит бизнес-логику, которую должен выполнять каждый наблюдатель при получении от Subject оповещения об изменении.
  • ConcreteSubject —  конкретная реализация класса Subject, определяющего состояние приложения SubjectState, которому необходимо сообщить о произошедшем изменении. С этой целью обычно реализуются методы доступа (getState и setState), поскольку они управляют состоянием. Этот класс также несет ответственность за отправку всем своим наблюдателям оповещений об изменениях состояния.
  • ConcreteObserver —  это класс, моделирующий каждого конкретного наблюдателя. В нем реализуется метод update, принадлежащий интерфейсу Observer. Этот метод отвечает за поддержание в классе состояния, согласующегося с наблюдаемыми им объектами subject

В настоящее время существует набор библиотек под названием Reactive Extensions или ReactiveX, благодаря которым “Наблюдатель” стал широко известен. Помимо него Reactive Extensions задействуют еще один паттерн  —  “Итератор”. 

Они также включают группу операторов, использующих функциональное программирование. В число наиболее известных Reactive Extensions входят:

В этих реализациях есть отличия в именах классов и методов. Перечислим самые распространенные из них: 

  1. Subscriber соответствует классу Observer

2. ConcreteSubscriber —  не что иное, как ConcreteObserver

3. Класс Subject остается таким, как есть, но имена методов attach и detach меняются на subscribe и unsubscribe.

4. Классы ConcreteSubject являются конкретными реализациями, такими как BehaviorSubject, ReplaySubject или AsyncSubject.

Паттерн “Наблюдатель”: стратегии взаимодействия 

В основе взаимодействия Subject (наблюдаемых) и Observer (наблюдателей) лежат 2 модели: 

  • Pull-модель. В соответствии с ней subject отправляет минимум данных observer, вследствие чего тому приходится выполнять запросы для получения более подробной информации. Отношения в этой модели выстраиваются на том, что Subject игнорирует observer
  • Push-модель. subject отправляет observer огромное количество информации в связи с изменением вне зависимости от ее фактической востребованности. В рамках данной модели Subject досконально знает все потребности каждого своего observer

Изначально может показаться, что техника push-коммуникации менее подходит для переиспользования с учетом того, что Subject должен обладать знаниями об observer, однако это не всегда так. С другой стороны, стратегия pull-коммуникации также может оказаться неэффективной, поскольку observer должен понять, что же изменилось без помощи со стороны Subject

Паттерн “Наблюдатель”: случаи применения 

  1. Если между системными объектами существует зависимость “один-ко-многим”, чтобы в случае изменения состояния все зависимые объекты уведомлялись автоматически. 
  2. Вы не рассматриваете активное ожидание и периодический опрос в качестве техник для обновления наблюдателей. 
  3. Разделение зависимостей между объектами Subject (наблюдаемыми) и Observer (наблюдателями), что обеспечивает соблюдение принципа открытости/закрытости

Паттерн “Наблюдатель”: преимущества и недостатки 

Среди преимуществ “Наблюдателя” можно выделить следующие: 

  • Более удобный в обслуживании код за счет меньшей степени зацепления между наблюдаемыми классами и их зависимостями (наблюдателями). 
  • Чистый код. Гарантируется соблюдение принципа открытости/закрытости, поскольку можно добавлять новых наблюдателей (подписчиков) без нарушения существующего кода наблюдаемых объектов и наоборот. 
  • Более понятный код. Соблюдается принцип единственной ответственности (SRP): вместо того, чтобы размещать бизнес-логику в объекте Observable, ответственность каждого наблюдателя передается его методу update.

Примечание. Взаимодействие между объектами можно устанавливать не во время компиляции, а во время выполнения.

Основной недостаток “Наблюдателя”, как и большинства других паттернов, связан с усложнением кода и увеличением числа классов, которые в нем нуждаются. Но при работе с шаблонами с этим обстоятельством приходится мириться, поскольку оно является средством достижения абстракции в коде. 

Примеры паттерна “Наблюдатель” 

Далее будут проиллюстрированы 2 примера “Наблюдателя”: 

  1. Базовая структура. Здесь мы преобразуем теоретическую диаграмму UML в код TypeScript для идентификации каждого класса, представленного в паттерне. 
  2. Аукционная система. В ней есть объект subject, который сообщает об изменении (push-модель) в цене price представленного на торгах товара product всем наблюдателям observer, заинтересованным в его приобретении. Как только стоимость товара возрастает в связи с повышением ценового предложения observador, то все наблюдатели сразу получают об этом оповещение. 

В нижеследующих примерах, демонстрирующих реализацию этого паттерна, используется TypeScript, а не JavaScript, и этому есть свое объяснение. Дело в том, что в JS отсутствуют интерфейсы или абстрактные классы, поэтому ответственность за реализацию и тех, и других возлагается на разработчика. 

Пример 1. Базовая структура паттерна “Наблюдатель” 

В первом примере мы преобразуем теоретическую диаграмму UML в TypeScript для проверки возможностей данного паттерна. А вот и сама диаграмма для реализации: 

Для начала определяем интерфейс Subject. Поскольку мы имеем дело с интерфейсом, то определяются все методы, подлежащие реализации во всех конкретных Subject. В нашем случае есть только ConcreteSubject. Subject определяет 3 метода в соответствии с требованиями паттерна: attach, detach и notify. attach и detach принимают observer в качестве параметра, который будет добавляться или удаляться в структуре данных Subject

import { Observer } from "./observer.interface";

export interface Subject {
  attach(observer: Observer): void;
  detach(observer: Observer): void;
  notify(): void;
}

Количество ConcreteSubject зависит от наших потребностей. Конкретно для базовой схемы “Наблюдателя” нужен всего один. В этом примере наблюдаемым состоянием является атрибут state, принадлежащий к типу number. С другой стороны, все observers хранятся в массиве observer. Методы attach и detach проверяют, был ли ранее observer в структуре данных, чтобы добавить его или удалить. И наконец, метод notify отвечает за вызов метода update всех observers, наблюдающих за Subject

Объекты класса ConcreteSubject выполняют задание в соответствии с конкретной бизнес-логикой каждой задачи. В следующем примере присутствует метод operation, отвечающий за изменения состояния и вызов метода notify.

import { Observer } from "./observer.interface";
import { Subject } from "./subject.interface";

export class ConcreteSubject implements Subject {
  public state: number;
  private observers: Observer[] = [];

  public attach(observer: Observer): void {
    const isAttached = this.observers.includes(observer);
    if (isAttached) {
      return console.log("Subject: Observer has been attached already");
    }

    console.log("Subject: Attached an observer.");
    this.observers.push(observer);
  }

  public detach(observer: Observer): void {
    const observerIndex = this.observers.indexOf(observer);
    if (observerIndex === -1) {
      return console.log("Subject: Nonexistent observer");
    }

    this.observers.splice(observerIndex, 1);
    console.log("Subject: Detached an observer");
  }

  public notify(): void {
    console.log("Subject: Notifying observers...");
    for (const observer of this.observers) {
      observer.update(this);
    }
  }

  public operation(): void {
    console.log("Subject: Business Logic.");
    this.state = Math.floor(Math.random() * (10 + 1));

    console.log(`Subject: The state has just changed to: ${this.state}`);
    this.notify();
  }
}

Другим компонентом паттерна является observer. Следовательно, начнем с определения интерфейса Observer, требующего лишь определения метода update, который должен выполняться каждый раз при оповещении observer о произошедшем изменении. 

import { Subject } from "./subject.interface";

export interface Observer {
update(subject: Subject): void;
}

Каждый класс, реализующий этот интерфейс, должен включать его бизнес-логику в метод update. В данном примере были определены 2 ConcreteObserver. Они буду выполнять действия в соответствии с состоянием (state) Subject. Следующий код показывает 2 конкретные реализации для 2 разных типов наблюдателей: ConcreteObserverA и ConcreteObserverB.

ConcreteObserverA:

import { ConcreteSubject } from "./concrete-subject";
import { Observer } from "./observer.interface";
import { Subject } from "./subject.interface";

export class ConcreteObserverA implements Observer {
  public update(subject: Subject): void {
    if (subject instanceof ConcreteSubject && subject.state < 3) {
      console.log("ConcreteObserverA: Reacted to the event.");
    }
  }
}

ConcreteObserverB:

import { ConcreteSubject } from "./concrete-subject";
import { Observer } from "./observer.interface";
import { Subject } from "./subject.interface";

export class ConcreteObserverB implements Observer {
  public update(subject: Subject): void {
    if (
      subject instanceof ConcreteSubject &&
      (subject.state === 0 || subject.state >= 2)
    ) {
      console.log("ConcreteObserverB: Reacted to the event.");
    }
  }
}

На завершающем этапе мы определяем класс Client или Context, применяющий данный паттерн. В следующем коде реализованы необходимые классы для имитации использования Subject и Observer:

import { ConcreteObserverA } from "./concrete-observerA";
import { ConcreteObserverB } from "./concrete-observerB";
import { ConcreteSubject } from "./concrete-subject";

const subject = new ConcreteSubject();

const observer1 = new ConcreteObserverA();
subject.attach(observer1);

const observer2 = new ConcreteObserverB();
subject.attach(observer2);

subject.operation();
subject.operation();

subject.detach(observer2);

subject.operation();

Пример 2. Аукционные торги с помощью “Наблюдателя” 

В этом примере с помощью “Наблюдателя” мы сымитируем аукционный дом, в котором группа аукционеров (Auctioneer) предлагает цену за различные товары (product). Руководит аукционом уполномоченное лицо (Agent). Все аукционеры должны оповещаться о каждом факте повышения цены на товар, чтобы принять решение о продолжении или прекращении торгов. 

Как и в предыдущем примере, начнем с изучения диаграммы UML для знакомства с образующими паттерн компонентами. 

product, продающийся с аукциона, является состоянием Subject, и все observers ожидают уведомления о происходящих в нем изменениях. Таким образом, класс product состоит из 3 атрибутов: price, name и auctionner (имеется в виду аукционер, за которым закреплен товар). 

import { Auctioneer } from "./auctioneer.interface";

export class Product {
  public price;
  public name;
  public auctionner: Auctioneer = null;

  constructor(product) {
    this.price = product.price || 10;
    this.name = product.name || "Unknown";
  }
}

Agent —  это интерфейс, который определяет методы для управления группой Auctioneers и оповещения их об изменении цены на аукционный товар. В этом примере методы attach и detach переименованы в subscribe и unsubscribe.

import { Auctioneer } from "./auctioneer.interface";
import { Product } from "./product.model";

export interface Agent {
  subscribe(auctioneer: Auctioneer): void;
  unsubscribe(auctioneer: Auctioneer): void;
  notify(): void;
}

Конкретная реализация интерфейса Agent осуществляется классом ConcreteAgent. Подобно трем ранее описанным методам, обладающими схожим поведением с методом, который был представлен в предыдущем примере, реализуется bidUp. После нескольких проверок цены, предложенной аукционерами, он принимает ее и оповещает всех об изменении. 

import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { Product } from "./product.model";

export class ConcreteAgent implements Agent {
  public product: Product;
  private auctioneers: Auctioneer[] = [];

  public subscribe(auctioneer: Auctioneer): void {
    const isExist = this.auctioneers.includes(auctioneer);
    if (isExist) {
      return console.log("Agent: Auctioneer has been attached already.");
    }

    console.log("Agent: Attached an auctioneer.");
    this.auctioneers.push(auctioneer);
  }

  public unsubscribe(auctioneer: Auctioneer): void {
    const auctioneerIndex = this.auctioneers.indexOf(auctioneer);
    if (auctioneerIndex === -1) {
      return console.log("Agent: Nonexistent auctioneer.");
    }

    this.auctioneers.splice(auctioneerIndex, 1);
    console.log("Agent: Detached an auctioneer.");
  }

  public notify(): void {
    console.log("Agent: Notifying auctioneer...");
    for (const auctioneer of this.auctioneers) {
      auctioneer.update(this);
    }
  }

  public bidUp(auctioneer, bid): void {
    console.log("Agent: I'm doing something important.");
    const isExist = this.auctioneers.includes(auctioneer);
    if (!isExist) {
      return console.log("Agent: Auctioneer there is not in the system.");
    }
    if (this.product.price >= bid) {
      console.log("bid", bid);
      console.log("price", this.product.price);
      return console.log(`Agent: ${auctioneer.name}, your bid is not valid`);
    }
    this.product.price = bid;
    this.product.auctionner = auctioneer;

    console.log(
      `Agent: The new price is ${bid} and the new owner is ${auctioneer.name}`
    );
    this.notify();
  }
}

Здесь присутствуют 4 различных типа Auctioneer, определенных в классах AuctioneerA, AuctioneerB, AuctioneerC и AuctioneerD. Все они реализуют интерфейс Auctioneer, который определяет name, MAX_LIMIT и метод update. Атрибут MAX_LIMIT устанавливает максимально возможную сумму, которую может предложить каждый тип Auctioneer

import { Agent } from "./agent.interface";

export interface Auctioneer {
  name: string;
  MAX_LIMIT: number;
  update(agent: Agent): void;
}

Определение разных типов Auctioneer потребовалось, чтобы показать отличия в поведении каждого из них при получении оповещения Agent в методе update. При этом все изменения в примере коснулись лишь вероятности продолжения торгов и суммы, на которую повысились предлагаемые цены. 

ConcreteAuctioneerA:

import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerA implements Auctioneer {
  name = "ConcreteAuctioneerA";
  MAX_LIMIT = 100;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const bid = Math.round(agent.product.price * 1.1);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}

ConcreteAuctioneerB:

import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerB implements Auctioneer {
  name = "ConcreteAuctioneerB";
  MAX_LIMIT = 200;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const isBid = Math.random() < 0.5;
    if (!isBid) {
      return console.log(`${this.name}: I give up!`);
    }
    const bid = Math.round(agent.product.price * 1.05);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}

ConcreteAuctioneerC:.

import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerC implements Auctioneer {
  name = "ConcreteAuctioneerC";
  MAX_LIMIT = 500;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const isBid = Math.random() < 0.2;
    if (!isBid) {
      return console.log(`${this.name}: I give up!`);
    }
    const bid = Math.round(agent.product.price * 1.3);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}

ConcreteAuctioneerD:

import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerD implements Auctioneer {
  name = "ConcreteAuctioneerD";
  MAX_LIMIT = 1000;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const isBid = Math.random() < 0.8;
    if (!isBid) {
      return console.log(`${this.name}: I give up!`);
    }
    const bid = Math.round(agent.product.price * 1.2);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}

Теперь рассмотрим класс Client, задействующий паттерн “Наблюдатель”. В следующем примере аукционный дом объявлен с Agent и четырьмя Auctioneers. На торгах представлены 2 разных товара: diamond и gem. В первом аукционе участвуют все аукционеры. Во втором торги заканчивает участник класса D, а трое остальных продолжают состязаться. 

import { ConcreteAgent } from "./concrete-agent";
import { ConcreteAuctioneerA } from "./concrete-auctioneerA";
import { ConcreteAuctioneerB } from "./concrete-auctioneerB";
import { ConcreteAuctioneerC } from "./concrete-auctioneerC";
import { ConcreteAuctioneerD } from "./concrete-auctioneerD";
import { Product } from "./product.model";

const concreteAgent = new ConcreteAgent();

const auctioneerA = new ConcreteAuctioneerA();
const auctioneerB = new ConcreteAuctioneerB();
const auctioneerC = new ConcreteAuctioneerC();
const auctioneerD = new ConcreteAuctioneerD();

concreteAgent.subscribe(auctioneerA);
concreteAgent.subscribe(auctioneerB);
concreteAgent.subscribe(auctioneerC);
concreteAgent.subscribe(auctioneerD);

const diamond = new Product({ name: "Diamond", price: 5 });
concreteAgent.product = diamond;

concreteAgent.bidUp(auctioneerA, 10);

console.log("--------- new Bid-----------");

concreteAgent.unsubscribe(auctioneerD);

const gem = new Product({ name: "Gem", price: 3 });
concreteAgent.product = gem;

concreteAgent.bidUp(auctioneerB, 5);

console.log(`The winner of the bid is 
             Product: ${diamond.name}
             Name: ${diamond.auctionner.name}
             Price: ${diamond.price}`);

console.log(`The winner of the bid is 
             Product: ${gem.name}
             Name: ${gem.auctionner.name}
             Price: ${gem.price}`);

Я создал два npm scripts, благодаря которым вы сможете выполнить код, представленный в статье: 

npm run example1
npm run example2

Данный GitHub-репозиторий содержит полный вариант кода. 

Заключение 

“Наблюдатель” —  это паттерн проектирования, позволяющий соблюдать принцип открытости/закрытости, поскольку он предполагает создание новых Subject и Observer без нарушения уже имеющегося кода. Помимо этого, согласно принципам его работы участникам системы не обязательно знать друг о друге, чтобы наладить между собой взаимодействие. Данный паттерн решает проблему снижения производительности, свойственную многим более простым техникам, таким как активное ожидание и периодический опрос. 

Самое главное достоинство “Наблюдателя” не в его конкретной реализации, а в способности распознать потенциально решаемую проблему и подобрать нужный момент для применения. Конкретная реализация не так важна, поскольку она будет меняться в зависимости от языка программирования. 

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Carlos Caballero: Understanding the Observer Design Pattern

Предыдущая статьяКак в два счета сделать сайт редактируемым извне с помощью данных Google Sheets
Следующая статьяОптимизация структур в Golang для эффективного распределения памяти