设计模式——2_6 观察者(Observer)

这世界没有一件事情是虚空而生的,站在光里,背后就会有阴影,这深夜里一片寂静,是因为你还没有听见声音

------马良《坦白书》

文章目录

定义

定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖他的对象都得到通知并被自动更新

二十年前如果你想知道明天的天气怎么样,你用不着隔几分钟就到气象台问问有没有最新的情报,而是可以在气象台登记一下你的电话号码。每当有最新的天气预报发布的时候,气象台会自动给所有进行登记过的电话号码发短信(这里面就包括你的)

这种模式其实就是观察者模式,而你就是被记录在册的观察者

图纸

一个例子:在RPG游戏里应对善变的天气

假定现在我们有一个RPG对战游戏,有这样的设定:

  • 天气分为:晴天、大雾和下雨
  • 玩家可以选择火元素、水元素或者风元素的骑士
  • 骑士一定是在某个区域内活动,而区域有对应的天气。每种元素的骑士在不同的天气下会变化自己的属性

定义元素

很显然,天气是区域的一种属性,就像这样:

Area & Weather
java 复制代码
/**
 * 骑士可以活动的区域
 */
public class Area {

    /**
     * 当前区域的天气
     */
    private Weather weather;

    public Area(Weather weather) {
        this.weather = weather;
    }

    public Weather getWeather() {
        return weather;
    }

    public void setWeather(Weather weather) {
        this.weather = weather;
    }
}

public enum Weather {

	sunny,fog,rain
}

骑士也应当有自己的类簇,就像这样:

java 复制代码
/**
 * 骑士
 */
public class Knight {

    /**
     * 攻击力
     */
    private int attack;

    /**
     * 生命值
     */
    private int healthPoint;

    /**
     * 骑士名称
     */
    private String name;

    /**
     * 骑士所在区域
     */
    private Area area;

    public Knight(String name, Area area) {
        this.name = name;

        setAttack(10);//默认10点攻击力
        setHealthPoint(100);//默认100点生命值
      	setArea(area);
    }

    public int getAttack() {
        return attack;
    }

    public void setAttack(int attack) {
        this.attack = attack;
    }

    public int getHealthPoint() {
        return healthPoint;
    }

    public void setHealthPoint(int healthPoint) {
        this.healthPoint = healthPoint;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Area getArea() {
        return area;
    }

    public void setArea(Area area) {
        this.area = area;
    }
    
    @Override
    public String toString() {
        return String.format("%s:攻击力=%s", name, attack);
    }
}

/**
 * 火属性骑士
 */
public class FireKnight extends Knight {

    public FireKnight(Area area) {
        super("火属性骑士", area);
    }
}

/**
 * 风属性骑士
 */
public class WindKnight extends Knight {

    public WindKnight(Area area) {
        super("风属性骑士", area);
    }
}

/**
 * 水属性骑士
 */
public class WaterKnight extends Knight {

    public WaterKnight(Area area) {
        super("水属性骑士", area);
    }
}

我们创建了天气的枚举 Weather 用来表示所有当前可能出现的天气,并把 Weather 作为 Area 的内部属性

对于骑士,我们创建 Knight 根类用于存放所有的骑士都有的一些属性,再根据不同的元素分出三个子类

现在我们实现了前两步,至于最后一步,想必我们需要在 AreaKnight 之间建立一些联系

给 Area 和 Knight 建立联系

那你会说了,不对啊,Knight 里面有自己当前所处的 Area 的引用,这不就是很好的联系吗?

Knight 里面的引用,实现出来的效果是这样的:

java 复制代码
/**
 * 骑士
 */
public class Knight {

    ......

    public void setArea(Area area) {
        this.area = area;
        updateByWeather();
    }

    protected void updateByWeather(){
        //不实现,也不强制子类实现她,所以留空
    }
    
    /**
     * 把属性复原
     */
    protected void reset(){
        setAttack(10);
    }
}

/**
 * 火属性骑士
 */
public class FireKnight extends Knight {

    public FireKnight(Area area) {
        super("火属性骑士", area);
    }

    @Override
    protected void updateByWeather() {
        reset();

        Weather weather = getArea().getWeather();
        if (weather.equals(Weather.sunny)) {
            //如果是晴天,攻击力+10
            setAttack(getAttack() + 10);
        } else if (weather.equals(Weather.rain)) {
            //如果是雨天,攻击力减半
            setAttack(getAttack() / 2);
        }
    }
}

/**
 * 风属性骑士
 */
public class WindKnight extends Knight {

    public WindKnight(Area area) {
        super("风属性骑士", area);
    }

    //什么天气都跟他没关系 所以不需要重写
}

/**
 * 水属性骑士
 */
public class WaterKnight extends Knight {

    public WaterKnight(Area area) {
        super("水属性骑士", area);
    }

