Шаблон «Наблюдатель»: расскажите, как там на Марсе
2026-02-21 03:33 Diff

#База знаний

  • 22 ноя 2021
  • 0

Шаблон «Наблюдатель»: расскажите, как там на Марсе

Исследуем погоду на Марсе с помощью Java и паттерна проектирования «Наблюдатель».

Фулстек-разработчик. Любимый стек: Java + Angular, но в хорошей компании готова писать хоть на языке Ада.

18 февраля 2021 года в рамках миссии NASA «Марс-2020» на поверхность Красной планеты успешно приземлился ровер Perseverance — «Настойчивость». С тех пор он передаёт на Землю кучу данных о нашем соседе.

Представьте, что вас взяли на работу в NASA, а в качестве первого простого задания попросили разобраться с полученными от Perseverance данными.

Техническое задание

Разработать программу, которая при получении новых данных от ровера будет по-разному распоряжаться ими:

  • температуру на Марсе выведет на большой экран в холле;
  • давление на Марсе покажет на экране в лаборатории;
  • свежие фотографии поверхности опубликует на сайте NASA.

Список вариантов обработки данных не окончательный. Нужно иметь возможность быстро подключать новые обработчики и отключать старые.

А вот и сопроводительные документы. Это класс, в котором хранятся актуальные данные от ровера:

public class PerseveranceData { private final double temperature; // температура private final double pressure; // давление private final String photo; // фотография (для простоты пусть это будет строка) public PerseveranceData(double temperature, double pressure, String photo) { this.temperature = temperature; this.pressure = pressure; this.photo = photo; } public double getTemperature() { return temperature; } public double getPressure() { return pressure; } public String getPhoto() { return photo; } }

И класс-заготовка для обработчика этих данных:

public class Perseverance { private PerseveranceData data; // последние полученные данные public PerseveranceData getData() { return date; } // этот метод вызывается каждый раз при получении новых данных public void onNewData(PerseveranceData newData){ // сюда можно дописать свою обработку } }

Первое решение пришло в голову почти сразу.

Тут и думать нечего — раз есть метод, который вызывается при получении новых данных, в нём же эти данные и разошлём по нужным представлениям. Только сначала опишем классы для этих представлений.

Для вывода температуры:

public class TemperatureDisplay { public void update(PerseveranceData data) { System.out.printf("Температура на Марсе - %2.0f градусов по Цельсию %n", data.getTemperature()); } }

Для вывода давления:

public class PressureDisplay { public void update(PerseveranceData data) { System.out.printf("Давление на Марсе - %3.1f кПа %n", data.getPressure()); } }

И для публикации фотографий:

public class PhotoPublisher { public void update(PerseveranceData data) { System.out.printf("Опубликовано новое фото Марса - %1$s %n", data.getPhoto()); } }

А вот и самая очевидная реализация рассылки новых данных:

public class Perseverance { private PerseveranceData data; public PerseveranceData getData() { return data; } TemperatureDisplay temperatureDisplay = new TemperatureDisplay(); PressureDisplay pressureDisplay = new PressureDisplay(); PhotoPublisher photoPublisher = new PhotoPublisher(); // этот метод вызывается каждый раз при получении новых данных от ровера public void onNewData(PerseveranceData newData) { data = newData; temperatureDisplay.update(data); pressureDisplay.update(data); photoPublisher.update(data); } }

Быстро, просто, всё работает, но есть минусы:

  • При добавлении нового представления придётся снова менять класс Perseverance.
  • Невозможно отключать представления и добавлять новые прямо во время выполнения программы.
  • Так как в Perseverance используются реализации — конкретные классы представлений, а не интерфейсы, то при появлении другой реализации любого представления опять же придётся менять класс ровера.

Иными словами, решению недостаёт гибкости, а класс марсохода и классы — представления данных слишком сильно связаны между собой. Но есть вариант и поинтереснее — для решения нашей задачи отлично подойдёт паттерн проектирования «Наблюдатель».

В классической книге «Паттерны объектно-ориентированного проектирования» авторства так называемой Банды четырёх этот шаблон описывается так:

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

В реальной жизни полно примеров использования этого шаблона:

