Источник: Nuances of Programming
В книге “Приемы объектно-ориентированного проектирования: паттерны проектирования” Эриха Гамма описываются 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 .
Количество ConcreteSubject зависит от наших потребностей. Конкретно для базовой схемы “Наблюдателя” нужен всего один. В этом примере наблюдаемым состоянием является атрибут state , принадлежащий к типу number . С другой стороны, все observers хранятся в массиве observer . Методы attach и detach проверяют, был ли ранее observer в структуре данных, чтобы добавить его или удалить. И наконец, метод notify отвечает за вызов метода update всех observers , наблюдающих за Subject .
Объекты класса ConcreteSubject выполняют задание в соответствии с конкретной бизнес-логикой каждой задачи. В следующем примере присутствует метод operation , отвечающий за изменения состояния и вызов метода notify .
Другим компонентом паттерна является observer . Следовательно, начнем с определения интерфейса Observer , требующего лишь определения метода update , который должен выполняться каждый раз при оповещении observer о произошедшем изменении.
Каждый класс, реализующий этот интерфейс, должен включать его бизнес-логику в метод update . В данном примере были определены 2 ConcreteObserver . Они буду выполнять действия в соответствии с состоянием (state ) Subject . Следующий код показывает 2 конкретные реализации для 2 разных типов наблюдателей: ConcreteObserverA и ConcreteObserverB .
ConcreteObserverA:
ConcreteObserverB
На завершающем этапе мы определяем класс Client или Context , применяющий данный паттерн. В следующем коде реализованы необходимые классы для имитации использования Subject и Observer :
Пример 2. Аукционные торги с помощью “Наблюдателя”
В этом примере с помощью “Наблюдателя” мы сымитируем аукционный дом, в котором группа аукционеров (Auctioneer ) предлагает цену за различные товары (product ). Руководит аукционом уполномоченное лицо (Agent ). Все аукционеры должны оповещаться о каждом факте повышения цены на товар, чтобы принять решение о продолжении или прекращении торгов.
Как и в предыдущем примере, начнем с изучения диаграммы UML для знакомства с образующими паттерн компонентами.
product , продающийся с аукциона, является состоянием Subject , и все observers ожидают уведомления о происходящих в нем изменениях. Таким образом, класс product состоит из 3 атрибутов: price , name и auctionner (имеется в виду аукционер, за которым закреплен товар).
Agent — это интерфейс, который определяет методы для управления группой Auctioneers и оповещения их об изменении цены на аукционный товар. В этом примере методы attach и detach переименованы в subscribe и unsubscribe .
Конкретная реализация интерфейса Agent осуществляется классом ConcreteAgent . Подобно трем ранее описанным методам, обладающими схожим поведением с методом, который был представлен в предыдущем примере, реализуется bidUp . После нескольких проверок цены, предложенной аукционерами, он принимает ее и оповещает всех об изменении.
Здесь присутствуют 4 различных типа Auctioneer , определенных в классах AuctioneerA , AuctioneerB , AuctioneerC и AuctioneerD . Все они реализуют интерфейс Auctioneer , который определяет name , MAX_LIMIT и метод update . Атрибут MAX_LIMIT устанавливает максимально возможную сумму, которую может предложить каждый тип Auctioneer .
Определение разных типов Auctioneer потребовалось, чтобы показать отличия в поведении каждого из них при получении оповещения Agent в методе update . При этом все изменения в примере коснулись лишь вероятности продолжения торгов и суммы, на которую повысились предлагаемые цены.
ConcreteAuctioneerA:
ConcreteAuctioneerB:
ConcreteAuctioneerC:.
ConcreteAuctioneerD:
Теперь рассмотрим класс Client , задействующий паттерн “Наблюдатель”. В следующем примере аукционный дом объявлен с Agent и четырьмя Auctioneers . На торгах представлены 2 разных товара: diamond и gem . В первом аукционе участвуют все аукционеры. Во втором торги заканчивает участник класса D , а трое остальных продолжают состязаться.
Я создал два npm scripts , благодаря которым вы сможете выполнить код, представленный в статье:
npm run example1
npm run example2
Данный GitHub-репозиторий содержит полный вариант кода.
Заключение
“Наблюдатель” — это паттерн проектирования, позволяющий соблюдать принцип открытости/закрытости, поскольку он предполагает создание новых Subject и Observer без нарушения уже имеющегося кода. Помимо этого, согласно принципам его работы участникам системы не обязательно знать друг о друге, чтобы наладить между собой взаимодействие. Данный паттерн решает проблему снижения производительности, свойственную многим более простым техникам, таким как активное ожидание и периодический опрос.
Самое главное достоинство “Наблюдателя” не в его конкретной реализации, а в способности распознать потенциально решаемую проблему и подобрать нужный момент для применения. Конкретная реализация не так важна, поскольку она будет меняться в зависимости от языка программирования.
Читайте также:
Перевод статьи Carlos Caballero : Understanding the Observer Design Pattern