观察者模式

本文搬运自《Head First 设计模式》第二章

工作合约

工作合约 恭喜贵公司获选为蔽公司建立下一代 Internet 气象观测站!该气象观测站必须建立在我们专利申请中的 WeatherData 对象上,由 WeatherData 对象负责追踪目前的天气状况(温度,适度,气压)。我们希望贵公司建立一个应用,有三种布告板,分别显示目前的状况,气象统计以及简单的预报。当 WeatherObject 对象获得最新的测量数据时,三种布告板必须时时更新。 而且,这是一个可以扩展的气象站,Weather-O-Rama 气象站希望公布一组 API,好让其他开发人员可以写出自己的气象布告板,并插入此应用中。我们希望贵公司能够提供这样的 API。 Weather-O-Rama 气象站有很好的商业运营模式:一旦客户上钩,他们使用每个布告板都要付钱。最好的部分就是,为了感觉贵公司建立此系统,我们将以公司的认股权支付你。 我们期待看到你的设计和应用的 alpha 版本。 真挚的 Johnny Hurricane --- Weather-O-Rama 气象站执行长 附注:我们正在通宵整理 WeatherData 源文件给你们

气象监测应用的概况

此系统中的三个部分是气象站(获取实际气象数据的物理装置),WeatherData 对象(追踪来自气象站的数据,并更新布告板)和布告板(显示目前天气状况给用户看)。 SCR-20221029-h6d.png 瞧一瞧刚送到的 WeatherData 类 如同他们所承诺的,隔天早上收到了 WeatherData 源文件,看了一下代码,一切都很直接: SCR-20221029-h7t.png

我们目前知道些什么

  • WeatherData 类提供了三个 getter 方法,可以取得三个测量值:温度,湿度和气压。
  • 当新的测量数据备妥时,measurementsChanged() 方法就会被调用(我们不在乎这个方法是怎么被调用的,我们只在乎它被调用了)。
  • 我们需要实现三个使用天气数据的布告板:“目前状态”布告,“气象统计”布告,“天气预报”布告。一旦有新的测量,这个布告需要马上更新。
  • 此系统必须可扩展,让其他开发人员建立定制的布告板,用户可以随心所欲地添加或删除任何布告板。目前初始的布告板有三类:“目前状况”布告,“气象统计”布告,“天气预报”布告。

先看一个错误示范

这是第一个可能的实现,我们依照 Weather-O- Rama 气象站开发人员的暗示,在 measurementsChanged() 方法中添加我们的代码:

export class WeatherData {
  public measurementsChanged() {
    // 调用三个 get 方法,以取得最近的测量值。
    const temp = this.getTemperature();
    const humidity = this.getHumidity();
    const pressure = this.getPressure();

    // 现在,更新三个布告栏。
    currentConditionsDisplay.update(temp, humidity, pressure);
    statisticsDisplay.update(temp, humidity, pressure);
    forecastDisplay.update(temp, humidity, pressure);
  }

  // 这里是其他 WeatherData 的方法
}

这样实现有什么问题:

  • 我们这是在针对实现编程,而非针对接口。
  • 对于每个新的布告板,我们都要修改代码。
  • 我们没有封装改变的部分。
  • 我们无法在运行时动态增加或删除布告栏。

认识观察者模式

我们看看报纸和杂志的订阅是怎么回事

  1. 报社的业务就是出版报纸
  2. 向某家报社订阅报纸,只要他们有新报纸出版,就会给你送来。只要你是他们的客户,你就会一直收到新报纸
  3. 当你不想再看报纸时,取消订阅,他们就不会再送新报纸来
  4. 只要报社还在运营,就会一直有人(或单位)向他们订阅报纸或取消订阅报纸

出版者 + 订阅着=观察者模式

如果你了解报纸订阅是怎么回事,其实就知道观察者模式是怎么回事,只是名称不太一样:出版者改称为“主题”(Subject),订阅者改称为“观察者”(Observer)。 让我们来看得更仔细一点: SCR-20221029-h9o.png

定义观察者模式

当你试图勾勒观察者模式时,可以利用报纸订阅服务,以你出版社和订阅者来比拟这一切。 在真实世界中,你通常会看到观察者模式被定义成: >观察者模式 定义了对象之间一对多依赖,这样一来,当一个对象改变时, 它的所有依赖者都会收到通知并自动更新。