  • подписка на каналы, сообщества и новости друзей в социальных сетях;
  • подписка на получение информации о выходе новых серий любимых сериалов в онлайн-кинотеатрах;
  • подписка на оповещение об изменении цены на приглянувшийся товар в интернет-магазине.
Примеры паттерна «Наблюдатель» в реальной жизни. Изображение: Майя Мальгина для Skillbox Media

Ключевое слово здесь — «подписка». Без неё весь этот поток информации превращается в обычный спам.

В паттерне «Наблюдатель» два типа участников: тот (или те), кто генерирует обновления, и те, кому эти обновления приходят. Чтобы получать обновления, нужно сначала попасть в список подписчиков. И наоборот — если отказаться от подписки, обновления приходить перестанут.

Обычно участники первого типа называются Subject (Субъект), а второго — Observer (Наблюдатель). И Subject, и Observer — интерфейсы, на базе которых можно писать свои классы-реализации. В этих же классах можно хранить текущие состояния Субъекта и Наблюдателей.

У Субъекта есть методы для подписки, отказа от подписки и оповещения всех своих подписчиков, у Наблюдателя — метод, который вызывается при получении новых данных от Субъекта.

Диаграмма классов. Инфографика: Екатерина Степанова / Skillbox Media

Сначала напишем интерфейсы. Один для Субъекта:

public interface Subject { void registerObserver(Observer observer); void unregisterObserver(Observer observer); void notifyObservers(); }

Второй — для Наблюдателей:

public interface Observer { void update(PerseveranceData data); }

Теперь перепишем реализацию Perseverance таким образом, чтобы он реализовывал интерфейс Subject:

public class Perseverance implements Subject { private PerseveranceData data; // актуальный список Наблюдателей private Set<Observer> observers = new HashSet<>(); @Override public void registerObserver(Observer observer) { observers.add(observer); } @Override public void unregisterObserver(Observer observer) { observers.remove(observer); } @Override public void notifyObservers() { for (Observer observer : observers) observer.update(data); } public PerseveranceData getData() { return data; } // этот метод вызывается каждый раз при получении новых данных от ровера public void onNewData(PerseveranceData newData) { this.data = newData; notifyObservers(); } }

Perseverance хранит список Наблюдателей в переменной observers. Это множество (Set), так как в этом списке не допускаются дубликаты (представления одного типа), а также нам не важен порядок оповещения Наблюдателей.

При получении новых данных теперь вызывается метод notifyObservers, в котором, в свою очередь, вызывается метод update для каждого подписчика-Наблюдателя.

Классы TemperatureDisplay, PressureDisplay и PhotoPublisher тоже изменятся:

