观察者模式:加一种通知渠道就要回来改老代码?这回真不用了

发布时间:2026/10/16 22:04:08

观察者模式:加一种通知渠道就要回来改老代码?这回真不用了 作者鱼宵 「Java 后端实战精通营」实战驱动系列・第 6 篇完整可运行工程已开源https://gitee.com/j67mk2/design-patterns一、真实场景老板说加个微信通知你又要动那个老方法第 1 篇给外卖订单烂代码做体检时我标过 6 处坏味道第 4 处就是它// 第 1 篇坏味道 4发通知的 if 连锁if(order.needSms){发短信}if(order.needEmail){发邮件}if(order.needAppPush){App推送}当时我在结尾预告过第 6 课用观察者模式把这条 if 连锁拆掉。这篇就来兑现。先重温一下这个痛点有多真实。老板说加个微信通知吧你在通知那一节又补一个if (order.needWechat)第二周要给数据平台埋个点你又打开同一个方法再补一段。问题出在哪订单服务认识每一个通知渠道——发短信、发推送、埋点每加一个动作下单这个核心方法就要回来动一次。你改埋点的时候手抖碰坏短信分支线上就炸。观察者模式干的事一句话让订单服务只管喊一声订单成了谁关心这件事、谁自己来登记订单服务一个都不认识。代码全在design-patterns仓库的lesson-06工程里clone 下来跑一遍加微信通知、订单服务零改动这个结论你自己就能验证。二、核心原理公众号你天天关注背后就是这个模式2.1 观察者模式四要素观察者模式Observer Pattern定义对象间一对多的依赖——一个对象状态变了所有依赖它的对象自动收到通知。发布者不知道订阅者是谁订阅者自己来登记。类比就是微信公众号你点关注订阅公众号发文章时自动推给你——你不用天天跑去问更新了吗。公众号也不认识你是谁它只管发。对照本课的外卖订单四个角色是这样分工的角色本课对应类干什么主题 / 事件总线 SubjectEventBus维护订阅者名单提供订阅和广播观察者 ObserverOrderEventListener 接口 三个实现登记过的通知通道事件来了各自处理事件对象 EventOrderEvent只装数据订单号、金额不装行为发布者 PublisherOrderService订单落库后喊一嗓子不认识任何监听器关键解耦点OrderService里没有任何要不要发短信的 if只有一句eventBus.publish(...)。短信、邮件、推送是三个互相不认识的独立类自己向总线登记。2.2 为什么这就叫开闭了回到第 1 篇讲的开闭原则对扩展开放、对修改关闭。以前加微信通知改OrderService.processOrder()加一个 if。动老代码 冒着碰坏短信分支的风险。现在加微信通知新写一个WechatListener类在 Main 里subscribe一下。订单服务一行不碰。扩展走的是加新代码动订单服务的次数归零——加渠道 加监听器 登记发布端零改动。2.3 两个高频区别面试常考观察者 vs 发布-订阅观察者模式发布-订阅模式通信方式主题直接调用观察者的方法中间隔一个 Broker / MQ两边互不知道进程同一个 JVM 内同步直连跨进程、跨服务通常异步本课对应手写 EventBusRocketMQ / Kafka概念层一句话观察者是同一个屋子里我喊一嗓子在场的都听见发布-订阅是我把信扔进邮筒邮筒负责送给各个收件人。手写 EventBus 是观察者RocketMQ 是发布-订阅。JDK 自带的 Observable 呢JDK 早期给过一个java.util.Observable类和Observer接口就是观察者模式但早就废弃了——破坏封装、没有事件对象、线程模型混乱。知道它存在就行新项目别用。三、关键代码8 个类看懂 EventBus 就全懂了本课工程在lesson-06/src/main/java/com/dp/lesson06/先贴最小的两个——事件对象和观察者接口packagecom.dp.lesson06;/** * 下单成功事件事件对象只装数据不装行为。 * 事件要瘦别把整个 Order 连数据库连接一起塞过去耦合又回来了。 */publicclassOrderEvent{/** 订单号 */privatefinalStringorderId;/** 实付金额元 */privatefinaldoubleamount;publicOrderEvent(StringorderId,doubleamount){this.orderIdorderId;this.amountamount;}publicStringgetOrderId(){returnorderId;}publicdoublegetAmount(){returnamount;}}packagecom.dp.lesson06;/** * 订单事件监听器观察者接口每个关心下单成功的模块实现它。 */publicinterfaceOrderEventListener{/** 事件来了自己处理自己的事处理完就走互不干扰 */voidonEvent(OrderEventevent);/** 监听器名字演示打印用让大家看到谁收到了事件 */Stringname();}然后是主角——事件总线整个模式的骨架就 40 行packagecom.dp.lesson06;importjava.util.List;importjava.util.concurrent.CopyOnWriteArrayList;/** * 简易事件总线主题角色就干三件事——订阅、取关、广播。 */publicclassEventBus{/** 订阅者名单所有关心下单成功的模块都登记在这里 */privatefinalListOrderEventListenerlistenersnewCopyOnWriteArrayList();/** 关注把监听器加进名单 */publicvoidsubscribe(OrderEventListenerlistener){listeners.add(listener);System.out.println( [EventBus] 新订阅者listener.name());}/** 取关把监听器移出名单 */publicvoidunsubscribe(OrderEventListenerlistener){listeners.remove(listener);System.out.println( [EventBus] 取消订阅listener.name());}/** 发布事件遍历所有订阅者挨个回调。订单服务只调这一行。 */publicvoidpublish(OrderEventevent){System.out.println( [EventBus] 发布事件订单 event.getOrderId()依次通知 listeners.size() 个订阅者);for(OrderEventListenerlistener:listeners){// 同步挨个调用故意不开线程保证输出顺序固定可复现listener.onEvent(event);}}}这段代码讲了什么subscribe存名单publish遍历名单挨个调onEvent。注意publish里没有任何 if——登记过的都通知没登记的自然不在名单里。为什么用CopyOnWriteArrayList而不是普通ArrayList遍历通知时如果有别的线程在注册/取关普通 List 会炸ConcurrentModificationException这个类就是防这个的。三个通知通道是平级实现几乎同款合起来贴给你packagecom.dp.lesson06;/** 短信监听器订单成功后发短信观察者的具体实现之一 */publicclassSmsListenerimplementsOrderEventListener{OverridepublicvoidonEvent(OrderEventevent){System.out.println(String.format( [短信] 给用户发短信订单 %s 已受理实付 %.2f 元,event.getOrderId(),event.getAmount()));}OverridepublicStringname(){return短信通道;}}packagecom.dp.lesson06;/** 邮件监听器和短信监听器平级互不知道对方存在 */publicclassEmailListenerimplementsOrderEventListener{OverridepublicvoidonEvent(OrderEventevent){System.out.println(String.format( [邮件] 给用户发邮件订单 %s 的详情已生成实付 %.2f 元,event.getOrderId(),event.getAmount()));}OverridepublicStringname(){return邮件通道;}}packagecom.dp.lesson06;/** App 推送监听器订单成功后推通知 */publicclassAppPushListenerimplementsOrderEventListener{OverridepublicvoidonEvent(OrderEventevent){System.out.println(String.format( [App推送] 推送通知订单 %s 有新进展,event.getOrderId()));}OverridepublicStringname(){returnApp推送通道;}}最后是重构后的订单服务——跟第 1 篇的坏代码对比着看packagecom.dp.lesson06;/** * 订单服务发布者落库后发布一个事件不认识任何通知渠道。 */publicclassOrderService{/** 依赖的是总线这个抽象不是任何具体通知渠道 */privatefinalEventBuseventBus;publicOrderService(EventBuseventBus){this.eventBuseventBus;}publicvoidcreateOrder(StringorderId,doubleamount){// 1. 校验和第 1 课一样if(orderIdnull||orderId.isEmpty()){thrownewIllegalArgumentException(订单号不能为空);}// 2. 落库模拟只打印System.out.println(String.format([订单服务] 订单 %s 已落库金额 %.2f 元,orderId,amount));// 3. 发布事件没有任何发短信发邮件的 if关心的模块自己来听eventBus.publish(newOrderEvent(orderId,amount));System.out.println([订单服务] 订单 orderId 创建完成);}}对比第 1 篇坏代码原来下单后面跟着if (needSms) / if (needEmail) / if (needAppPush)三个 if现在只剩一行publish。Main 入口很简单——先订阅三个通道再下两单packagecom.dp.lesson06;publicclassMain{publicstaticvoidmain(String[]args){System.out.println( 第 6 课观察者模式——下单成功自动通知 );// 1. 总线是公众号三个通道是粉丝先关注好EventBusbusnewEventBus();bus.subscribe(newSmsListener());bus.subscribe(newEmailListener());bus.subscribe(newAppPushListener());System.out.println();// 2. 订单服务只依赖总线不认识任何通知渠道OrderServiceorderServicenewOrderService(bus);// 3. 下两单每单发布一次事件三个监听器各自响应System.out.println(-- 创建订单 A3001 --);orderService.createOrder(A3001,130.00);System.out.println();System.out.println(-- 创建订单 A3002 --);orderService.createOrder(A3002,70.40);System.out.println();System.out.println( 结束加微信通知新增一个监听器并 subscribe订单服务零改动 );}}四、完整演示 实测输出clone 下来后在lesson-06目录执行Windows PowerShellcd C:\你的clone路径\design-patterns-journey\lesson-06$env:JAVA_HOMEC:\jdk\jdk-17.0.12mvn clean install-DskipTests实测输出[INFO] BUILD SUCCESS [INFO] Total time: 1.214 s编译通过后运行中文乱码就先执行[Console]::OutputEncoding[System.Text.Encoding]::GetEncoding(936)C:\jdk\jdk-17.0.12\bin\java.exe-cp.\target\classescom.dp.lesson06.Main实测输出 第 6 课观察者模式——下单成功自动通知 [EventBus] 新订阅者短信通道 [EventBus] 新订阅者邮件通道 [EventBus] 新订阅者App推送通道 -- 创建订单 A3001 -- [订单服务] 订单 A3001 已落库金额 130.00 元 [EventBus] 发布事件订单 A3001依次通知 3 个订阅者 [短信] 给用户发短信订单 A3001 已受理实付 130.00 元 [邮件] 给用户发邮件订单 A3001 的详情已生成实付 130.00 元 [App推送] 推送通知订单 A3001 有新进展 [订单服务] 订单 A3001 创建完成 -- 创建订单 A3002 -- [订单服务] 订单 A3002 已落库金额 70.40 元 [EventBus] 发布事件订单 A3002依次通知 3 个订阅者 [短信] 给用户发短信订单 A3002 已受理实付 70.40 元 [邮件] 给用户发邮件订单 A3002 的详情已生成实付 70.40 元 [App推送] 推送通知订单 A3002 有新进展 [订单服务] 订单 A3002 创建完成 结束加微信通知新增一个监听器并 subscribe订单服务零改动 输出解读开头三行新订阅者就是三个监听器向总线登记。每个订单里订单服务只打印了已落库和创建完成中间那段通知是 EventBus 广播出来的——它全程不知道短信、邮件、推送的存在。五、挑战题改参数会怎样答案在源码里⭐ 在 Main 里把短信监听器存成变量SmsListener sms new SmsListener();先bus.subscribe(sms)下完一单后再bus.unsubscribe(sms)接着下一单——短信那行还打不打控制台会多出一行什么打印⭐⭐ 在EmailListener.onEvent第一行加一句throw new RuntimeException(邮件服务炸了)重跑——短信和 App 推送还打不打主线程序会以什么结束想明白了再给EventBus.publish的 for 循环里每个监听器调用包一层 try-catch修复后再跑输出变成什么样⭐⭐ 把EventBus.publish里的同步调用改成裸线程new Thread(() - listener.onEvent(event)).start()再跑一遍——三路通知的到达顺序还固定吗为什么生产不建议这么裸开线程三题答案都在lesson-06源码里clone 下来改一行就能验证。跑出来的结果和你想的不一样评论区贴出来一起查。六、生产进阶手写 EventBus 讲原理Spring 事件总线才是饭碗我们手写这个 EventBus 是为了看清骨架。生产代码里没人这么写——Spring 早就把这套东西做成现成的了// 发布端业务服务注入 ApplicationEventPublisherComponentpublicclassOrderService{AutowiredprivateApplicationEventPublisherpublisher;publicvoidcreateOrder(StringorderId,doubleamount){// 落库...publisher.publishEvent(newOrderCreatedEvent(orderId,amount));}}// 订阅端方法参数类型 关心的事件类型ComponentpublicclassSmsListener{EventListenerAsync// 异步不阻塞下单主流程TransactionalEventListener(phaseTransactionPhase.AFTER_COMMIT)publicvoidonSms(OrderCreatedEventevent){// 发短信}}三个加分点面试答出来就是真用过和背过的区别为什么要AFTER_COMMIT下单是个带事务的操作。事务还没提交就发事件监听器去查库可能查不到这条订单更糟的是用户下单后又退款了短信已经发出去了。AFTER_COMMIT保证数据库落定了再通知。异常隔离手写 EventBus 同步遍历第 2 个监听器炸了第 3 个直接收不到挑战题 2 让你亲手踩。Spring 可以配ErrorHandler把每个监听器隔离开。源码层面就一句话Spring 的SimpleApplicationEventMulticaster.multicastEvent()就是遍历所有监听器挨个调用——和你手写的for (l : listeners) l.onEvent(e)骨架完全一样有线程池就扔异步。还有一个容易被忽略的坑监听器不注销会内存泄漏。事件总线是长生命周期的单例它持有监听器的强引用页面、请求级别的短命监听器用完不unsubscribeGC 永远回收不掉它。生产里要么用 Spring 管监听器生命周期和容器一致要么用完主动取关。选型一句话单机内解耦用 Spring 事件总线跨服务、要削峰、要持久化上 RocketMQ / Kafka。七、面试回答模板Q1观察者模式怎么实现发布者不知道订阅者是谁一句话结论主题维护一份订阅者名单发布者只往主题里扔事件主题遍历名单挨个回调——发布者从头到尾没见过任何订阅者。展开四角色是主题、观察者、事件对象、发布者事件对象要瘦只传必要数据。本课lesson-06的 EventBus 就是手写骨架。Q2观察者模式和发布-订阅是一回事吗一句话结论不是——观察者同进程同步直连发布-订阅中间隔 Broker 跨进程异步。展开手写 EventBus 是观察者RocketMQ/Kafka 是发布-订阅前者解耦模块协作后者解耦服务通信。Q3EventListener和TransactionalEventListener有什么区别一句话结论前者事件一发布就触发后者可以指定在事务的哪个阶段触发AFTER_COMMIT最常用。展开事务没提交就发事件监听器查库查不到、退款了短信已发AFTER_COMMIT就是防这个。Q4为什么教学手写 EventBus生产却用 Spring 事件总线一句话结论手写是为了看清注册 遍历广播的骨架生产要的事务阶段控制、异常隔离、异步执行器手写版都没有。展开Spring 把这三样全给了所以生产直接用注解单机解耦用事件总线跨服务上 MQ。八、总结表坑原因解法加一种通知渠道要回来改订单服务发布端认识每个渠道耦合死观察者加监听器 订阅发布端零改动一个监听器抛异常后面全收不到同步遍历没做异常隔离监听器内部自己 catch或配 Spring ErrorHandler遍历时注册/取关炸 ConcurrentModificationException普通 List 一边遍历一边改订阅表用 CopyOnWriteArrayList事件对象塞太胖耦合又带回来连数据库连接一起传等于没解耦事件只放订单号、金额这种必要数据事务没提交就发事件监听器查不到订单或退款了短信已发TransactionalEventListener(AFTER_COMMIT)监听器用完不注销长生命周期总线持有强引用GC 回收不掉主动 unsubscribe或交给 Spring 容器管生命周期九、关于这个系列本文是「Java 后端实战精通营」设计模式系列第 6 篇。第 1 篇给外卖订单烂代码体检时预告的那一刀今天落了地通知 if 连锁被观察者模式拆掉订单服务从此不认识短信邮件推送。完整可运行工程https://gitee.com/j67mk2/design-patterns本课源码在lesson-06/主类com.dp.lesson06.Main下一篇预告《责任链模式——一个请求过五道关卡谁都不攥着流程不放》。把请求沿着一条链逐级传递、每一环决定自己处理还是传给下一环写成模式手写一个审批链组长 → 经理 → 总监金额超阈值就往上抛再落到 Spring MVC 的HandlerInterceptor和 MyBatis 插件机制——这是面试你怎么处理层层递进的请求的标准答案。互动提问你项目里有没有加一种触发动作就要回来改老主流程的场景下单发通知、操作日志、数据埋点评论区聊聊我帮你看看能不能用事件总线拆开。本文部分内容由 AI 辅助生成
延伸阅读

