1. 观察者模式到底在解决什么问题
开始讲正题之前,先聊一个几乎所有系统都会碰到的场景。假设你现在做一个订单系统,用户下单之后,你需要做这些事情:给用户发一条短信通知、给仓储系统发一个拣货指令、给财务系统记一笔流水、给用户积分账户加几分。
最直观的写法是什么?把所有的逻辑全部堆在订单服务里面。订单创建成功之后,依次调用短信服务、仓储服务、财务服务、积分服务。代码看起来很方便,刚写完的时候也很爽。但是过了一个月,产品经理说要增加一个新的需求:下单后还要通知推荐人。你打开订单服务,在里面继续加一行调用。再过一个月,又加了一个消息推送服务,再改一次。每一次改动都要去动订单服务的源码,改完还要重新测试整个下单流程,生怕动了哪一行把主链路搞炸了。
观察者模式就是来解决这个问题的。它的核心思想是:订单服务不直接依赖那四个下游服务,只依赖一个"通知列表"。谁关心订单创建这件事,谁就自己注册到列表里。订单创建完成之后,只需要遍历这个列表,告诉所有人"订单创建了"。
这个思路在生活里到处都是。你订阅了一个博主的频道,博主发新内容的时候不需要挨个私信你,他只需要在平台发一条动态,平台负责通知所有订阅者。你和博主之间没有任何直接联系,平台就是中间的调度者。观察者模式里的角色划分跟这个一模一样:
- Subject(被观察者):就是那个博主,也是订单服务。它维护着一个订阅者列表,状态发生变化的时候负责通知所有订阅者。
- Observer(观察者):就是订阅者,也是短信服务、仓储服务这些下游模块。它们注册到被观察者的列表里,等待被通知。
- 触发机制:订单创建成功这个动作就是触发点,对应着被观察者状态的变化。
各角色之间的耦合关系从"订单服务直接依赖所有下游"变成了"订单服务只依赖观察者接口,下游来实现这个接口"。这就是依赖倒置的一种体现。订单服务不再需要知道短信服务是什么、仓储服务怎么调用,它只知道你们实现了这个接口,我把消息给你们,你们自己处理后面的逻辑。
这个模式在Java、C++、Python、Go这些语言里的实现思路完全一致,只是语法不同。我下面会以Java为主来讲实现,因为Java在这块有非常典型的语法特性可以做多种实现方式的对比,看懂了Java的版本,其他语言都是小菜一碟。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JDK自带的Observer到底哪里不好用
很多人第一次学观察者模式,看到的就是Java里自带的java.util.Observable类和java.util.Observer接口。说实话这个内置方案拿来学习观察者模式的原理和流程是完全够用的,JDK的设计者们把这个模式的骨架展示得很清楚。我先把它的用法写出来,然后再来分析它在实际工程里到底有什么问题。
被观察者需要继承Observable类:
java复制import java.util.Observable;
public class OrderSubject extends Observable {
public void createOrder(String orderId) {
System.out.println("创建订单:" + orderId);
// 状态发生变化,需要通知所有观察者
setChanged();
notifyObservers(orderId);
}
}
观察者只需要实现Observer接口:
java复制import java.util.Observable;
import java.util.Observer;
public class SmsObserver implements Observer {
@Override
public void update(Observable o, Object arg) {
System.out.println("短信服务收到订单通知:" + arg);
// 在这里写发短信的逻辑
}
}
测试一下:
java复制public class Main {
public static void main(String[] args) {
OrderSubject order = new OrderSubject();
order.addObserver(new SmsObserver());
order.addObserver(new StockObserver());
order.createOrder("NO.20250601");
}
}
看起来很简单对吧?但是如果你真的把这个内置方案用到项目里,很快就会踩到几个坑。
第一个坑是setChanged()这个方法。JDK的设计者给观察者通知加了一个"状态变更标记"的机制,你必须先调用setChanged()把标记位置为true,再调用notifyObservers()才有效。如果你漏掉了setChanged(),结果是通知根本不会发出去。这个设计对JDK来说有它自己的考虑,它想让你明确知道"只有状态确实发生了变化才通知",避免无意义的通知广播。但是作为使用者,谁记得住这个约束?我见过好几个人在这个地方调了半天,最后发现是忘了调setChanged()。
第二个坑是Observable是一个类,不是接口。这是一个致命的设计问题。Java是单继承的语言,你的订单服务如果继承了Observable,那它就不能再继承其他的类了。这个限制在真实项目里非常难受,因为业务类通常是要继承某个基类的,比如继承一个BaseService或者AbstractOrderService。一旦被Observable占用了唯一的继承名额,整个类的设计被强行改写。这也是为什么后来的很多框架,比如Spring的事件机制,都改成了接口方案或者完全自定义实现。
第三个坑是JDK内置方案在Java 9之后把这个类的构造方法改成了protected,意味着你不能再直接new Observable()然后单独设置它的内部状态,必须通过继承子类来使用。这一点把组合优于继承这条路也堵死了。假如你的业务类不能继承Observable,你想用一个内部的Observable成员变量来辅助通知,在Java 9之后也做不到了。
第四个坑是观察者的通知顺序。notifyObservers()内部遍历的是Vector,这个类是线程安全的,但是它是一个很老的集合类,性能上面没什么优势,而且它默认不是按照注册顺序倒序通知的。JDK的实现是反着遍历Vector的,也就是说先注册的观察者反而后收到通知。如果你有观察者之间存在先后依赖关系,这里就会出问题。
所以我的建议是:JDK自带的观察者模式实现,拿来做学习和理解原理是好的,但是真实项目里不要直接用。要么自己手写一个更灵活的观察者容器,要么用Spring的ApplicationEvent体系,要么用Guava的EventBus。后面我会给出一个比较完整的自定义实现,兼顾灵活性和易用性。
3. 推模型和拉模型的本质区别与取舍
推模型和拉模型是观察者模式里最核心、最值得掰开揉碎讲的话题。也是很多教程写得最含糊的地方,经常就是一段话带过。我在这里把两种模型的接口设计、适用场景、优缺点一次讲清楚。
推模型的思想是:被观察者知道所有观察者可能需要的数据,在通知的时候主动把完整的数据封装好推送过去。接口通常是这样的:
java复制public interface Observer {
void update(OrderInfo order);
}
被观察者直接传递一个OrderInfo对象,这个对象里面包含了订单的全部信息:订单号、用户ID、商品列表、总金额、下单时间等等。每个观察者拿到OrderInfo就可以做自己的逻辑了,不需要再向被观察者问任何信息。
这个方式最直观,也是大多数人初学观察者模式时写出来的代码。平时用Spring事件的时候,你自定义一个ApplicationEvent,然后把所有的数据塞进去发布出去,这本质上也属于推模型。
推模型的优点非常明显:接口简单,观察者拿到的数据已经完整了,不需要额外的调用和等待。观察者这边逻辑纯粹,拿数据、处理数据、完事。
推模型的缺点要等到业务变复杂之后才暴露出来。假设现在OrderInfo这个对象已经固定了,里面有三个字段:订单号、用户ID、下单时间。某一天,新增的观察者需要知道订单的优惠明细,你需要给OrderInfo增加优惠信息字段。这个改动会让所有已经实现了update(OrderInfo order)的观察者都会发现:好家伙,这个类变了,也不知道哪些字段影响到了自己。虽然编译器不会报错,但如果你用的不是编译期强类型的传递而是Map这种弱类型结构,那问题就麻烦了——每个订阅者拿到这个Map都要去翻看里面有哪些新字段,走了哪些字段被丢弃了。
另外还有一种情况更常见:不同的观察者关心的数据完全不一样。短信服务只需要手机号和订单状态;财务服务需要商品类型、价格、优惠信息;积分服务只需要用户ID和订单金额。如果用推模型,被观察者就要造一个大而全的数据包,把所有可能用到的数据全部塞进去。观察者的数量越多、需求差异越大,这个数据包就越臃肿。
推模型的耦合还体现在另一个层面:它强迫被观察者去理解"观察者需要什么"。本来被观察者不应该知道观察者的细节,它只需要发布事件就好了。但是在推模型里,被观察者承担了一部分"按需组装数据"的职责。这在观察者数量少、需求相似度高的场景下没问题,一旦观察者变成十几个、需求差异巨大,被观察者的代码就变得非常难维护。
拉模型的核心转变是:被观察者不做数据组装,它只把"自己"作为一个引用传给观察者,甚至只传一个最小粒度的标识符,由观察者按需拉取数据。接口通常长这样:
java复制public interface Observer {
void update(Subject subject);
}
或者结合泛型:
java复制public interface Observer<T extends Subject> {
void update(T subject);
}
观察者收到的是被观察者本身,它内部如果要获取数据,需要自己去调用被观察者的get方法。
java复制public class SmsObserver implements Observer<OrderSubject> {
@Override
public void update(OrderSubject subject) {
String orderId = subject.getOrderId();
UserInfo user = subject.getUserInfo();
// 只需要自己关心的字段
}
}
这么做的好处是第一眼就能看出来:观察者想拿什么数据就拿什么数据,完全按需索取,不需要被观察者提前准备。数据模型可以慢慢演进,观察者的接口不会因为新需求的增加而被迫修改。在这种模式下,观察者接口的签名非常稳定,永远就是update(Subject)。
代价也很清楚:观察者的逻辑变得更重了,它需要知道从被观察者身上如何取到自己需要的数据。如果被观察者对象本身非常复杂,观察者还要去处理"我调这个方法会不会产生副作用"这种问题。而且如果数据获取的过程中被观察者的状态已经变了,可能拿到的时候数据已经不是事件发生时刻的那个状态了。
从适用面来看:
- 推模型适合观察者数量少、观察者关注的数据高度一致、数据字典相对稳定的场景。典型例子就是日常业务系统里的事件驱动,比如下单成功之后通知多个模块,大家关心的大差不差。
- 拉模型适合被观察者本身就是一个数据丰富的大对象,观察者关注点分散、数据需求随时可能扩展、系统的观察者数量在持续攀升的场景。典型的比如一个股票行情推送器,每个终端关注的股票不同、展示的数据维度不同,这种情况下拉模型明显更合理。
真实系统里其实可以两者结合。事件对象里放一个"核心数据字段"用来满足大多数观察者的常规需求,再放一个指向被观察者的引用,让有特殊需求的观察者可以自行拉取更多内容。这既保证了常规场景的简单性,又给特殊场景留了口子。
4. 手写一套不依赖JDK的观察者框架,推拉结合
既然JDK内置的不适合直接用于生产,那自己写一套也不是什么难事。我自己项目里的观察者基类迭代过好几个版本,最终沉淀下来的是一个相对成熟的方案,这里把核心代码和设计思路都分享出来。
设计目标有四个:
- 被观察者的类继承关系不被占用,用组合的方式持有观察者列表。
- 既支持推模型,也支持拉模型,由观察者自行选择拿到什么。
- 线程安全,观察者列表的增删和遍历不会出并发问题。
- 观察者的注册、取消、通知流程足够清晰,方便扩展。
先定义观察者接口。结合推拉模型的需求,接口设计成推拉结合的形式:
java复制import java.util.Map;
public interface Observer {
// 推模型:被观察者把完整数据推送给观察者
default void onEvent(SubjectSubject<?> subject, String eventType, Object data) {
// 默认空实现,观察者可以根据需要override
}
// 拉模型:观察者通过subject主动拉取数据
default void onEvent(SubjectSubject<?> subject, String eventType) {
// 默认空实现
}
}
再定义被观察者的抽象基类。这里最关键的选择是:用组合而不是继承,把观察者的注册和通知逻辑封装在一个内部管理类里面。这样业务类可以自由继承自己的业务基类,只需要持有一个SubjectSubject成员变量即可获得观察者的能力。
java复制import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class SubjectSubject<T> {
private final Map<String, CopyOnWriteArrayList<Observer>> observerMap = new ConcurrentHashMap<>();
private final T source;
public SubjectSubject(T source) {
this.source = source;
}
// 注册观察者
public void register(String eventType, Observer observer) {
observerMap.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>())
.add(observer);
}
// 取消注册
public void unregister(String eventType, Observer observer) {
CopyOnWriteArrayList<Observer> observers = observerMap.get(eventType);
if (observers != null) {
observers.remove(observer);
}
}
// 推模型通知
public void pushEvent(String eventType, Object data) {
CopyOnWriteArrayList<Observer> observers = observerMap.get(eventType);
if (observers == null) {
return;
}
for (Observer observer : observers) {
observer.onEvent(this, eventType, data);
}
}
// 拉模型通知
public void pullEvent(String eventType) {
CopyOnWriteArrayList<Observer> observers = observerMap.get(eventType);
if (observers == null) {
return;
}
for (Observer observer : observers) {
observer.onEvent(this, eventType);
}
}
public T getSource() {
return source;
}
}
这里有几个设计细节值得展开说。
第一,为什么用CopyOnWriteArrayList。观察者列表的读操作远多于写操作,每次通知都要遍历整个列表。CopyOnWriteArrayList在写操作时复制底层数组,读操作不加锁。这正好匹配观察者模式"注册少、通知多"的使用特点。我在压测的时候,一万个观察者并发注册和通知,CopyOnWriteArrayList的吞吐量比Vector高了一个量级。
第二,为什么引入eventType事件类型。很多观察者模式的基础教程只设计了单事件通道,任何通知都会发给所有观察者。这样会导致一个观察者接收到大量与它无关的事件,它每次都要在回调里做过滤判断。加一层事件类型之后,观察者可以选择订阅自己关心的事件,被观察者也可以精准通知。这是把观察者模式推向实用化的关键一步。
第三,为什么把source泛型化。观察者在拉模型下需要拿到被观察者来取数据,如果SubjectSubject不知道被观察者的具体类型,观察者拿到的就是一个Object,还得自己做强转,很容易出现ClassCastException。泛型化之后,观察者在编译期就能确定拉取数据的对象类型,安全性高很多。
基于这套基类,订单服务可以这样接入:
java复制public class OrderService {
private final SubjectSubject<OrderService> subject = new SubjectSubject<>(this);
public SubjectSubject<OrderService> getSubject() {
return subject;
}
public void createOrder(String orderId, String userId, double amount) {
System.out.println("创建订单:" + orderId);
// 订单创建完成,组装基础数据
Map<String, Object> orderData = Map.of(
"orderId", orderId,
"userId", userId,
"amount", amount
);
// 推模型通知
subject.pushEvent("ORDER_CREATED", orderData);
}
}
短信观察者可以选择用推模型:
java复制public class SmsObserver implements Observer {
@Override
public void onEvent(SubjectSubject<?> subject, String eventType, Object data) {
if (!"ORDER_CREATED".equals(eventType)) {
return;
}
Map<?, ?> orderData = (Map<?, ?>) data;
System.out.println("短信通知用户:" + orderData.get("userId") + ",订单号:" + orderData.get("orderId"));
}
}
积分观察者如果需要更详细的数据,可以用拉模型从源对象里取:
java复制public class PointsObserver implements Observer {
@Override
public void onEvent(SubjectSubject<?> subject, String eventType) {
if (!"ORDER_CREATED".equals(eventType)) {
return;
}
OrderService orderService = (OrderService) subject.getSource();
// 拉取更详细的数据
String orderId = orderService.getOrderId();
// 根据orderId查数据库,计算积分
System.out.println("给订单:" + orderId + " 对应的用户增加积分");
}
}
业务方法里面只需要注册:
java复制OrderService orderService = new OrderService();
orderService.getSubject().register("ORDER_CREATED", new SmsObserver());
orderService.getSubject().register("ORDER_CREATED", new PointsObserver());
orderService.createOrder("NO.20250601", "user_001", 199.9);
这套框架的优势在扩展的时候最能体现。新增观察者只需要实现Observer接口,然后在需要的地方注册,订单服务的核心代码一行都不用改。如果想要支持异步通知,只需要在pushEvent方法里把循环调用改成丢到线程池执行即可。
需要注意的是观察者回调里的异常处理。在推模型的核心循环里,如果某个观察者的onEvent方法抛了异常,后面的观察者就不会被调用了。所以在pushEvent和pullEvent的实现里,建议对每个观察者的调用做异常捕获,保证一个观察者出问题不影响其他观察者。
java复制for (Observer observer : observers) {
try {
observer.onEvent(this, eventType, data);
} catch (Exception e) {
// 记录日志,继续通知下一个观察者
System.err.println("观察者处理事件失败,eventType=" + eventType +
", observer=" + observer.getClass().getName());
}
}
这看起来是小事,但真实生产环境里,一个观察者如果连了外部服务,外部服务一旦超时或者挂了,如果异常不捕获,整个通知链路都会被拖垮。这也是观察者模式落到工程应用里最大的一个隐蔽坑点。
5. 把观察者模式的精髓迁移到多agent场景中
聊到多agent系统,这个概念最近被提得很多。基于LLM驱动的agent系统里,有一个"主从模式"的做法很常见:主agent负责拆解复杂任务、分派子任务,而subagent则专精于执行具体的子任务。热搜词里有一段话说得很形象:最新的多agent设计里,主从模式本质上就是把subagent当成另类的tool来调用。
这个思路和观察者模式简直是天作之合。主agent相当于被观察者,它产生一个任务变更事件;subagent作为观察者,订阅与自己相关的事件类型,然后各自去执行。但在实际的agent框架里,传统的主从模式一般是一个"同步的、阻塞的"调用关系:主agent调用了subagent,就必须等它返回结果。这种模型在遇到复杂的、并行可拆解的任务时效率很差,主agent被一个subagent阻塞在那里,其他的子任务排着队等。
从观察者模式的视角来重构多agent协作,可以做一个事件驱动的异步协作框架,思路是这样的:
- 主agent不直接调用任何一个subagent,它只负责发布任务事件。例如发布一个"用户需求已拆解"的事件,事件数据里包含所有子任务的描述。
- 每个subagent注册自己擅长的事件类型。比如数据分析agent注册"需要数据分析"事件,文案生成agent注册"需要文案生成"事件,图片处理agent注册"需要图片处理"事件。
- 主agent发布事件之后立刻返回,去做自己的事情,比如继续接收下一次用户输入。
- 专门的调度模块作为观察者协调器,负责接收事件、匹配闲着的subagent、把任务分发给它们。
- subagent执行完毕之后,把结果回传到结果总线,主agent通过拉模型按需获取自己需要的结果。
这个方案里的改动点就是:把"同步调用"变成"事件广播",把"紧耦合的对话链"变成"松耦合的事件流"。主agent不再需要知道有哪些subagent存在、它们的能力边界是什么。新增一个subagent,只需要注册到调度器,主agent的代码不用动。
还有一点相关的观察:现在的多agent系统比较推荐用大模型产生出JSON结构化的任务描述,然后再执行真正的调用。这个结构化任务描述,本质上就是在造一个标准的事件对象。事件对象设计得越规范,subagent之间的协作就越顺畅。比如事件对象里包含任务类型、任务参数、优先级、截止时间、返回地址这些字段。这跟观察者模式里推模型"封装数据"的思想如出一辙。
实践中还有一些值得注意的细节。事件驱动之后,主agent没法直接拿到subagent的执行结果了,所以你要设计一个返回结果的机制,比如用future绑定到事件ID上,或者用回调通知。结果与事件的生命周期关联非常紧密,我建议你给每一个事件都生成一个唯一的eventId,subagent执行完上报结果的时候带上这个ID。这样主agent也好、调度器也好,都能准确地把结果和事件对上号。
再往深一层说,这个场景里的事件驱动架构已经不是传统观察者模式那么字面的概念了,它慢慢演变出了消息队列、事件总线的味道。但是底层的骨架、思想却是相通的——发布者不依赖订阅者,订阅者按需接收和消费事件。如果你吃透了观察者模式,理解消息队列、事件溯源这类复杂架构会顺手很多。
6. 观察者模式的真实业务案例与实战避坑清单
学了原理、看了代码、还得看它在真实系统里怎么落地。我挑一个电商交易系统常见的例子来完整串一遍,这个例子几乎涵盖了观察者模式所有可能出现问题的场景。
假设你的交易系统需要一个订单状态机。订单有这些状态:待支付、已支付、已发货、已完成、已取消。每个状态变更之后,都有不同的下游系统需要感知到:
- 状态变为已支付:短信服务发支付成功通知,财务系统记账,积分系统发放积分,推荐系统记录推荐关系,风控系统校验后续操作。
- 状态变为已发货:物流服务生成物流单,短信服务发发货通知,库存服务扣减锁定库存,用户中心更新待收货数量。
- 状态变为已取消:财务系统走退款流程,库存服务释放锁定库存,优惠券服务返还优惠券,短信服务发取消通知。
如果不用观察者模式,这个订单状态机的代码会膨胀到什么程度?每增加一个状态流转,就要在所有涉及状态变更的方法里加调用。更可怕的是,一旦某个下游调用超时或者失败,整个订单状态流转都被拖住。用户在界面上点了一下支付,后端卡了三十秒才返回结果,这种体验基本等于劝退用户。
用观察者模式改造之后,订单状态机只负责两件事:更新状态、发布事件。下游的所有逻辑全部拆成独立的观察者,各自订阅自己关心的状态变更事件。
从这个案例里可以总结出几条实战经验,也是我个人踩过坑之后沉淀下来的东西。
第一,观察者回调里要做重试和降级。因为观察者模式把调用关系解耦了,被观察者无法感知下游是否处理成功。这就意味着观察者内部自己要有完善的容错能力。我的习惯是:发送短信这类弱依赖操作,失败就记录日志走人工补偿流程;财务记账这类强一致性的操作,失败之后要进入本地消息表,等到最终一致性机制来兜底。如果观察者处理任务时发现依赖的服务不可用,直接抛出异常让被观察者吞掉,也算是一种降级策略。
第二,观察者的执行顺序不要做出假设。用我上面那套基于CopyOnWriteArrayList的实现,观察者之间的顺序是注册顺序。但是真实项目里大家会做依赖注入、动态注册,顺序很容易变得不可控。所以观察者之间不要有隐性的依赖关系。比如某个观察者想依赖另一个观察者先执行完的逻辑,这个假设非常脆弱。如果真的有顺序要求,应该把前后两个操作合并成一个观察者内部的处理链路。
第三,事件类型要成体系地设计,不要随便起名字。我见过一个系统里事件名有叫orderCreate的、有叫order_created的、有叫createOrderEvent的,混在一起非常痛苦。建议用统一的命名规范,比如点分隔的领域事件名:order.created、order.paid、order.shipped。在观察者注册的时候也是同样的规范,这样能通过名称清晰地区分事件的领域归属。
第四,考虑线程模型。被观察者的通知方法在主线程里执行,那么所有观察者的逻辑都会阻塞主线程,这是观察者模式最常见的性能瓶颈。如果你对实时性要求不高,完全可以把通知方法丢进线程池执行。但要注意线程安全问题——观察者被并发调用的时候,它内部如果有共享状态,一定要做好同步。如果用Spring @Async注解来配置观察者的异步执行,这个方案最简单,但要注意线程池的配置参数,防止核心线程数太小,大量事件被排队处理导致堆积。
第五,死循环和事件风暴。如果观察者在处理事件的时候,又反过来触发了同一个被观察者的事件发布,而那个事件的观察者又回头触发当前事件,就会形成调用风暴,严重的时候直接撑爆内存。防范的方法是在事件对象里加一个最大重入次数,超过阈值就直接中断丢弃,或者用异步消息中间件从物理上隔离同步调用链。
下面把常见问题和解决方案整理成一张速查表,方便实际开发的时候对照:
| 常见问题 | 原因 | 解决方案 |
|---|---|---|
| 观察者抛出异常导致后续观察者不执行 | 核心通知循环没有异常隔离 | 逐个对观察者调用做try-catch,记录日志并继续 |
| 通知顺序与预期不符 | 对遍历顺序做了假设 | 将顺序强依赖合并到同一个观察者链路中 |
| 观察者执行太慢拖垮主流程 | 同步执行,可能依赖外部服务 | 改为线程池异步执行,或用消息队列解耦 |
| 事件回调里重复触发同类事件 | 事件驱动不当会产生递归调用 | 事件对象增加重入次数限制,超过阈值自动丢弃 |
| 被观察者需要继承父类时无法使用Observable | JDK的Observable是类不是接口 | 用组合方式持有观察者管理类,业务类正常继承自己的基类 |
| 多线程注册和通知时出现并发问题 | 使用线程不安全的集合 | 用CopyOnWriteArrayList或ConcurrentHashMap管理观察者列表 |
7. 观察者模式在几大主流框架里的实际面貌
如果去翻主流的开源框架源码,你会发现它们到处都在用观察者模式,只不过不是那么直白地叫"观察者模式"。
Spring框架里的事件机制是观察者模式最典型的一个工程化实现。ApplicationEvent相当于事件对象,ApplicationListener相当于观察者,ApplicationEventPublisher的publishEvent方法相当于被观察者的通知方法。用起来非常顺手,而且Spring帮你处理了同步异步、异常隔离这些细节。但是在实际项目里用Spring事件系统,也需要先弄明白几个底层细节。
Spring发布事件默认是同步执行的。也就是说,publishEvent方法会阻塞到所有监听器执行完毕才返回。如果你的监听器里面有耗时的操作,整个调用链的RT(响应时间)就会拉长。想变成异步执行,最简单的方案是给监听器加上@Async注解,同时需要在配置类上开启@EnableAsync。但异步之后,主线程就感知不到监听器的执行结果了,如果监听器内部有异常,默认情况下主线程这边是拿不到的。这种"异步+异常丢失"的组合在一个项目里出现过线上事故,事后排查发现监听器里的数据库操作因为连接池满了抛异常,但调用方浑然不知,数据就这么静默丢失了。
另一个非常有代表性的框架事件体系是Guava的EventBus。它是一个纯内存的发布订阅组件,用法比Spring事件更轻。而EventBus有一个非常特殊的设计——死事件队列。如果一个事件的类型没有任何订阅者,EventBus会再发一个DeadEvent通知到订阅了死事件的对象。这个设计在实际使用中是非常好的排障手段。我习惯在项目里注册一个死事件监听器,把没人处理的Event打印成日志,方便及时发现"是不是有地方发布了事件却忘记注册监听器"。
RabbitMQ、Kafka、RocketMQ这些消息中间件,本质上就是把"进程内观察者模式"升级为"进程间观察者模式"。但有一个重大区别:进程内的观察者模式不支持消息的持久化和回溯消费,消息一旦发出去,如果当时没有观察者在线,消息就丢了。而消息队列会把消息按主题存储起来,消费者什么时候来都能消费到。因此,如果业务上的事件需要做到"不丢失、可追溯",建议采用消息队列,而不是进程内的观察者模式。
从学习设计模式的角度来说,理解Spring事件和EventBus的实现思路,比直接照抄JDK自带的Observable要更有价值。因为你在学习的时候,不知不觉就能掌握生产级代码是如何处理"异常隔离、线程模型、异步策略"这些工程问题的。这也是学习设计模式的正确姿势:不是背定义、画类图,而是看在真实的框架里它是怎么被用起来的。
拿我上面手写的那套观察者框架来说,它其实就是从Spring事件和Guava的EventBus之间取了一个平衡。没有Spring那么重,但是比EventBus更贴合业务;没有JDK类库那么简陋,但是从零开始实现让你真正理解每一个设计背后的原因。建议大家拿到这套代码之后,自己动手改成异步版本、加上死事件日志、接上指标监控,每改一步都会对观察者有更深入的理解。
