В книге “Приемы объектно-ориентированного проектирования: паттерны проектирования” Эриха Гамма описываются 23 классических паттерна, которые предлагают решения часто встречающихся задач в разработке ПО.
В данной статье речь пойдет о паттерне “Наблюдатель”, принципах его работы и случаях применения.
“Наблюдатель”: основная идея
Согласно определению в Википедии:
Это паттерн проектирования, в котором объект, именуемый “субъектом” (subject), обслуживает список своих “подчиненных”, так называемых “наблюдателей” (observer), автоматически сообщая им о любых изменениях состояния, как правило, через вызов одного из их методов.
С другой стороны, в первоисточнике предлагается следующее толкование:
Определяет зависимость “один-ко-многим” между объектами так, что при изменении состояния одного из них все зависящие от него объекты уведомляются и обновляются автоматически.
Часто требуется взаимодействовать с системными компонентами, не привязывая их к коду или принципу коммуникации. Для того, чтобы наладить общение между группой объектов (наблюдателей), обязанных быть в курсе состояния другого объекта (наблюдаемого), существуют различные техники. Ниже перечислим самые известные из них:
- Активное ожидание. Суть этого процесса состоит в систематической проверке состояния. В нашем случае наблюдатель постоянно бы проверял, изменилось ли состояние наблюдаемого объекта. В некоторых ситуациях данная стратегия себя полностью оправдывает, но непосредственно к нашей она не подходит. Дело в том, что такой подход предполагает наличие нескольких процессов (наблюдателей) потребляющих ресурсы, но при этом ничего не выполняющих, тем самым вызывая экспоненциальное снижение производительности существующих наблюдателей.
- Периодический опрос. Данная техника подразумевает выполнение запроса через небольшие временные промежутки между операциями. Ее можно рассматривать как попытку синхронизировать процессы. Однако и здесь мы снова можем наблюдать снижение быстродействия системы. Кроме того, находясь в прямой зависимости от установленных интервалов между запросами, данные могут задержаться настолько, что станут недостоверными, приводя к трате ресурсов.
Следующие фрагменты кода демонстрируют реализации данных техник:
- Активное ожидание:
while(!condition){
// Запрос
if(isQueryValid) condition = true;
}
- Периодический опрос:
function refresh() {
setTimeout(refresh, 5000);
// Запрос
}
// Начальный вызов или просто вызов refresh напрямую
setTimeout(refresh, 5000);
Поскольку эти техники являются альтернативами рассматриваемого здесь паттерна проектирования, то будет целесообразно вкратце в них разобраться, пусть это и не предусмотрено данной статьей. Отличие между активным ожиданием и периодическим опросом состоит в том, что в первом случае запрос осуществляется постоянно, а во втором — с паузами.
- Активное ожидание:
while(resourceIsNotReady()){
// Ничего не выполняет
}
- Периодический опрос:
while(resourceIsNotReady()){
Sleep(1000); // 1000 или любое другое время
}
Паттерн “Наблюдатель” позволяет добиться большей эффективности и меньшего зацепления, поскольку он обходит упомянутую ранее проблему. Еще одно его преимущество связано с обслуживанием кода. Ниже представлена 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 входят:
В этих реализациях есть отличия в именах классов и методов. Перечислим самые распространенные из них:
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
.
Паттерн “Наблюдатель”: случаи применения
- Если между системными объектами существует зависимость “один-ко-многим”, чтобы в случае изменения состояния все зависимые объекты уведомлялись автоматически.
- Вы не рассматриваете активное ожидание и периодический опрос в качестве техник для обновления наблюдателей.
- Разделение зависимостей между объектами
Subject
(наблюдаемыми) иObserver
(наблюдателями), что обеспечивает соблюдение принципа открытости/закрытости.
Паттерн “Наблюдатель”: преимущества и недостатки
Среди преимуществ “Наблюдателя” можно выделить следующие:
- Более удобный в обслуживании код за счет меньшей степени зацепления между наблюдаемыми классами и их зависимостями (наблюдателями).
- Чистый код. Гарантируется соблюдение принципа открытости/закрытости, поскольку можно добавлять новых наблюдателей (подписчиков) без нарушения существующего кода наблюдаемых объектов и наоборот.
- Более понятный код. Соблюдается принцип единственной ответственности (SRP): вместо того, чтобы размещать бизнес-логику в объекте
Observable
, ответственность каждого наблюдателя передается его методуupdate
.
Примечание. Взаимодействие между объектами можно устанавливать не во время компиляции, а во время выполнения.
Основной недостаток “Наблюдателя”, как и большинства других паттернов, связан с усложнением кода и увеличением числа классов, которые в нем нуждаются. Но при работе с шаблонами с этим обстоятельством приходится мириться, поскольку оно является средством достижения абстракции в коде.
Примеры паттерна “Наблюдатель”
Далее будут проиллюстрированы 2 примера “Наблюдателя”:
- Базовая структура. Здесь мы преобразуем теоретическую диаграмму UML в код TypeScript для идентификации каждого класса, представленного в паттерне.
- Аукционная система. В ней есть объект
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
без нарушения уже имеющегося кода. Помимо этого, согласно принципам его работы участникам системы не обязательно знать друг о друге, чтобы наладить между собой взаимодействие. Данный паттерн решает проблему снижения производительности, свойственную многим более простым техникам, таким как активное ожидание и периодический опрос.
Самое главное достоинство “Наблюдателя” не в его конкретной реализации, а в способности распознать потенциально решаемую проблему и подобрать нужный момент для применения. Конкретная реализация не так важна, поскольку она будет меняться в зависимости от языка программирования.
Читайте также:
- 5 основных рекурсивных задач на собеседованиях по программированию
- Лучшие JavaScript-фреймворки и тенденции веб-разработки в 2021 году
- Аспектно-ориентированное программирование в JavaScript
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Carlos Caballero: Understanding the Observer Design Pattern