更多相关文章

2026/10/16 21:59:07

ONVIF实战入门:30行Python代码调通摄像头

1. 这不是ONVIF协议文档翻译,而是一份能让你当天就调通摄像头的实操手记ONVIF——1——从零到可以编程。这个标题里藏着三个关键信号:ONVIF是核心对象,1代表极简起点(不是“第一课”,而是“第一步就落地”)…

2026/10/16 21:59:07

传输网管入门:从TMN到E300,掌握SDH网管核心知识与实操

简介:这是一份面向传输网管初学者的入门参考,内容基于中兴通讯传输网管开发部内部培训PPT整理,系统讲解电信管理网(TMN)基础、SDH网管概述、信产部关于EMS的技术规范,以及E300网管系统实例。资源为单个PPT演…

2026/10/16 21:59:07

C# NModbus 操作实战:从串口 RTU 到 TCP 主站与寄存器解析

简介:面向工业自动化上位机开发人员,这份资源围绕C#环境下NModbus库的应用展开,帮助解决PLC、仪表等设备基于Modbus协议的数据读写与从站模拟问题。资料对Modbus RTU、Modbus ASCII与Modbus TCP三种变体进行了说明,重点解释了RTU与…

2026/10/16 23:34:27

Spec系统:构建能力单一真相源,解决需求不一致