SCR-20221029-hae.png

松耦合的威力

当两个对象之间松耦合,它们依然可以交互,但是不太清楚彼此的细节。 观察者模式提供了一种对象设计,让主题和观察者之间松耦合。 为什么呢? 关于观察者的一切,主题只知道观察者实现了某个接口(Observer 接口)。主题不需要知道观察者的具体类是谁,做了什么或其他细节。 任何时候我们都可以增加新的观察者。因为主题唯一依赖的东西是一个实现 Observer 接口的对象列表,所以我们可以随时增加观察者。事实上,在运行时我们可以用新的观察者取代现有观察者,主题不受任何影响。同样的,也可以在任何时候删除某个观察者。 有新类型的观察者出现时,主题的代码不需要修改。假如我们有个新的具体类需要当观察者,我们不需要为了兼容新类型而修改主题的代码,所有要做的就是在新的类里实现此观察者接口,然后注册为观察者即可。主题不在乎别的,它只会发送通知给所有实现了观察者接口的对象。 我们可以独立的复用主题或观察者。如果我们在其他地方需要使用主题和观察者,可以轻易的复用,因为二者并非紧耦合。 改变主题或观察者其中一方,并不会影响另一方。因为两者是松耦合的,所以只要他们之间接口仍被遵守,我们就可以自由得改变他们而不会有任何问题。 >设计原则 为了交互对象之间的松耦合设计而努力

松耦合的设计之所以能让我们建立有弹性的 OO 系统,能够应对变化,是因为对象之间的互相依赖降到了最低。

完成气象站

设计气象站

SCR-20221029-hbq.png

实现气象站

根据上面的 UML 图,我们要开始实现这个系统了。如果我们使用 Java 去实现,实际上在 java.util 包中已经内置了最基本的 Observer 接口与 Observable 类,这和我们的 Subject 接口 与 Observer 接口很相似。既然我们时前端开发那么我们就用 TS 去实现。 所以,让我们从建立接口开始:

export interface Subject {
  registerObserver(o: Observer);
  removeObserver(o: Observer);
  notifyObservers();
}

export interface Observer {
  update(temp: number, humidity: number, pressure: number);
}

export interface DisplayElement {
  display();
}

把观测值直接传入观察者中是更新状态最直接的方法。你认为这样做法明智吗? 暗示:这些观测值的种类和个数在未来有可能改变吗?如果以后会改变,这些变化是否被很好得封装?

别担心,在我们完成第一次实现后,我们会再回来探讨这个设计决策。

在 WeatherData 中实现主题接口

还记得我们在一开始试图实现的 WeatherData 类吗?我们现在要用观察者模式实现。

export class WeatherData implements Subject {
  private observers: Set<Observer> = new Set();
  private temperature: number;
  private humidity: number;
  private pressure: number;

  public registerObserver(o: Observer) {
    this.observers.add(o);
  }

  public removeObserver(o: Observer) {
    if (this.observers.has(o)) {
      this.observers.delete(o);
    }
  }

  public notifyObservers() {
    this.observers.forEach((observer) => {
      observer.update(this.temperature, this.humidity, this.pressure);
    });
  }

  public measurementsChanged() {
    this.notifyObservers();
  }

  /**
   * 为了测试,模拟读取气象数据
   */
  public setMeasurements(temp: number, humidity: number, pressure: number) {
    this.temperature = temp;
    this.humidity = humidity;
    this.pressure = pressure;
    this.measurementsChanged();
  }
}

现在我们来建立布告板吧

我们已经把 WeatherData 类写出来了,现在轮到布告板了。Weather-O-Rama 气象站订购了三个布告板:目前状况布告板,统计布告板和预测布告板。我们先看看目前布告板。

/**
 * 目前状况布告板
 */
export class CurrentConditionsDiaplay implements Observer, DisplayElement {
  private temperature: number;
  private humidity: number;

  public constructor(private weatherData: Subject) {
    weatherData.registerObserver(this);
  }

  public update(temp: number, humidity: number) {
    this.temperature = temp;
    this.humidity = humidity;
    this.display();
  }

  public display() {
    console.log(
      `Current conditions: ${this.temperature}F degress and ${this.humidity}%humidity`
    );
  }
}

很方便,我们实现了另外两块布告板

/**
 * 统计布告板
 */
