作者:鱼宵 | 「Java 后端实战精通营」设计模式系列・第 9 篇
一、真实场景:第 1 篇那份烂代码,今天该动手术了
第 1 篇开篇,我贴过一段外卖订单代码:一个processOrder方法把校验、算优惠、分类型、发通知、记审计5 件事全塞在里头,当时一口气数出 6 处毛病——魔法数字、魔法字符串、一个方法干 5 件事、加需求就改老代码、直接 new、字段 public 裸奔加静默失败。
那篇结尾我放了三句狠话,今天回头看一句都没收:
“第 9 课把 5 件事拆成责任链。”
“一个方法干 5 件事 → 第 9 课责任链组合实战。”
“你现在看到的这份外卖烂代码,就是后面 9 篇的手术对象。”
前 8 课我们一篇磨一把手术刀:第 3 课工厂收拾魔法数字、第 5 课策略干掉优惠 if-else、第 6 课观察者拆掉通知 if 连锁、第 7 课责任链拆校验、第 8 课代理包横切逻辑。每把刀都单独练过,却从没在同一个病人身上一起用过。
今天第 9 课,该把那份烂代码推上手术台——不是再来一个新模式,而是把前面练过的 4 个模式拼成一条能跑的订单全生命周期链路:谁管顺序、谁管环节、谁管算法、谁管广播,一刀一刀切给你看。
代码全在design-patterns仓库的lesson-09里,clone 跑一遍,那 5 件事怎么拆开又怎么拼回去,控制台当场演给你看。
二、核心原理:4 个模式在一条链上,各管哪一段
2.1 为什么不能"一个模式打天下"
真实业务里,一个订单从进来到成交,要校验、算钱、扣库存、发通知、记审计——这一串活,你想用单个模式全包圆?没戏。因为这几件事变化的方式根本不一样:
- 校验、算优惠、扣库存这几步,顺序固定但环节可能增删→ 责任链最合适;
- "怎么算优惠"有满减、折扣、不打折好几种,要能互换 → 策略最合适;
- "先校验、再算钱、再扣库存、最后通知"这个大顺序不能乱→ 模板方法把骨架焊死;
- 通知有短信、邮件、归档,加渠道不该动主流程→ 观察者广播出去。
类比一条食品加工流水线。模板方法是整体布局(先清洗→切割→包装→质检,顺序焊死);责任链是每个工位可插拔(加个杀菌工位就多一段);策略是切割工位内部怎么切,切块还是切丝随便换;观察者是包装完广播一声"货好了",仓库物流客服各自去忙。四个模式各管一段,谁也不抢谁的活。
面试一句话先背下来:模板方法管"先后",责任链管"环节",策略管"算法",观察者管"广播"。
2.2 分工表(先背这张,再看代码)
| 模式 | 在本课链路里管什么 | 它应对的"变化" | 对应类 |
|---|---|---|---|
| 模板方法 | 钉死订单处理的宏观时序骨架 | 流程顺序不变,细节子类填 | AbstractOrderProcessTemplate |
| 责任链 | 校验/算优惠/扣库存/审计串成可插拔的链 | 环节能增删、能调序、能中途叫停 | OrderHandler+ 4 个环节类 |
| 策略 | 算优惠的算法族(满减/折扣/无) | 优惠算法能互换 | DiscountStrategy+ 3 个实现 |
| 观察者 | 下单成功后广播给短信/邮件/归档 | 通知渠道能加减,主流程无感 | OrderEventBus+ 3 个 Listener |
2.3 模板方法和责任链,会不会重复?(最容易绕晕的点)
有人会嘀咕:模板方法process()排好序了,责任链里不又排一遍?不重复——模板方法是宏观编排,一个订单走哪 4 步、什么顺序,final process()焊死子类不许改;责任链是第 2 步"处理环节"的可插拔实现,链里几个环节、谁先谁后链自己说了算。说白了模板方法持有一条责任链,两层各管各的。
2.4 两个"审计"为什么是两个类?
本课还有两个带"审计"的类,千万别因为名字像就合并:ProcessingAuditHandler在责任链里同步记"每步走了啥",是流程日志,排障用;ArchiveAuditListener在订单成交后写对账风控账本,是事后归档。触发时机和用途都不同,所以拆开——这就是按变化理由拆分。
三、关键代码:从导演到观众,四个模式各上各的岗
本课工程在lesson-09/src/main/java/com/dp/lesson09/,一共 23 个类但每个都很短。我按"链路从外到内"贴核心骨架,其余类 clone 下来一眼就能看完。
先看责任链的抽象节点——next指针和"怎么往下传"全钉在这:
packagecom.dp.lesson09;/** * 责任链的抽象节点。每个订单处理环节都是它的一个子类。 */publicabstractclassOrderHandler{/** 链上下一个环节,null 表示链尾 */protectedOrderHandlernext;/** 把下一个环节接在自己后面,并返回它,方便 .setNext().setNext() 一路点下去 */publicOrderHandlersetNext(OrderHandlernext){this.next=next;returnnext;}/** 链入口:final 钉死"怎么传",子类别想改 */publicfinalvoidhandle(OrderContextctx){if(ctx.isStopped()){return;// 已被叫停:本环节直接放行,啥也不干}doHandle(ctx);// 本环节自己干活if(next!=null&&!ctx.isStopped()){next.handle(ctx);// 没被叫停才传给下一个}}/** 子类实现:本环节具体干啥 */protectedabstractvoiddoHandle(OrderContextctx);}这段讲的是"接力棒":ctx是订单随身带的办事单,每个环节干完要么传给next,要么ctx.stop(原因)叫停整条链。叫停后后面每个节点进来先看到stopped=true,直接走人——不用抛异常,也不用写"后面还有几个节点"。
再看模板方法——整条链的"导演",final process()把 4 步焊死:
packagecom.dp.lesson09;/** * 订单处理模板(模板方法模式核心):不管哪种订单,处理顺序固定为 * 前置检查 → 跑责任链 → 发布成功事件 → 收尾。顺序钉死在 final 方法里。 */publicabstractclassAbstractOrderProcessTemplate{protectedfinalOrderEventBuseventBus;// 事件总线,构造时注入protectedAbstractOrderProcessTemplate(OrderEventBuseventBus){this.eventBus=eventBus;}/** 模板方法:final 钉死,子类不许改流程顺序 */publicfinalvoidprocess(Orderorder){OrderContextctx=newOrderContext(order);beforeProcess(order,ctx);// 第1步:子类钩子(预售校数量/团购校人数)if(ctx.isStopped()){// 前置就被拦:不跑后面System.out.println("[中止] "+order.getOrderId()+" 被前置检查拦下:"+ctx.getStopReason());return;}OrderHandlerchain=buildChain();// 第2步:组装责任链并跑chain.handle(ctx);if(ctx.isStopped()){// 链路里被拦:不发事件、不算成交System.out.println("[中止] "+order.getOrderId()+" 在链路中被拦下:"+ctx.getStopReason());return;}eventBus.publish(newOrderEvent(order.getOrderId(),order.getType(),order.getFinalAmount()));// 第3步:观察者广播System.out.println("[完成] 订单 "+order.getOrderId()+" 全链路结束,实付 "+String.format("%.2f",order.getFinalAmount())+" 元");}/** 组装责任链:校验 → 算优惠 → 扣库存 → 链路审计。final 钉死顺序 */protectedfinalOrderHandlerbuildChain(){OrderHandlerhead=newValidationHandler();// 先用变量接住链头!head.setNext(newDiscountHandler(discountStrategy())).setNext(newStockHandler(stockMode())).setNext(newProcessingAuditHandler());returnhead;}protectedabstractDiscountStrategydiscountStrategy();// 抽象方法:本类订单用哪个优惠策略protectedabstractStringstockMode();// 抽象方法:什么扣库存模式protectedvoidbeforeProcess(Orderorder,OrderContextctx){}// 钩子:默认空,需要才重写}这里有个本课真踩过的坑:setNext()返回的是"下一个节点",new A().setNext(B).setNext(C)整条表达式最后求值是 C(链尾)。你图省事直接return new A().setNext(B)...,拿到的 chain 直接指向链尾,前面校验、算优惠、库存全部静默不执行,还不报错——第一版我就这么翻过车,负数金额订单都"成交"了。正确姿势:先用变量head接住链头,一路接完再return head。
责任链的"算优惠"环节自己不懂怎么算,它持一个策略——这就是策略嵌进链里的接缝:
packagecom.dp.lesson09;/** 折扣策略接口:每一种优惠算法就是一个实现类,想换算法就换类 */publicinterfaceDiscountStrategy{doublediscount(Orderorder);// 算优惠后实付Stringname();// 中文名,打印日志用}packagecom.dp.lesson09;/** 满减策略:满 100 减 20。规则要改(比如满 200 减 50)只动这一个文件 */publicclassFullReductionStrategyimplementsDiscountStrategy{privatestaticfinaldoubleTHRESHOLD=100.0;// 满减门槛privatestaticfinaldoubleREDUCTION=20.0;// 减多少@Overridepublicdoublediscount(Orderorder){doubleoriginal=order.getOriginalAmount();// 原价 = 单价 × 数量if(original<THRESHOLD){returnoriginal;// 没到门槛不减,绝不减成负数}returnoriginal-REDUCTION;}@OverridepublicStringname(){return"满100减20";}}另外两个实现PercentDiscountStrategy(0.8)(八折)和NoDiscountStrategy(原价返回,空对象)不到 40 行。加新券 = 新增一个实现类,主流程一行不改——第 1 篇那个"满减".equals(couponType)的 if-else 连锁,到这彻底拆掉。
观察者那侧,事件总线的心脏就两行:
packagecom.dp.lesson09;importjava.util.ArrayList;importjava.util.List;/** 手写事件总线:一个 List 存订阅者,publish 时 for 循环挨个叫 */publicclassOrderEventBus{privatefinalList<OrderEventListener>listeners=newArrayList<>();/** 订阅:把监听器登记进列表 */publicvoidsubscribe(OrderEventListenerlistener){listeners.add(listener);System.out.println("[EventBus] 监听器已注册:"+listener.listenerName());}/** 发布事件:挨个通知所有订阅者,主流程发完就不管了 */publicvoidpublish(OrderEventevent){System.out.println("[EventBus] 发布下单成功事件:订单 "+event.getOrderId());// 复制一份再遍历,防止某个监听器回调时又去改原列表(并发修改异常)for(OrderEventListenerlistener:newArrayList<>(listeners)){listener.onOrderSuccess(event);}}}所谓"事件总线",拆开就是个订阅者列表 + for 循环。短信、邮件、归档三个监听器各自实现onOrderSuccess,主流程publish完根本不关心挂了几个——第 1 篇那三个if (needSms)、if (needEmail)、if (needAppPush),到这全没了。
最后是入口Main,看四个模式怎么被拼起来:
packagecom.dp.lesson09;publicclassMain{publicstaticvoidmain(String[]args){System.out.println("========== 第 9 课:模式组合实战 —— 订单全生命周期链路 ==========");// 先造一个事件总线,把三个监听器注册上去。主流程后面只管 publishOrderEventBusbus=newOrderEventBus();bus.subscribe(newSmsOrderListener());// 短信bus.subscribe(newEmailOrderListener());// 邮件bus.subscribe(newArchiveAuditListener());// 成交归档// 场景一:三种订单,各用各的模板子类,跑完整链路AbstractOrderProcessTemplatenormal=newNormalOrderProcessTemplate(bus);normal.process(newOrder("A2001",OrderType.NORMAL,50.0,3,"满减"));// 50×3=150,满减20AbstractOrderProcessTemplatepresale=newPresaleOrderProcessTemplate(bus);presale.process(newOrder("A2002",OrderType.PRESALE,88.0,1,"折扣"));// 88×1,八折AbstractOrderProcessTemplategroup=newGroupBuyOrderProcessTemplate(bus);group.process(newOrder("A2003",OrderType.GROUP,120.0,5,"无"));// 120×5=600,不叠券// 场景二:两种被拦下的异常单,看钩子和责任链怎么早停newPresaleOrderProcessTemplate(bus).process(newOrder("A2004",OrderType.PRESALE,88.0,0,"折扣"));// 预售数量0newNormalOrderProcessTemplate(bus).process(newOrder("A2005",OrderType.NORMAL,-10.0,1,"满减"));// 金额-10// 场景三:新总线只注册短信,主流程一行不改,通知渠道自动变少OrderEventBusleanBus=newOrderEventBus();leanBus.subscribe(newSmsOrderListener());newNormalOrderProcessTemplate(leanBus).process(newOrder("A2006",OrderType.NORMAL,50.0,2,"满减"));}}调用方全程只跟"抽象模板"打交道,三种订单各自决定策略和库存模式,共用一个总线。换优惠算法、加减通知渠道、插一个环节,分别只改一处——这就是"组合而不是堆砌"。
四、完整演示 + 实测输出
clone 下来后,在lesson-09目录执行(Windows + PowerShell):
cd C:\Users\haoji\Desktop\课程\design-patterns-journey\lesson-09$env:JAVA_HOME="C:\jdk\jdk-17.0.12"mvn clean install-DskipTests实测输出(编译段):
[INFO] BUILD SUCCESS [INFO] Total time: 1.729 s编译通过后运行(PATH 上的 java 可能是 JDK8,报 UnsupportedClassVersionError 就用全路径):
[Console]::OutputEncoding=[System.Text.Encoding]::GetEncoding(936)&"C:\jdk\jdk-17.0.12\bin\java.exe"-cp".\target\classes"com.dp.lesson09.Main实测输出(本机真实跑出来的):
========== 第 9 课:模式组合实战 —— 订单全生命周期链路 ========== [EventBus] 监听器已注册:短信通知 [EventBus] 监听器已注册:邮件通知 [EventBus] 监听器已注册:成交归档审计 ========== 场景一:三种正常订单跑完整链路 ========== ---------- 开始处理:普通单 A2001 ---------- [校验] 订单 A2001 通过校验 [算优惠] 订单 A2001 用【满100减20】:原价 150.00 -> 实付 130.00 [扣库存] 订单 A2001(普通单):立即扣减库存,数量 3 [链路审计] 订单 A2001 处理流水: 步骤1:校验通过:订单号=A2001,原价=150.00 步骤2:算优惠:策略=满100减20,原价=150.00,实付=130.00 步骤3:扣库存:模式=立即扣减库存,数量=3 [EventBus] 发布下单成功事件:订单 A2001 [短信] 您的订单 A2001 已受理,实付 130.00 元 [邮件] 已向用户邮箱发送订单详情:订单 A2001,金额 130.00 元 [成交归档] 已写入对账/风控库:订单 A2001(普通单)成交,金额 130.00 元 [完成] 订单 A2001 全链路结束,实付 130.00 元 ---------- 开始处理:预售单 A2002 ---------- [校验] 订单 A2002 通过校验 [算优惠] 订单 A2002 用【折扣8.0折】:原价 88.00 -> 实付 70.40 [扣库存] 订单 A2002(预售单):只锁库存不扣减(到货后补尾款再扣),数量 1 [链路审计] 订单 A2002 处理流水: 步骤1:校验通过:订单号=A2002,原价=88.00 步骤2:算优惠:策略=折扣8.0折,原价=88.00,实付=70.40 步骤3:扣库存:模式=只锁库存不扣减(到货后补尾款再扣),数量=1 [EventBus] 发布下单成功事件:订单 A2002 [短信] 尊敬的用户,您的预售订单 A2002 已锁库存,到货后将通知您付尾款 [邮件] 已向用户邮箱发送订单详情:订单 A2002,金额 70.40 元 [成交归档] 已写入对账/风控库:订单 A2002(预售单)成交,金额 70.40 元 [完成] 订单 A2002 全链路结束,实付 70.40 元 ---------- 开始处理:团购单 A2003 ---------- [校验] 订单 A2003 通过校验 [算优惠] 订单 A2003 用【无优惠】:原价 600.00 -> 实付 600.00 [扣库存] 订单 A2003(团购单):按团购人数批量扣减库存,数量 5 [链路审计] 订单 A2003 处理流水: 步骤1:校验通过:订单号=A2003,原价=600.00 步骤2:算优惠:策略=无优惠,原价=600.00,实付=600.00 步骤3:扣库存:模式=按团购人数批量扣减库存,数量=5 [EventBus] 发布下单成功事件:订单 A2003 [短信] 您的订单 A2003 已受理,实付 600.00 元 [邮件] 已向用户邮箱发送订单详情:订单 A2003,金额 600.00 元 [成交归档] 已写入对账/风控库:订单 A2003(团购单)成交,金额 600.00 元 [完成] 订单 A2003 全链路结束,实付 600.00 元 ========== 场景二:两种被链路拦下的异常订单 ========== ---------- 开始处理:预售单 A2004 ---------- [中止] A2004 被前置检查拦下:预售订单 A2004 数量非法,前置检查不通过 ---------- 开始处理:普通单 A2005 ---------- [中止] A2005 在链路中被拦下:订单 A2005 原价为 -10.0,非法金额 ========== 场景三:组合而不是堆砌 —— 通知渠道只动注册处 ========== [EventBus] 监听器已注册:短信通知 (新总线只注册了短信,没注册邮件和归档) ---------- 开始处理:普通单 A2006 ---------- [校验] 订单 A2006 通过校验 [算优惠] 订单 A2006 用【满100减20】:原价 100.00 -> 实付 80.00 [扣库存] 订单 A2006(普通单):立即扣减库存,数量 2 [链路审计] 订单 A2006 处理流水: 步骤1:校验通过:订单号=A2006,原价=100.00 步骤2:算优惠:策略=满100减20,原价=100.00,实付=80.00 步骤3:扣库存:模式=立即扣减库存,数量=2 [EventBus] 发布下单成功事件:订单 A2006 [短信] 您的订单 A2006 已受理,实付 80.00 元 [完成] 订单 A2006 全链路结束,实付 80.00 元 ========== 四个模式在链路里的分工 ========== 模板方法 AbstractOrderProcessTemplate.process() —— 钉死宏观时序 | |-- 1. beforeProcess() 钩子(子类:预售校时间/团购校人数) |-- 2. 责任链 chain.handle(ctx) —— 校验->算优惠->扣库存->链路审计 | | ValidationHandler (校验) | | DiscountHandler (内部委托 DiscountStrategy 策略) | | StockHandler (普通/预售/团购 三种扣法) | | ProcessingAuditHandler(流程日志) |-- 3. eventBus.publish(下单成功事件) —— 观察者广播 | | SmsListener 短信 | | EmailListener 邮件 | | ArchiveAuditListener 成交归档 |-- 4. 收尾打印 ========== 演示结束:四个模式各管一段,换某段实现只动一处 ==========输出解读,重点看两个反差:
- 两个异常单,拦的位置不一样:A2004(预售数量 0)连责任链都没进,被模板的
beforeProcess钩子就拦下;A2005(金额 -10)过了钩子,却在校验环节被责任链stop,后面算优惠、通知、归档全部跳过。第 1 篇那种"订单没处理也没报错"的静默失败,再也不会出现。 - 场景三最能说明解耦:新总线只注册短信,A2006 就只收到短信;但你往上翻——链路四步一步不少(校验/优惠/库存/审计全打印了),只是事件订阅者少了俩。主流程一行没动,链路和通知彻底分家。
五、挑战题(改参数会怎样?答案在源码里)
⭐ 把普通单 A2001 的数量从
3改成0(别的不动),再跑一遍——它会像 A2004 那样被"前置检查"拦下吗?仔细看NormalOrderProcessTemplate的beforeProcess()到底有没有重写。⭐⭐ 把团购单 A2003 的数量从
5改成2,再跑一遍——这条订单会走完链路、收到短信和归档吗?去GroupBuyOrderProcessTemplate里找那个成团人数常量,答案就在那一行。⭐⭐ 场景三里 leanBus 只注册了短信。如果你把
new SmsOrderListener()换成new EmailOrderListener()(只注册邮件),再跑 A2006——它的实付金额还是不是 80.00?为什么"通知变了"但"算出来的钱"一点没变?
三题答案都藏在lesson-09的源码里,clone 下来改一行参数就能验证。跑出来和你想的不一样?评论区贴出来一起查。
六、生产环境进阶:手写是为了看穿原理,生产直接用现成的
真实项目里,这套组合你早就天天在用——只是没从这个视角看:
- SpringMVC 的
DispatcherServlet就是"模板方法 + 责任链 + 策略"链路:doDispatch()顺序焊死(模板方法),HandlerInterceptor的preHandle/postHandle沿链传递(责任链),HandlerAdapter按类型挑实现(策略)。本课手写的四个角色,Spring 一个DispatcherServlet全用上了。 - Spring 事务 + 事件:
@Transactional是个"事务模板";事务方法里 publish 事件、监听器去查这条订单会查不到(事务还没提交),Spring 用@TransactionalEventListener(AFTER_COMMIT)让事件等提交后再触发,本课手写的 EventBus 只是"发布即同步通知"的最简版。
一句忠告:别在生产里从零手写事件总线或拦截器框架。本课手写 30 行是让你看穿"next 指针和 stop 怎么转",上线照 Spring 现成方案来。
七、面试回答模板
Q1:多个设计模式怎么在一个真实业务里分工不打架?
一句话结论:按"哪段代码会怎么变"来划——流程顺序不变用模板方法、环节要增删用责任链、算法要互换用策略、副作用要广播用观察者。展开:模板方法钉骨架、责任链管可插拔环节、策略管优惠算法、观察者管下单后广播,本文 2.2 的分工表直接背。
Q2:模板方法和责任链都排了一遍顺序,重复吗?
一句话结论:不重复——模板方法是宏观流程图(顺序焊死在 final 方法里),责任链是图里"处理环节"那一步的可插拔实现,模板方法持有它。展开:模板方法不关心链里挂几个 Handler,责任链不关心跑完要不要发事件。本文 2.3 讲过。
Q3:什么时候该上模式,什么时候老老实实写 if-else?
一句话结论:先写 if-else 能跑,等出现"第二种实现 / 第三个分支 / 第二个通知渠道"时再抽象,别为"未来可能"提前上模式。展开:一个需求只出现一次就拆策略,是过度设计;等 if-else 真长得别扭了再抽不迟。
八、总结表:第 1 篇的 6 处坏味道,拆线清单
把第 1 篇列的那 6 处坏味道,一条一条对成本课的落点:
| 第 1 篇的坏味道 | 当时的病根 | 本课用什么拆的 |
|---|---|---|
魔法数字type=1/2/3 | 值写死,含义靠猜 | OrderType枚举(NORMAL/PRESALE/GROUP),漏分支编译器提醒 |
魔法字符串"满减"/"折扣"的 if-else | 加券就要改老方法 | DiscountStrategy策略族,加券 = 加一个类 |
| 一个方法干 5 件事 | 5 个改变理由 | 责任链 4 环节 + 模板方法 4 步,各管一段 |
| 加需求就动老代码 | 违反开闭原则 | 加通知渠道 = 加 Listener 注册一行,主流程无感 |
调用方直接new写死 | 面向实现编程 | 面向抽象AbstractOrderProcessTemplate编程,子类可换 |
| 字段 public 裸奔 + 静默失败 | 谁都能改、出错不吭声 | Order全 private + getter;ctx.stop()显式叫停,再也不静默 |
九、关于这个系列
本文是「Java 后端实战精通营」设计模式系列第 9 篇。第 1 篇那份外卖烂代码,到这一篇算是做完了整台手术——前面每课磨一把刀,今天四把刀在同一个病人身上配合着用,你终于看清"模式不是背 23 个名字,而是这段代码该用哪张处方"。
- 完整可运行工程:https://gitee.com/j67mk2/design-patterns(本课源码在
lesson-09/,主类com.dp.lesson09.Main,23 个类全在里面) - 下一篇预告(系列完结篇):《支付网关收单平台:把 9 课的模式,落成一个能拿去面试讲的完整系统》。第 10 课把它升级成真实可跑的支付网关——多渠道路由用策略、渠道回调用模板方法、对账通知用观察者,再加签名验签、幂等防重、异常兜底这些企业级要求,做完就能直接写进简历。
互动提问:如果现在让你把自己项目里一个"干了 3 件事以上"的方法拆开,你最先想用哪个模式?为什么?评论区聊聊,我帮你看看该挂在链路哪一环。
本文部分内容由 AI 辅助生成