1. 为什么“能力”需要一个单一真相源做技术管理或者系统架构的人,大概都经历过这样的场景:产品经理拿着一份需求文档说“这个功能要支持批量导入”,后端开发说“接口文档里写的是单条提交”,前端说“我看到的原型图上只有一个上传…

2026/10/16 23:34:27

Audacity 音频处理实战:降噪、均衡与批量脚本全流程

简介:Audacity资源下载面向音频编辑初学者、播客制作者与音乐爱好者,提供一款免费、跨平台的开源音频处理工具,可用于录音、剪辑、混音及降噪等日常创作场景。压缩包共1488个文件,约31.16MB,以910个png界面截图、394个…

2026/10/16 23:34:27

从Excel到Pandas:百万级数据分析的实战入门与性能优化

上周帮一个零售团队处理他们的销售流水,一份两百多兆的Excel文件,打开就卡,等了三分钟才看到那一百多万行数据。列一多、表一宽,Excel直接进入半瘫痪状态。我当时的处理方案很简单:把数据丢给Pandas,用Pyth…

2026/10/16 23:34:27

最短时间搞定论文参考文献加注:工具实操指南

写论文这件事,大家普遍卡在最后一步:参考文献。内容写得差不多了,导师看完正文说“引用格式不对”“缺了几篇文献”“这个期刊要求作者名全拼”,你才意识到光是参考文献就能改一个通宵。我见过不少朋友因为参考文献排版把自己熬到…