    @Override
    protected void updateByWeather() {
        reset();

        Weather weather = getArea().getWeather();
        if (weather.equals(Weather.rain) || weather.equals(Weather.fog)) {
            //如果是雾天或者下雨,攻击力翻倍
            setAttack(getAttack() * 2);
        } else if (weather.equals(Weather.sunny)) {
            //如果是晴天,攻击力降为1
            setAttack(1);
        }
    }
}

采用这种方案,我们在 Knight 为自己设定 Area 的时候就读取了天气信息,同时更新自己的属性,使用 updateByWeather 方法

善变的天气

可是问题很快出现了,我们玩这个游戏的时候发现,所有的玩家都会根据即将进入的区域选择合适的骑士,没有人蠢到故意在晴天选水骑士,或者在下雨时选火骑士

所以为了增加可玩性,我们设定了第四点需求:

  • 一个区域内的天气不是一成不变的,他会进行随机的变化

想法很好,实践起来却有点麻烦了

根据前面的设计,我们在set Area 的时候变化了自己的属性,之后 Area 里面的 Weather 会如何变化,Knight 是不知道的

怎么让他知道呢?我们有两种方案:

  1. Knight 里面添加一个定时器,固定时间去查 Area 里面的 Weather 属性,如果出现了变化,更新自己
  2. 想个办法让 Area 在更新 Weather 的时候去通知 Knight ,让 Knight 及时更新

一看就知道后者明显优于前者,那能做到吗?

可以的,就像我们之前注册迭代器一样。我们只需要在 Area 里面维护一个 Knight 列表,然后在 set Weather 的时候通知 Knight 就完事了,就像这样:

java 复制代码
/**
 * 骑士
 */
public class Knight {

    ......
        
    public void setArea(Area area) {
        //注销
        if (this.area != null) {
            this.area.removeKnight(this);
        }

        this.area = area;

        //注册
        area.addKnight(this);

        //第一次执行
        updateByWeather();
    }

    public void update(){
        updateByWeather();
    }
}

/**
 * 骑士可以活动的区域
 */
public class Area {
    
    ......

    public void setWeather(Weather weather) {
        this.weather = weather;
        notifyKnight();
    }

    private final List<Knight> knightList = new ArrayList<>();

    public void addKnight(Knight knight){
        knightList.add(knight);
    }

    public void removeKnight(Knight knight){
        knightList.remove(knight);
    }

    public void notifyKnight(){
        for (Knight knight : knightList) {
            knight.update();
        }
    }
}

我们让 Area 去维护一个 knightList ,并在 Knight 设定 Area 的时候把自己写到 knightList 里面去。这就实现了一个可以从 Area 发指令给 Knight 的通道。接着,我们需要发送指令的时候,可以通过 notifyKnight 方法通知 knightList 里面所有的 Knight

这样一来,第四点需求得以实现,就像这样:

java 复制代码
public static void main(String[] args) {
	Area area = new Area(Weather.sunny);

	Knight fire = new FireKnight(area);
	Knight wind = new WindKnight(area);
	Knight water = new WaterKnight(area);

	System.out.println("晴天");
	System.out.printf("%s \n%s \n%s \n", fire, wind, water);

	System.out.println("*************************************************************");

	area.setWeather(Weather.rain);
	System.out.println("雨天");
	System.out.printf("%s \n%s \n%s \n", fire, wind, water);
}

而这正是一个标准的观察者实现

观察者的结构和原理简单到一眼就能望到头,但是这个简单的结构解决了无数个问题。这有点像多线程里面的 生产-消费者模型,也是结构简单但极其实用。也许这就是大道至简吧

碎碎念

定时器的方案一无是处吗?

其实并不是的,上例的情况是因为 KnightArea 可以双向主动向对方发起请求,所以可以用观察者,但是很多时候连接是单向的

比如说 http,这就是个无状态协议,除非用一些比较特殊的手法(比如 WebSocket),否则服务器是没办法主动向客户机发送请求的

这时候如果你有时效性不那么高的类似请求(游戏的时效性要求肯定不允许你用定时器),那么定时器和长连接就是你需要考虑的解决方案了

观察者和中介者

到了行为型模式这一篇,其实有很多模式关注的内容是类似的

比如前面讲过的 职责链(Chain of Responsibility)命令(Command)

职责链和命令都是通过参数化请求,以求实现请求者和处理器之间的解耦

之后还会提到的 状态模式(State)策略模式(Strategy)状态模式简直就是策略模式水里的倒影

以及现在要讲的 观察者(Observer)中介者(Mediator)

观察者和中介者都是为对象和对象之间通讯而存在的

这种通讯相当于,对象A执行了某个动作(在面向对象中其实就是某个函数被调用),对象B就要针对这个行为执行自己的动作

这就像自行车的主动轮和从动轮之间的关系

假定我们现在有A和B两个对象,A发出通知,B接收A的通知并执行操作

在这种情况下,如果让A直接调用B,那就意味着他们之间建立紧耦合;如果想要解耦,那么对象之间的通信方式基本上有两种