export class StatisticsDisplay implements Observer, DisplayElement {
  private tempSum = 0;
  private maxTemp = 0;
  private minTemp = 200;
  private numReadings = 0;

  public constructor(private weatherData: Subject) {
    weatherData.registerObserver(this);
  }

  public update(temp: number, humidity: number, pressure: number) {
    this.tempSum += temp;
    this.numReadings++;

    if (temp > this.maxTemp) {
      this.maxTemp = temp;
    }

    if (temp < this.minTemp) {
      this.minTemp = temp;
    }
    this.display();
  }

  public display() {
    console.log(
      'Avg/Max/Min temperature = ' +
        this.tempSum / this.numReadings +
        '/' +
        this.maxTemp +
        '/' +
        this.minTemp
    );
  }
}
/**
 * 预测布告板
 */
export class ForecastDisplay implements Observer, DisplayElement {
  private currentPressure = 29.92;
  private lastPressure = 0;

  public constructor(private weatherData: Subject) {
    weatherData.registerObserver(this);
  }

  public update(temp: number, humidity: number, pressure: number) {
    this.lastPressure = this.currentPressure;
    this.currentPressure = pressure;

    this.display();
  }

  public display() {
    console.log('Forecast: ');
    if (this.currentPressure > this.lastPressure) {
      console.log('Improving weather on the way!');
    } else if (this.currentPressure == this.lastPressure) {
      console.log('More of the same');
    } else if (this.currentPressure < this.lastPressure) {
      console.log('Watch out for cooler, rainy weather');
    }
  }
}

启动气象站

先建立一个测试程序

气象站已经完成得差不多了,我们还需要一些代码将一切连接在一起:

export class WeatherStation {
  public main() {
    const weatherData = new WeatherData();

    new CurrentConditionsDiaplay(weatherData);
    new StatisticsDisplay(weatherData);
    new ForecastDisplay(weatherData);

    weatherData.setMeasurements(80, 65, 30.4);
    weatherData.setMeasurements(82, 70, 29.2);
    weatherData.setMeasurements(78, 90, 29.2);
  }
}

运行程序

image.png

新需求:加入酷热指数布告板

刚刚接到气象站来电通知,他们还需要酷热指数布告板,而我们实现起来也很容易,之前涉及的代码完全不需要改动什么,只需要新建一个类就可以了:

/**
 * 酷热指数布告板
 */
export class HeatIndexDisplay implements Observer, DisplayElement {
  private heatIndex = 0;

  public constructor(private weatherData: Subject) {
    weatherData.registerObserver(this);
  }

  public update(temp: number, humidity: number, pressure: number) {
    this.heatIndex = this.computeHeatIndex(temp, humidity);

    this.display();
  }

  public display() {
    console.log('Heat index is ' + this.heatIndex);
  }

  private computeHeatIndex(t: number, rh: number) {
    return (
      16.923 +
      0.185212 * t +
      5.37941 * rh -
      0.100254 * t * rh +
      0.00941695 * (t * t) +
      0.00728898 * (rh * rh) +
      0.000345372 * (t * t * rh) -
      0.000814971 * (t * rh * rh) +
      0.0000102102 * (t * t * rh * rh) -
      0.000038646 * (t * t * t) +
      0.0000291583 * (rh * rh * rh) +
      0.00000142721 * (t * t * t * rh) +
      0.000000197483 * (t * rh * rh * rh) -
      0.0000000218429 * (t * t * t * rh * rh) +
      0.000000000843296 * (t * t * rh * rh * rh) -
      0.0000000000481975 * (t * t * t * rh * rh * rh)
    );
  }
}

再看刚才的那个设计决策

我们可以看一下 java.uitl.Observer 是怎么做的: 它也有一个 update 方法,但是方法签名不太一样

update(Observable o, Object arg)

这样做就比较灵活了, 主题本身当作第一个个变量,好让观察者知道是哪个主题通知它的。 第二个参数是具体的数据对象,这样就能做到通用。 感兴趣的同学可以用最新的设计方式重构上面的实现。

本章要点

  • 观察者模式定义了对象之间一对多的关系
  • 主题(也就是可观察者)用一个共同的接口来更新观察者
  • 观察者和可观察者之间用松耦合的方式结合(loosecoupling),可观察者不知道观察者的细节,只知道观察者实现了观察者接口。

本章源码

https://stackblitz.com/edit/typescript-e4kru2?file=index.ts