设计模式 31:谁管理订阅和失败
订单付款后要通知日志与开票两个本地回调。第一个回调抛错时第二个还需收到事件;开票回调又可能在处理 paid 时发布 invoiced。如果直接遍历订阅列表,回调失败会阻断后续通知,重入时顺序还可能让人意外。同步事件的顺序与失败到底由谁规定?
订阅表不等于交付保证
Before.publish 直接遍历监听器;在第一个回调抛 IllegalStateException 的反例中,发布调用抛错,后面的回调不会执行。After 维护订阅列表与一个待处理事件队列:事件按发布顺序入队,正在派发时的重入事件留到当前事件的所有订阅者处理完再派发;每轮取订阅快照,监听器抛出的 RuntimeException 被记录到 failures(),其他监听器仍被调用。测试轨迹是 first:paid, second:paid, first:invoiced, second:invoiced。退订后再次发布不会给被退订者送达。
1 | |
这是本章 Observer 的通知关系:发布者不需要知道订阅者具体处理什么;但错误策略、顺序、重入与退订语义不能仅靠“订阅”两个字得到。Alternative 在固定两位调用者时直接顺序调用,最容易理解;若订阅方不会变化,维护通用注册表不是必需的。与 Mediator 的集中协调多方交互不同,Observer 在这里是发布一个事实、由多个本地回调响应;与责任链不同,它不因前一位成功就停止后续派发。当前 failures() 累积错误但不自动重试,发布返回并不代表异步或持久化交付成功。若回调无限重发同类事件,队列也可能持续增长。
| 方案 | 两位正常监听者 | 第一位失败 / 重入 |
|---|---|---|
Before |
按注册顺序调用 | 抛错后停止;无重入策略 |
After |
按注册顺序调用 | 记录错误继续;重入排到本轮之后 |
Alternative |
两次直接调用 | 顺序明确,但错误和变化由调用者自己决定 |
没有测试并发注册、跨进程消息、重复消费、队列持久化或无限重入阻断。
flowchart LR
Publisher[发布方] --> Bus[After.publish]
Bus --> Pending[pending 队列]
Pending --> Snapshot[listeners 快照]
Snapshot --> Listener[Consumer.accept]
Listener -.重入 publish 入队.-> Bus
Listener -.失败记录.-> Failures[failures]
验证与练习
在 examples/design-patterns/ 执行 ./mvnw -B -ntp -pl labs/31 -am test、./mvnw -B -ntp verify;原始版本、输入、退出码与输出见 examples/design-patterns/evidence/31/RUN.md。正常订阅是旧合同,异常继续与重入顺序是新增断言。
- 新增“某订阅者处理一次后自动退订”,先写原有两个监听者的回归和退订/重入的新轨迹,再确定当前事件快照是否仍应收到一次通知。
- 把订阅者固定为两个普通函数,删去事件中心并运行旧合同;说明何时动态订阅才值得恢复 Observer。
参考资料
- GoF 原书公开图书馆 PDF,5.7 Observer,目录页标注起始页 326:https://cpcc.chd.gov.in/Content/PDFs/w8UkV3tWNyEtsbUZSWJ7fVhuB9A3tsGYdm4w6VGzg2wUTNFYikqnvvFbbkiW2zmfspPEghd7QTamiMby3lVIBemrhdVWwt6rOQnm.pdf 。
- Java SE 21
PropertyChangeSupportJavadoc:https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/beans/PropertyChangeSupport.html 。 - Vlissides/Schmidt 公开课件
An Introduction to Design Patterns:https://www.dre.vanderbilt.edu/~schmidt/PDF/GoF.pdf 。 - 本篇来源边界:
writing-plans/design-patterns/SOURCES.md。 - 实验代码:
examples/design-patterns/labs/31/。
上一节:30 请求对象能撤销什么;下一节:32 审核何时停止传递。