2026/10/16 23:34:27

JasperReports 7.0.0实战:从模板编译到PDF导出的完整流程

简介:作为一套开源报表工具库,JasperReports 7.0.0 完整项目源码包,面向 Java 后端开发与报表技术研究者,用于解决 Java 应用中复杂报表的生成、样式设计与多格式导出问题,特别适合需要在 Spring 等主流框架中集成报表…

2026/10/16 23:29:24

2027 年好用的 CDN 怎么选?按场景推荐(含维科云 Veikor CDN)

2027 年好用的 CDN 怎么选?按场景推荐(含维科云 Veikor CDN) 一句话结论: 没有「全球最好」的 CDN,只有「最匹配备案状态、用户地区、是否要高防、预算」的组合。国内已备案优先 阿里云 / 腾讯云 / 华为云;…

2026/10/16 5:33:43

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/16 0:01:11

Cephadm实战指南:Linux系统工程师的生产级Ceph集群部署与排障

简介:本资源是一份完整的Ceph分布式存储系统中文技术文档合集,面向Linux系统运维工程师、云平台架构师及存储方向开发者,帮助读者系统掌握Ceph集群的部署、配置与运维核心能力。文档覆盖对象存储(RGW)、块设备&#xf…

2026/10/16 0:01:11

ITK中SpatialObject几何体系解析:坐标系、变换与管道集成

1. 空间对象的定位:为什么 ITK 需要一套几何描述体系很多刚开始接触 ITK 的朋友,包括我自己,最初都是被它的图像滤波、配准、分割这些“正统”功能吸引进来的。等真正上手做点实际项目,比如把分割出来的器官边界做量化分析、给血管…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