多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

设计模式 16 · 观察者模式

设计模式 16 · 观察者模式 上一篇模板方法讲的是一个流程怎么分步走,这一篇的**观察者模式(Observer)**换个话题:一个对象的状态变了,怎么自动通知一大群关心它的对象?它是行为型里最重要、应用最广的模式之一,你熟悉的发布-订阅“事件驱动”“监听器”,本质上都是它。它的核心场景一句话就能说清:一变多知——一个对象(被观察者)发生变化时,所有依赖它、订阅了它的对象(观察者)都能自动收到通知并做出反应,而被观察者不需要知道这些观察者具体是谁、有多少个。这就像你关注了一个公众号,它发文章时会自动推送给所有关注者,而它并不需要挨个记住每个粉丝是谁——它只管发布,谁订阅了谁就收到。我们的订单场景里有一个再典型不过的例子:订单支付成功后的后续动作。一笔订单支付成功,往往要触发一连串互不相干的事:给用户加积分、发送短信通知、通知物流开始备货、更新销量统计……这些动作会随业务不断增减(明天可能又要加个发优惠券“推送 App 消息”)。如果把它们全塞进支付成功的方法里,那个方法会越来越臃肿、和一堆下游系统死死耦合。观察者模式,就是让支付成功这个事件优雅地广播出去,各个下游各自订阅、各自响应。这篇文章按这条线索展开:先看把后续动作硬编码在一起的困境;再引出观察者模式如何用订阅-通知解耦;然后讲清它的角色、以及推模型 vs 拉模型这个关键设计选择;接着看它在 Spring 事件、GUI 监听器里的身影;最后给出适用边界与注意事项。贯穿例是订单支付成功的通知。目录把后续动作硬编码在一起的困境观察者模式:订阅与通知角色,与推模型 vs 拉模型现实身影:Spring 事件与监听器什么时候用,以及几个注意事项一、把后续动作硬编码在一起的困境看订单支付成功后要做的事。最直觉的写法,是在支付成功的方法里,把所有后续动作一件件调过去:publicclassOrderService{publicvoidpaySuccess(Orderorder){// 支付成功后,挨个通知下游pointService.addPoints(order);// 加积分smsService.send(order);// 发短信logisticsService.prepare(order);// 通知物流备货statService.updateSales(order);// 更新销量统计// 明天要加发优惠券?继续往这加...}}这段代码能跑,但毛病和前面几篇的一坨如出一辙:紧耦合:OrderService被迫认识积分、短信、物流、统计所有下游系统。它本该只关心订单支付这件事,现在却要知道一大堆和它无关的下游。违反开闭原则:每加一个后续动作(发优惠券、推送 App),就得回来撬开paySuccess方法加一行。这个方法会越来越长,变成一个牵一发动全身的雷区。职责混乱:处理支付和支付后做什么两件事被搅在一起。而且这些下游动作大多是可以异步、可以失败重试的,硬编码在一起后,一个下游卡住可能拖垮整个支付流程。问题的根源是:“事件的产生”(支付成功了)和事件的响应(该做哪些后续),被硬编码耦合在了一起。产生事件的一方,本不该关心谁对这个事件感兴趣、要做什么响应。我们真正想要的是:支付成功时,OrderService只管喊一嗓子支付成功了!,至于谁听到了、听到后干什么,由那些关心的对象自己去订阅、自己去处理。这就是观察者模式。二、观察者模式:订阅与通知观察者模式的做法:定义一个观察者接口(所有想响应事件的对象都实现它);被观察者内部维护一个观察者列表,提供订阅/取消订阅的方法;当自身状态变化时,遍历列表、逐个通知。第一步,定义观察者接口:// 观察者:所有想响应支付成功的下游都实现它publicinterfaceOrderObserver{voidonPaySuccess(Orderorder);}第二步,每个下游动作是一个具体观察者:publicclassPointObserverimplementsOrderObserver{publicvoidonPaySuccess(Orderorder){/* 加积分 */}}publicclassSmsObserverimplementsOrderObserver{publicvoidonPaySuccess(Orderorder){/* 发短信 */}}publicclassLogisticsObserverimplementsOrderObserver{publicvoidonPaySuccess(Orderorder){/* 通知物流 */}}第三步,被观察者(主题)维护观察者列表,状态变化时广播:publicclassOrderSubject{// 观察者列表privatefinalListOrderObserverobserversnewArrayList();publicvoidsubscribe(OrderObservero){observers.add(o);}// 订阅publicvoidunsubscribe(OrderObservero){observers.remove(o);}// 取消订阅// 状态变化:支付成功,广播给所有观察者publicvoidpaySuccess(Orderorder){// ... 处理支付本身for(OrderObservero:observers){o.onPaySuccess(order);// 逐个通知,不关心它们具体是谁}}}用起来,下游各自订阅,OrderSubject对它们一无所知:OrderSubjectsubjectnewOrderSubject();subject.subscribe(newPointObserver());// 积分订阅subject.subscribe(newSmsObserver());// 短信订阅subject.subscribe(newLogisticsObserver());// 物流订阅subject.paySuccess(order);// 一喊,三个观察者全部自动响应对比第一节,升级点非常清晰:OrderSubject再也不认识积分、短信、物流这些具体下游了,它只认识OrderObserver接口;要加发优惠券,新建一个CouponObserver订阅进去就行,OrderSubject一个字都不用改——完美符合开闭原则。事件的产生方和响应方彻底解耦了。用一张图看这个一变多知的广播结构最清楚:图里最该记住的,是那条从主题到观察者的单向广播箭头,以及主题只依赖OrderObserver接口这个抽象。被观察者持有的是一个观察者接口的列表,而不是任何具体观察者——这正是解耦的关键,和前面组合、策略里持有抽象的思路一脉相承。谁想响应,谁就实现接口、订阅进来;主题只管广播,不管谁在听。三、角色,与推模型 vs 拉模型观察者模式的角色,四个:角色本例中是谁职责抽象主题(Subject)OrderSubject(可抽象成接口)维护观察者列表,提供订阅/通知方法具体主题同上的实现状态变化时通知所有观察者抽象观察者(Observer)OrderObserver接口定义响应事件的方法具体观察者PointObserver等实现具体的响应逻辑结构讲完,有一个重要的设计选择必须讲清楚——通知时,主题该给观察者传多少数据?这就是推模型和拉模型的区别:推模型(Push):主题在通知时,主动把数据推给观察者。就像上面代码里onPaySuccess(Order order),主题直接把整个order塞给观察者。观察者拿到就能用,简单直接。缺点是:主题得预设观察者需要什么数据,如果不同观察者需要的数据不同,要么传一大坨(浪费),要么众口难调。拉模型(Pull):主题通知时只告诉观察者我变了,不传具体数据;观察者收到通知后,自己主动去拉它需要的那部分数据。比如onEvent(OrderSubject subject),把主题自己传过去,观察者按需调subject.getXxx()取数据。灵活,各观察者取各自需要的,但观察者要多做一步拉取,且和主题的耦合稍紧一点。// 推模型:主题主动推数据voidonPaySuccess(Orderorder);// 拿到就是全部数据// 拉模型:主题只通知,观察者自己拉voidonEvent(OrderSubjectsubject);// 需要什么,自己 subject.getXxx()怎么选?推模型适合所有观察者需要的数据基本一致、且不多的场景,简单高效;拉模型适合不同观察者需要的数据差异大、或数据量大的场景,更灵活。实际项目里,推模型用得更多(直接传一个封装好的事件对象),因为它省事;但要注意别把主题的通知数据设计得太胖。两者没有绝对优劣,看数据的分布来定。四、现实身影:Spring 事件与监听器观察者是应用最广的模式之一,尤其在事件驱动的场景里:Spring 的事件机制(ApplicationEvent ApplicationListener):这是观察者在框架里的工业级实现。你发布一个事件applicationContext.publishEvent(new OrderPaidEvent(order)),所有EventListener标注的方法(观察者)会自动收到并处理。发布者和监听者完全解耦——这正是把订单支付通知观察者化的标准做法:支付成功后publishEvent,积分、短信、物流各写一个EventListener监听,新增响应只加监听器、不动发布方。而且 Spring 事件还能配Async做异步,天然解决了第一节说的下游卡住拖垮主流程的问题。GUI 事件监听器:所有图形界面的按钮点击监听“鼠标事件监听”(如 Swing 的addActionListener、前端的addEventListener)都是观察者——你给按钮注册一个监听器(订阅),按钮被点击(状态变化)时通知所有监听器。消息队列的发布-订阅:Kafka、RabbitMQ 的 pub-sub 模型,是观察者思想在分布式层面的放大——生产者发消息到 topic,所有订阅该 topic 的消费者都收到。JDK 的java.util.Observer/Observable:JDK 自带的观察者实现(不过已在 Java 9 被标记废弃,因为设计较简陋,现在更推荐用 Spring 事件或自己实现)。一个识别信号:凡是一个动作发生后,要触发一串互不相干的、且会增减的后续响应,观察者(事件驱动)就是标准解法。五、什么时候用,以及几个注意事项适合用观察者的信号:一个对象的状态变化,需要自动通知一批其他对象,且这批对象会动态增减;你想让事件的产生方和响应方解耦,产生方不该知道有哪些响应方;存在天然的一对多依赖关系(一变,多个跟着变)。不必用的信号:就一个固定的后续动作,永远不会增减——那直接调用更清晰,套观察者是过度设计;响应方之间有严格的先后依赖、或需要拿到彼此的结果——观察者的各观察者是平行独立的,这种有依赖的编排更适合别的方式(比如责任链、或直接编排)。几个容易踩的坑,务必注意:通知顺序不保证:观察者被通知的顺序,通常就是它们订阅的顺序,但你不该依赖这个顺序。如果多个响应之间有严格先后要求,观察者模式不合适——那是下一篇责任链或直接编排的活。异常传播:如果用同步通知(像第二节的for循环),某个观察者抛了异常,会中断后面观察者的通知,甚至影响主题本身。生产环境要么捕获每个观察者的异常(一个失败不影响其他),要么用异步。内存泄漏:观察者订阅后如果忘了取消订阅,主题会一直持有它的引用,导致它无法被回收——这是观察者模式一个经典的内存泄漏点(尤其在长生命周期的主题 短生命周期的观察者场景)。记得在合适的时机unsubscribe。判断的核心还是那句话:先确认真的存在一对多、且会动态增减的通知关系,观察者才值得上;而且要想清楚同步还是异步、异常怎么处理。小结。观察者模式解决一变多知的问题:被观察者维护一个观察者列表,状态变化时逐个通知,而它只依赖观察者接口、不认识具体观察者——从而把事件的产生和事件的响应彻底解耦,新增响应只需订阅、不改动发布方。它有推模型(主题推数据,简单)和拉模型(观察者拉数据,灵活)两种数据传递方式。Spring 的事件机制、GUI 监听器、消息队列的 pub-sub,都是它的身影,是事件驱动架构的基石。用它时要注意通知顺序、异常传播和内存泄漏三个坑。下一篇我们讲责任链模式——如果说观察者是一个事件广播给所有人、各自独立处理,那责任链是一个请求沿着一条链传递,由链上的节点依次处理、或拦截,典型如下单前的一串风控校验。
返回列表