  1. 在A和B之间建立一个平台,让A和B都去跟平台打交道而不知道对方的存在,这个平台就是 中介者
  2. 在A里面,维护一个监听者列表,形成一个 1→N 的关系,这时候我会把B写入A的监听者列表里(这时候建立的是抽象耦合)。当发生某个事件的时候,A会通知所有监听者进行更新(其中就包含B),这时候的B就是 观察者

先说两种方式的共同点,两种做法都可以解除A和B之间的紧耦合。A可以不知道这个通知会被传递到哪里去,可以不知道B的数量,甚至可以不知道B的具体类型

但两种设计模式又各有千秋:

  • 中介者 内部的对象没有明确的主次,任何对象都可以通过平台发出信息或对某个信息进行响应
  • 观察者 不需要这个平台,subject和observer之间存在明确的主次关系,信息传递的方向也永远是 S u b j e c t → O b s e r v e r Subject → Observer Subject→Observer

这是他们好的一面,而他们的缺点和优点一样明显:

  • 中介者 的平台随着所要维护的对象数量增加,需要处理的关联也越来越多,这最终会让中介者平台变成一个庞然大物,所有的关联都集中到一种,最终形成一个错综复杂的线团,把他理清是很痛苦的事情

  • 观察者 不需要第三方平台,这是便利,也是缺陷。因为subject和observer都对对方太不了解了,所以在后期维护的时候,如果不了解程序结构的人调用了subject里某个会在observer里产生副作用的方法,程序可能出现一些诡异的行为,而很难发现是哪个观察者的问题。这些诡异的行为包括但不限于:

    • 调用一个更新数据的方法,但另一个看似风马牛不相及的视图也被更新了

    • subject通知observer进行操作,但是observer又会调用subject里的行为,到最后形成死循环

      那你会说,我又不是傻,为什么要这样写?

      现实是这种情况时有发生,因为整个项目的结构里不会只有 subject 和 observer 这两者。也许你通过subject 通知 observer 后,observer 又会去调用其他对象,其他对象又调用其他对象,以此往复,最终跑回 subject 来

可以抽象出来的Subject和Observer

你可能发现了,其实所有在观察者模式中发出信息的 变化主体,或者说 subject,在维护观察者列表的时候,都需要三个方法:

  1. addObserver

    增加观察者

  2. removeObserver

    删除观察者

  3. notifyObserver

    通知观察者进行更新

在观察者,或者说Observer里,则需要用于更新的 update 方法

既然有通用的部分,那我们其实就可以把他们抽象出来,就像这样:

java 复制代码
/**
* 被观察者 主体
**/
public class Subject {

    private final List<Observer> observerList = new ArrayList<>();

    public void notifyObserver() {
        for (Observer observer : observerList) {
            observer.update(this);
        }
    }

    public void addObserver(Observer observer) {
        observerList.add(observer);
    }

    public void removeObserver(Observer observer) {
        observerList.remove(observer);
    }
}

/**
* 观察者
**/
public interface Observer {

    void update(Subject subject);
}

在Java里面,Subject和Observer甚至不需要你自己写,因为在 java.util 里面就有对应的工具类,可以直接继承

虽然说从Java9 开始这玩意就废弃了,我也是写这个的时候才发现

不过值得一提的是,Observer 通常可以作为接口存在,但如果你需要 Subject 帮你维护观察者列表,那么 Subject 至少得是一个抽象类,那就只能用继承

在Java这个单继承的语言里,使用继承要慎重,就比如上例的 Area ,就算我有 Subject 工具类,我也不会让 Area 去继承她,我宁可自己写

因为这会破坏整体的语法,区域怎么可能是主体的子类呢?这会让继承我的代码的后辈产生误解,这虽然只是编程风格的问题,但是我坚信细节决定成败,所以该抠的地方还是严谨一点的好

万分感谢您看完这篇文章,如果您喜欢这篇文章,欢迎点赞、收藏。还可以通过专栏,查看更多与【设计模式】有关的内容

相关推荐
罗政2 分钟前
PDF书籍《手写调用链监控APM系统-Java版》第9章 插件与链路的结合:Mysql插件实现
java·mysql·pdf
一根稻草君8 分钟前
利用poi写一个工具类导出逐级合并的单元格的Excel(通用)
java·excel
kirito学长-Java11 分钟前
springboot/ssm网上宠物店系统Java代码编写web宠物用品商城项目
java·spring boot·后端
木头没有瓜25 分钟前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
奋斗的老史25 分钟前
Spring Retry + Redis Watch实现高并发乐观锁
java·redis·spring
high201127 分钟前
【Java 基础】-- ArrayList 和 Linkedlist
java·开发语言
老马啸西风34 分钟前
NLP 中文拼写检测纠正论文 C-LLM Learn to CSC Errors Character by Character
java
Cosmoshhhyyy1 小时前
LeetCode:3083. 字符串及其反转中是否存在同一子字符串(哈希 Java)
java·leetcode·哈希算法
AI人H哥会Java1 小时前
【Spring】基于XML的Spring容器配置——<bean>标签与属性解析
java·开发语言·spring boot·后端·架构