时隔一年多之后,我又回来更新这个系列了==。==

这次我们来看一下被广为使用,即使没接触过设计模式同学也一定听说过的,观察者模式

JDK版本:11(Java8的语言特性)

参考书籍:《HEAD FIRST 设计模式》

IDE: IntelliJ IDEA

实例引入

接触过Web前端的同学都知道,DOM API中,可以给事件绑定回调函数 (callback)

const node = document.createElement("div")
node.onclick = () => console.log("clicked!")

简单解释一下,代码第一行创建一个DOM,第二行给 onclick 设置一个函数,每当触发 click 事件时都会执行该函数【还有个闭包的效果,这里没体现,当然这也不是这篇的重点,不展开讲】。

来看一下点击之后的效果

同理我们还可以给其他事件绑定回调函数

node.ondrag = () => console.log("dragged!")

通俗来讲,上述代码做的是类似 trigger 一样的工作:当目标对象发生变化时,我们需要一个函数/方法去对其进行响应。

在上述语句中我们引入几个名词:

  • 被观察对象( observerable ):即目标对象 node
  • 观察者( observer ):对 observable 的变化进行响应的对象(示例代码中的匿名函数)
  • 订阅( subscribe ):对象成为 observer 的一员这一动作( node.onclick = ...
  • 广播( boardcast ):observerable 向所有的 observer 发送消息这一动作( node.click()

并给出观察者模式的正式定义:

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。https://design-patterns.readthedocs.io/zh_CN/latest/behavioral_patterns/observer.html “3. 观察者模式— Graphic Design Patterns - Read the Docs”

可以看到,上述JS代码其实就已经应用了一个简易的观察者模式。但需要注意有些许不同:

  1. JS代码中,虽然对一个 node 有多个观察者,但对一个事件只设了一个观察者,不是很符合 一对多的依赖关系定义。
  2. JS代码中使用了匿名函数( anonymous function),而Java中并没有函数,意味着需要使用接口来曲线救国,或者使用JDK8+的 lambda 表达式来实现,但是本质还是接口。

具体实现

思路非常简单,目标对象内部维护一个观察者列表,当发生变化时,调用观察者提供的方法即可。

直接看UML图

Subject 接口有 attachdetachnotifyObservers 这几个方法。

Observer 接口有 update 一个方法即可。

// 被观测对象接口
public interface Observable {
    void attach(Observer observer);

    void detach(Observer observer);

    void notifyAllObservers();

}
// 观察者接口
public interface Observer {
    void update();
}

// 实现类
public class ObservableImpl implements Observable {
    private List<Observer> observers = new ArrayList<>();

    @Override
    public void attach(Observer observer) {
        this.observers.add(observer);
    }

    @Override
    public void detach(Observer observer) {
        this.observers.remove(observer);
    }

    @Override
    public void notifyAllObservers() {
        this.observers.forEach(Observer::update);
    }
}

前面的部分如砍瓜切菜般写完,可 update 接口该怎么实现?是直接把整个目标对象传过去(或者干脆观察者内部保存目标对象应用),还是仅把需要的字段传过去?由此引出了观察者模式的两种实现—— PushPull 模型

Pull模型——数据自己取

在Pull模型中,update 的声明大概如下

void push(Observerable subject);

目标对象把自己作为参数直接传给观察者,观察者调用 getter 获取状态,然后进行操作。

所以这里要给 Observerable 接口 getter 方法

// 被观测对象接口
public interface Observable {
    void attach(Observer observer);

    void detach(Observer observer);

    void notifyAllObservers();

    State1 getState1();

    State2 getState2();

}

当然了,如果想把代码写的ugly一点,也可以直接强制类型转换成真实对象的类型。

Push模型——主动送数据

在Push模型中,update 的声明大概如下

void update(int data1, int data2,...);

直接把需要的数据放在形参列表里写死,这样的好处是 Observer 拿到数据就能干活,不同的观察者所需要的数据不一样,需要创建多个 Observer 接口来重载 update

Pros and Cons

推模型是假定主题对象知道观察者需要的数据;而拉模型是主题对象不知道观察者具体需要什么数据,没有办法的情况下,干脆把自身传递给观察者,让观察者自己去按需要取值。

推模型可能会使得观察者对象难以复用,因为观察者的update()方法是按需要定义的参数,可能无法兼顾没有考虑到的使用情况。这就意味着出现新情况的时候,就可能提供新的update()方法,或者是干脆重新实现观察者;而拉模型就不会造成这样的情况,因为拉模型下,update()方法的参数是主题对象本身,这基本上是主题对象能传递的最大数据集合了,基本上可以适应各种情况的需要。https://www.cnblogs.com/KongkOngL/p/6849859.html “Java设计模式の观察者模式(推拉模型)”

简单以一表分析二者优劣

优势 劣势
Pull 易于扩展 封装性受到破坏
Push 松耦合 多需求下需要多个update方法

多线程解决观察者模型

观察者模型看似已经完美,但有一点值得引起注意:观察者很可能会造成阻塞,影响当前线程的继续执行,所以我们要请出多线程来帮忙,每当有 broadcast 发出时,每个观察就开一个线程来执行update。

下面来看一个例子,这里有一个简易的 Observable Counter,观察者订阅其计数器变化的消息。

public class Counter implements Observable {
    private List<Observer> observers = new ArrayList<>();
    private int cnt = 0;
    @Override
    public void attach(Observer observer) {
        this.observers.add(observer);
    }

    @Override
    public void detach(Observer observer) {
        this.observers.remove(observer);
    }

    @Override
    public void notifyAllObservers() {
        this.observers.forEach(observer -> observer.update(cnt));
    }

    void increase() {
        cnt++;
        notifyAllObservers();
    }
    void decrease() {
        cnt--;
        notifyAllObservers();
    }
}

public class ObserverImpl implements Observer {
    @Override
    public void update(int cnt) {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(cnt);
    }
}

这里为了模拟,给update强行 sleep ,造成阻塞的效果。

再来看主函数

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        counter.attach(new ObserverImpl());
        counter.increase();
        counter.increase();
    }
}

运行函数,发现线程被卡住了,第二个 increase 被阻塞。

提出改进:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        counter.attach(cnt -> new Thread(() -> {
            System.out.println(cnt);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start());
        counter.increase();
        counter.increase();
    }
}

这里偷懒使用了 lambda表达式 ,相信各位能够看懂。开新线程来执行update有效防止了阻塞调用。当然同步问题就要去考虑了2333



学习笔记      设计模式

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!