  • Укажем, что каждый из них теперь реализует интерфейс Observer.
  • Создадим конструктор с параметром типа Subject и будем регистрироваться в качестве Наблюдателя прямо при создании класса.

Например, TemperatureDisplay будет выглядеть так:

public class TemperatureDisplay implements Observer { public TemperatureDisplay(Subject subject) { subject.registerObserver(this); } @Override public void update(PerseveranceData data) { System.out.printf("Температура на Марсе - %2.0f градусов по Цельсию %n",data.getTemperature()); } }

Напишем тестовый пример — убедимся, что программа работает так, как мы ожидаем:

public class PerseveranceTest { public static void main(String[] args) { // создадим экземпляр ровера Perseverance perseverance = new Perseverance(); // и экземпляры классов-представлений TemperatureDisplay temperatureDisplay = new TemperatureDisplay(perseverance); PressureDisplay pressureDisplay = new PressureDisplay(perseverance); PhotoPublisher photoPublisher = new PhotoPublisher(perseverance); // отдельно регистрировать их в качестве Наблюдателей уже не нужно - они зарегистрировались в конструкторах // передадим роверу тестовые данные perseverance.onNewData(new PerseveranceData(-25, 0.6, "кратер Езеро")); System.out.println("--------------"); // теперь уберём из списка подписчиков temperatureDisplay perseverance.unregisterObserver(temperatureDisplay); // и снова вызовем обновление данных perseverance.onNewData(new PerseveranceData(-35, 0.5, "море Дождей")); } }

Так как мы создали экземпляры всех трёх классов-представлений, то при первом вызове метода onNewData все три класса должны получить обновления и обработать их в соответствии со своими реализациями метода update.

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

Запустим приложение и убедимся в этом:

Вывод в консоли после запуска программы. Скриншот: Екатерина Степанова / Skillbox Media

Мы передавали новые данные от марсохода в методе update, но допустима и другая реализация: — передавать в метод update экземпляр Субъекта целиком и воспользоваться его методом getData для получения новых данных.

public class TemperatureDisplay implements Observer { public TemperatureDisplay(Subject subject) { subject.registerObserver(this); } public void update(Subject subject) { System.out.println(String.format("Температура на Марсе - %2.0f градусов по Цельсию", ((Perseverance) subject).getData().getTemperature())); } }

В этом случае мы приводим (преобразуем) экземпляр Subject к PerseveranceData, чтобы достучаться до температуры, давления и фотографий.

Этот вариант позволяет использовать интерфейс Observer для других задач, не связанных с марсоходами, — так как его метод для обновления больше не завязан на формат данных ровера. О том, какие конкретно данные передаются, «знают» только реализации интерфейса.

Можно не изобретать велосипед и не писать свои интерфейсы Subject и Observer, а воспользоваться готовыми возможностями Java — в пакете java.beans есть класс PropertyChangeSupport и интерфейс PropertyChangeListener, которые отлично подходят для реализации паттерна «Наблюдатель».

Чтобы всё заработало, в класс Субъекта нужно добавить экземпляр PropertyChangeSupport, а классы Наблюдателей должны имплементить (реализовывать) интерфейс PropertyChangeListener.

Вот так будет выглядеть новая версия Perseverance:

public class Perseverance { private PerseveranceData data; private final PropertyChangeSupport support = new PropertyChangeSupport(this); public void addPropertyChangeListener(PropertyChangeListener pcl) { support.addPropertyChangeListener(pcl); } public void removePropertyChangeListener(PropertyChangeListener pcl) { support.removePropertyChangeListener(pcl); } public PerseveranceData getData() { return data; } public void setData(PerseveranceData data) { this.data = data; } // этот метод вызывается каждый раз при получении новых данных от ровера public void onNewData(PerseveranceData newData) { support.firePropertyChange("perseverance", this.data, newData); this.data = newData; } }

А так — класс для вывода температуры:

public class TemperatureDisplay implements PropertyChangeListener { @Override public void propertyChange(PropertyChangeEvent evt) { System.out.println(String.format("Температура на Марсе - %2.0f градусов по Цельсию", ((PerseveranceData) evt.getNewValue()).getTemperature())); } }

В классе PropertyChangeSupport есть методы для добавления и удаления новых Наблюдателей, а также метод для оповещения — firePropertyChange. Он принимает три параметра: тип изменений, предыдущие данные и новые данные.

Такой механизм даёт Наблюдателям дополнительные возможности. Как вариант, они могут реагировать только на отдельные категории событий или совершать какие-то действия только при достижении целевого уровня изменений: например, выводить температуру на экран, только если она изменилась не менее чем на 5 градусов.

В Java ещё есть интерфейс java.util.Observer и класс java.util.Observable. Для реализации паттерна «Наблюдатель» с их помощью можно наследовать класс Субъекта от Observable и имплементить Observer в своих Наблюдателях.

Однако, начиная с Java 9, Observer и Observable помечены deprecated — не рекомендуются к использованию. Вместо них лучше применять PropertyChangeSupport и PropertyChangeListener.

Шаблон проектирования «Наблюдатель» полезен, когда одни объекты нужно оповещать об изменении других по подписке. С его помощью можно создать гибкое и легко расширяемое решение, при котором:

  • новые подписчики-Наблюдатели добавляются без изменений в существующих классах;
  • реализации Субъекта и Наблюдателей отделены друг от друга, так что бизнес-логику в Наблюдателях можно менять как угодно без правок Субъекта.

О других шаблонах проектирования, а также об алгоритмах, структурах данных, концепциях объектно-ориентированного программирования с примерами на языке Java — на курсе «Профессия Java-разработчик PRO». Освойте востребованный язык программирования, научитесь создавать качественные приложения под разные платформы, а Skillbox поможет с трудоустройством.

Бесплатный курс по Python ➞
Мини-курс для новичков и для опытных кодеров. 4 крутых проекта в портфолио, живое общение со спикером. Кликните и узнайте, чему можно научиться на курсе. Смотреть программу