Код
Java
#База знаний

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

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

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 поможет с трудоустройством.

Изучайте IT на практике — бесплатно

Курсы за 2990 0 р.

Я не знаю, с чего начать
Научитесь: Профессия Java-разработчик PRO Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована