ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

观察者模式实战:从硬编码通知到Spring事件解耦

观察者模式实战:从硬编码通知到Spring事件解耦 观察者模式真正的价值不在于面试里回答一句“对象间一对多依赖”而在于当你的业务代码被一次次“加通知”加成一团乱麻时它能不能帮你把变化重新收敛起来。我在一次消息推送系统改造里亲历过一个订单状态变更方法后面挂了十几个 if 分支和零零散散的方法调用每次需求变更都要在核心类里小心翼翼地再插一脚。后来把这段逻辑抽成观察者新增一种通知只是追加一个订阅者老代码一行都不用动。这种变化比看十遍定义都深刻。这篇文章我会用 Java 的视角来拆观察者模式包括问题背景、角色分工、JDK 与 Spring 事件等实现方式以及真实业务里特别容易踩的内存泄漏和回调顺序坑。新手可以跟着代码跑一遍老手可以直接跳到第四章看事故复盘。1. 观察者模式到底在解决什么问题一段疯涨的“通知代码”复盘1.1 轮询、硬编码通知与越长越长的 if 分支很多设计模式教程上来就甩定义但我始终觉得观察者模式这种“看起来太简单”的模式不结合具体场景根本看不出价值。我见过太多团队把它背下来应付面试代码里却一直用最硬编码的方式处理状态通知。假设我们有一个订单服务最初只有两件事改状态、保存。用户打开订单页前端轮询接口看到新状态。轮询的缺点很快暴露拉取周期中间的延迟摆在那里用户量上来以后大量请求其实什么都没拉到。于是产品要求状态一变化就主动通知用户、刷新商家后台、发短信给买家。初级项目会直接把通知逻辑塞进核心方法public void changeOrderStatus(Order order, OrderStatus newStatus) { order.setStatus(newStatus); orderRepository.save(order); userNotifier.notify(order.getUserId(), 订单已更新为 newStatus); dashboardService.refresh(order.getStoreId()); smsService.send(order.getUserName(), 您的订单状态发生变化); reportService.update(order); }第一次加还好第二次加第三个状态这个方法的行数就失控了。等业务再复杂一点还会出现“哪些通知组合发送”的判断于是核心业务类里全是 if 嵌套和调用列表。这段代码有几个致命的问题新增通知必须改核心方法核心类慢慢知道所有业务模块的细节。通知执行顺序被写死多个通知之间天然有了隐式耦合。测试困难单测订单服务的时候还得模拟一堆无关模块。最关键的是你永远不知道再给这段方法加一个通知会碰断哪个老功能。1.2 观察者模式为什么能拆掉这堆胶水代码观察者模式解决的就是这种“一个状态变化周围一堆东西要跟着动”的耦合问题。我习惯把它理解成“插座与插头”订单状态类是墙上的插座面板它只需要提供一个插孔订阅方法任何新设备来了自己插上插座面板不需要知道设备是什么。状态变化时面板广播事件所有插着的设备各自干活。用这种方式核心业务代码从“主动打电话给每个模块”变成“发布一条广播”。新增通知时核心代码不动只增一个新监听器。这个差异在需求频繁变动的项目里就是天壤之别一个是每次都要心惊胆战地改核心类一个是写个新类再挂上去。这里也有一个经常被误解的点观察者模式不是用来“提速”的。从 CPU 角度看直接调用比通过观察者分发更快毕竟多了循环遍历、方法分派还可能夹带同步控制和异常捕捉。它的价值不是降低单次请求延迟而是降低系统后续的变更成本。我见过一个团队把观察者模式当性能优化方案引入支付回调结果事件分发器成了新热点方向完全错了。性能优化应该用流水线或异步队列而不是这种一对多广播。同样观察者模式也解决不了“业务要 100% 事务一致”的问题。如果在事务提交前发布事件一个观察者失败可能把主流程一起带崩如果在事务提交后再发布观察者读到新状态时可能已经晚于并发操作。这个时机问题很关键后面踩坑部分会详细展开。2. 核心角色与接口设计别把 Subject 写成上帝对象2.1 三个角色的职责边界观察者模式有三个天然的角色Subject被观察者/主题持有并维护观察者列表状态变化时负责广播事件。它要做的事其实很窄订阅、退订、通知仅此而已。Observer观察者/监听器实现统一的回调接口每个具体监听器自己决定收到事件后干什么。Event事件对象描述刚才发生了什么。通常是一个不可变的数据载荷包含变化来源、变化键值和时间等信息。我在 code review 里最常见的毛病是有人把 Subject 当成“大管家”所有业务规则、状态判断、通知条件全塞进去。Observable 本来只应该解决通知链路它不需要知道每个观察者内部在干嘛更不应该代替它们做业务判断。业务判断应该在服务层完成状态变化后调用 Subject 的广播方法就够了。同样的毛病也发生在事件设计上。很多初学实现的监听接口是void update(String message)然后消息里塞一串拼好的字符串。表面看挺灵活实际上监听器做不到类型安全解析字符串本身就是新耦合。我倾向于每个业务场景定义一个事件类哪怕字段多几个可读性和扩展性都比“万能字符串”好得多。2.2 接口定义的三个关键选择第一个选择是回调方法命名。JDK 老接口叫update(Observable o, Object arg)语义含糊后来的框架更多用onEvent、handle或accept。只要团队统一哪个都行但我推荐onEvent看到名字就知道是“事件来了”。第二个选择是事件参数要不要带整个 Subject 引用。不推荐。观察者拿到 Subject 后很容易反向修改业务状态引发第二次广播形成循环通知。如果确实需要当前对象信息把所需字段复制进 Event 对象而不是把整个大对象引用丢出去。第三个选择是函数式接口与自定义接口的取舍。Java 8 之后直接使用ConsumerT就能当观察者回调subject.subscribe(event - System.out.println(收到事件 event));简单场景没问题但一个监听器如果包含多个方法相比如 onBefore、onAfter或者需要上下文清理自定义接口更清晰。我的建议是观察者列表规模小、逻辑简单时用函数式业务回调复杂时改专用接口。下面是三个角色的骨架public interface EventListenerE { void onEvent(E event); } public class SimpleSubjectE { private final ListEventListenerE listeners new CopyOnWriteArrayList(); public void subscribe(EventListenerE listener) { listeners.add(listener); } public void unsubscribe(EventListenerE listener) { listeners.remove(listener); } public void publish(E event) { for (EventListenerE listener : listeners) { listener.onEvent(event); } } }这个版本已经能跑通基础流程但注意publish里目前没有任何异常处理。这不是小问题后面踩坑部分会专门提在同步观察者链路里异常处理几乎是第一批要补的东西。3. Java 实现观察者模式的三种常见形态JDK、手写、Spring 事件3.1 JDK 自带方案Observer 与 Observable 为什么该被放弃Java 很早就内置了观察者模式支持java.util.Observer接口和java.util.Observable类。用起来很简单继承Observable调用setChanged()标记状态变化然后notifyObservers(arg)触发所有 Observer 的update方法。但它在 Java 9 开始被标记为 deprecated官方明确建议不要在新代码中使用。原因从接口设计就能看出来Observable是一个类Java 是单继承你的业务类如果已经继承了别的类就无法再继承它这是最致命的硬伤。接口没有泛型Observer.update(Observable o, Object arg)里的 arg 是 Object不同模块传参全靠强转类型安全为零。不提供结构化的事件对象只有(Observable, Object)二元组事件表达力很弱。没有同步控制策略多线程场景需要自己额外处理。底层用 Vector 存观察者虽然是线程安全列表但性能早已落后于现代集合。所以我在新项目里不会考虑 JDK 自带方案它适合当学习源码的标本不适合生产环境。3.2 手写实现从“能跑”到“能在生产环境跑”不引入第三方框架时手写一个类似SimpleSubject的类完全够用。但“够用”要补几个工程细节否则生产环境会出幺蛾子。第一点是列表选择。骨架用了CopyOnWriteArrayList为什么不用ArrayList因为遍历可能持续一段时间期间另一个线程执行subscribe或unsubscribe会抛出ConcurrentModificationException。CopyOnWriteArrayList在每次修改时复制底层数组遍历用的都是快照读多写少的场景非常适合观察者广播。代价是写入成本高而观察者注册和退订相对低频这个代价完全可接受。第二点是异常隔离。事件分发循环里如果某个监听器抛出RuntimeException默认会中断整个遍历异常冒泡到发布方主流程。很多业务事故就是这么来的支付成功回调里一个发短信的监听器空指针异常导致订单状态更新流程整体回滚。所以生产代码里几乎总要在循环里做异常捕获记录错误日志后继续分发public void publish(E event) { for (EventListenerE listener : listeners) { try { listener.onEvent(event); } catch (Exception ex) { log.error(事件 {} 分发到监听器时异常, event, ex); } } }至于要不要吞掉异常取决于系统对“通知可靠性”的要求。某个观察者如果必须“尽力而为”捕获后继续没问题如果某个观察者失败会影响主流程那它就不该做成同步观察者应该放进事务性消息或异步任务里。第三点是执行顺序。基础的遍历按注册顺序依次调用但这不应该是业务依赖的依据。线程调度、异步线程池、容器排序都可能影响最终顺序。如果有强顺序依赖比如“先做风控检查再发短信”建议把这种依赖放进同一个观察者内部不要期待两个观察者之间维持神秘顺序。3.3 Spring 事件实际项目里更常见的“观察者增强版”Spring 框架提供了ApplicationEvent和ApplicationEventPublisher对外是事件发布器内部用观察者模式维护监听器。使用流程很简单注入发布器调用publishEvent在监听方法上加EventListener注解Service public class OrderService { private final ApplicationEventPublisher publisher; public void changeStatus(Long orderId, OrderStatus newStatus) { orderRepository.updateStatus(orderId, newStatus); publisher.publishEvent(new OrderStatusChangedEvent(orderId, newStatus)); } } Component public class SmsListener { EventListener public void onOrderStatusChanged(OrderStatusChangedEvent event) { smsClient.send(event.getOrderId(), event.getNewStatus()); } }Spring 把“发布者只知道发布事件不知道谁在听”这件事做到了极致业务模块之间没有直接依赖新增监听器也无需改已有代码。它还提供了TransactionalEventListener(phase AFTER_COMMIT)可以在事务提交后才触发事件避免“观察者读到还没提交的数据”这种尴尬。它的代价是接受“隐式调用”读代码时只看到publishEvent看不到具体执行逻辑必须通过事件名去全局搜索监听器。搜索工具对这种隐式调用还算友好但和显式调用相比仍是认知负担。要不要用 Spring 事件我的判断标准是如果模块间本来就有清晰调用关系用普通方法调用更直观如果要解耦业务域订单域到消息域用事件更合理。4. 踩坑实录内存泄漏、异常污染与“幽灵通知”4.1 事件消息泄漏注册了却忘了销毁观察者模式最经典的一类 bug 是“注册后不解除”。它的危害不只是内存泄漏更麻烦的是“幽灵通知”一个已经离开页面、离开业务流程的对象依然在接收事件并执行不该执行的动作。举个移动端的例子。Android 页面在onCreate里注册了一个全局订单监听器用来刷新页面上的订单状态。如果onDestroy里没有unsubscribe页面对象就会被事件分发器一直持有。用户反复进入离开页面实例越积越多最终内存溢出。就算没溢出下一次订单状态变化时所有残留页面都会触发刷新界面出现明显卡顿和重复请求。修复思路有几种。最直接的是手动unsubscribe在生命周期销毁点统一解绑。第二种是用弱引用但弱引用带来不确定性GC 时机不好预测不建议作为唯一手段。第三种是借助生命周期感知组件比如 Android 里把注册动作和Lifecycle绑定销毁时自动清理。服务端也有对应场景。长连接网关、缓存组件、线程池任务里注册了静态单例监听器如果不清理监听器会越积越多。所以凡是订阅一定要考虑“什么时候退订”。我 reviewing 代码时看到subscribe第一个问题永远是对应的unsubscribe在哪里4.2 异常污染一个观察者抛异常后面全趴窝前面提到过JDK 自带的notifyObservers和很多入门教程的手写实现都没有对分发循环做异常保护。一旦某个观察者抛出RuntimeException循环立刻中断后面的观察者收不到通知异常还会冒泡到发布方。我经历过一次线上事故订单支付成功后订单服务调用发布器监听器里有发短信、发邮件、更新统计报表三个观察者。某个观察者对订单号解析时碰到一条特殊历史数据的空指针异常一路冒到支付回调入口导致那条订单实际支付成功但本地状态回滚成“未支付”。用户下单页面一直显示待支付银行却扣了款。复盘之后问题不只是空指针本身更是“观察者异常不应该阻断主流程”的架构原则没落地。解决方法双向发布方在分发循环里捕获并记录异常不中断后续监听器观察者内部也尽量自洽对边界情况做好校验不把空数据当正常业务继续。不过这里有一条非常重要的边界如果监听器和主流程强相关比如“更新订单状态”和“发送支付凭证”必须严格绑定那么捕获异常会掩盖业务失败。这种情况应该把事件放入消息队列用可靠投递加重试来保证而不是在同步观察者里做尽力而为。4.3 顺序依赖与循环通知观察者模式里观察者之间默认没有顺序约束。手写实现按注册顺序执行Spring 事件也有自己的排序规则但这都是实现细节不是契约。如果两个观察者间真的有先后依赖正确做法是合并成一个观察者或者给事件对象增加阶段字段让一个观察者内部依次处理。循环通知则是另一个隐蔽问题。观察者 A 收到事件后修改了 Subject 状态这个修改再次触发广播另一个观察者 B 又改状态又一次广播。如果没有终止条件事件风暴就会出现。常见场景是“订单状态变更引起库存变更库存变更又触发订单状态重算”。规避循环通知工程上可以做三件事事件对象只保留必要数据不让观察者随手拿到目标对象引用。在回调开头判断“当前业务状态是否已满足条件”不满足直接返回。在发布器内部对同一次状态变更增加深度计数或事件去重超过阈值告警并停止分发。至少前两种应该做因为从根源减少“回头路”比事后中断更可靠。4.4 “模式用多了会变成蜘蛛网”观察者模式的核心是解耦但解耦不是免费的。事件一多项目里到处都是publishEvent和EventListener新人读代码根本不知道一次操作会触发多少隐藏逻辑。调试时只能靠打断点去观察遍历代码找不到全部调用链。我自己的经验是有意识地限制使用范围只对“一个状态变化需要通知不确定多方”的场景使用而不是把所有方法调用都改成事件。如果两个模块之间存在清晰的一对一调用关系直接调用比事件更好读、更好调试。架构设计的核心不是多用模式而是在合适的地方用合适的模式观察者模式同样逃不过这个原则。5. 观察者模式和发布订阅的关系一字之差分工完全不同5.1 观察者模式目标对象直接拉观察者的手传统观察者模式里Subject 直接维护 Observer 列表发布事件相当于遍历列表逐个调用。两者之间有明确的依赖Observer 知道 Subject 有订阅接口Subject 知道 Observer 实现了回调。这种结构适合进程内、同步、轻量通知实现简单出问题时也能快速定位谁在听。5.2 发布订阅中间人 Channel 接手发布订阅模式在观察者和发布者之间插入了一个“频道”或“事件总线”。发布方只把事件放进频道订阅方向频道注册双方互不见面。它允许跨线程、跨进程通过消息中间件还能支持分布式。Spring 事件实际上已经很接近发布订阅因为发布方只依赖ApplicationEventPublisher完全不知道具体监听器是谁但它仍在同一 JVM 内分发可靠性依赖 Spring 容器管理。跨服务的订单状态同步就是典型的发布订阅场景订单服务把“订单已支付”事件写进消息队列库存服务、积分服务、物流服务各自订阅。订单服务不需要知道这些服务存不存在也不需要关心它们是否处理成功重试和补偿由消息队列的投递机制负责。这种可靠性保障观察者模式本身给不了。5.3 选型的时候怎么判断下面这张表格是我做技术选型时常用的对照维度观察者模式发布订阅模式通信方式目标对象直接回调观察者通过中间频道或事件总线间接通信发布方与订阅方订阅方知道目标对象互不可见线程模型默认同步可同步、可异步常搭配消息队列可靠性无内置重试与补偿可加持久化、重试、死信适合场景进程内轻量通知跨模块、跨线程、跨服务解耦调试成本相对可读隐式调用需搜索事件名一句话总结观察者是“我知道你是谁我通知你”发布订阅是“我把消息放到柜台上谁要谁拿”。业务里如果只是几个模块间解耦观察者模式够用如果要做异步解耦、削峰填谷、跨服务通信直接上消息队列或事件总线。6. 拿订单状态通知练手从硬编码改成观察者的完整步骤6.1 原始痛点与重构目标假设原始代码是订单支付成功后需要发送邮件、发送短信、更新商家后台、记录审计日志。当前版本直接把这一串调用写在changeStatus里。现在新需求来了支付成功后还要触发风控积分检查。按原有方式改就是在核心方法里再加一行。如果后面再来一个优惠券核销、再来一个物流比价这个方法就完蛋了。重构目标定得很清楚changeStatus方法里不再出现具体通知模块所有“支付成功后的反应”通过观察者追加。6.2 定义事件与发布器先定义事件对象。注意它是不可变的只携带和这次变化相关的数据public record OrderStatusEvent( Long orderId, OrderStatus oldStatus, OrderStatus newStatus, Instant occurredAt ) {}Java 16 的 record 很适合做事件对象天然不可变。如果项目还在用老版本 Java就老老实实写 private final 字段加构造函数和 getter。6.3 实现监听器并注册定义两个监听器一个发短信一个写审计日志public class SmsOnOrderStatusChanged implements EventListenerOrderStatusEvent { private final SmsClient smsClient; public SmsOnOrderStatusChanged(SmsClient smsClient) { this.smsClient smsClient; } Override public void onEvent(OrderStatusEvent event) { smsClient.send(event.orderId(), event.newStatus()); } } public class AuditLogOnOrderStatusChanged implements EventListenerOrderStatusEvent { private static final Logger log LoggerFactory.getLogger(AuditLogOnOrderStatusChanged.class); Override public void onEvent(OrderStatusEvent event) { log.info(订单状态变更审计: orderId{}, old{}, new{}, event.orderId(), event.oldStatus(), event.newStatus()); } }在应用装配处注册EventPublisherOrderStatusEvent publisher new EventPublisher(); publisher.subscribe(new SmsOnOrderStatusChanged(smsClient)); publisher.subscribe(new AuditLogOnOrderStatusChanged());现在OrderService.changeStatus只需要三件事改状态、存库、发布事件public void changeStatus(Long orderId, OrderStatus newStatus) { Order order orderRepository.findById(orderId); OrderStatus oldStatus order.getStatus(); order.setStatus(newStatus); orderRepository.save(order); publisher.publish(new OrderStatusEvent(orderId, oldStatus, newStatus, Instant.now())); }有人会觉得这多了一道“中间商”代码文件也多了。但换来的是接下来每新增一个“支付后要做的事”都只需要新增一个监听器类并注册订单服务完全封闭。这才是观察者模式最核心的收益把变化从核心行为中分离出去让扩展发生在旁路。6.4 测试与收尾建议单测至少要覆盖三块发布器遍历逻辑、订单服务状态变更与事件发布、每个监听器自己的业务。监听器自身的业务测试不要只断言“事件对象不为空”一定要针对具体状态去验证效果。集成测试建议把事件缓冲到内存缓冲区模拟真实发布路径然后批量断言。如果用了异步分发记得加条件等待不要固定 sleep否则很容易出现“测试偶尔过、偶尔不过”的情况。如果用 Spring重构就变成publishEvent加EventListener订单服务代码更干净但要注意监听类是否被 Spring 扫描到还要注意泛型事件在父子类继承时的分发差异。这些都容易在项目里埋下隐性 bug测试时建议补一个“发布子类事件时父类监听器是否被调用”的用例。最后分享一条我判断是否使用观察者模式的简单标准如果这段业务以后会频繁新增“状态变化后的动作”硬编码通知迟早会失控观察者模式是刚需如果这类通知很固定三五年也不会变那直接调用反而更简洁。观察者模式给你的是扩展的自由同时也把隐式调用的成本转移给了后来的维护者到底怎么选取决于你更怕哪一种成本。
返回列表