作者:鱼宵 | 「Java 后端实战精通营」设计模式系列・第 5 篇
一、真实场景:老板每次加一种券,你就得回来加一个 else-if
还记得第 1 篇那份故意写烂的外卖订单代码吗?里面算优惠那段是这么写的:
doublefinalAmount=order.amount;if("满减".equals(order.couponType)){if(order.amount>=100){finalAmount=order.amount-20;}}elseif("折扣".equals(order.couponType)){finalAmount=order.amount*0.8;}当时我在第 1 篇末尾埋了个钩子:这段 if-else 留着,等第 5 课用策略模式收拾。今天兑现。
痛点你一定熟:老板下周加个"无门槛券",你在优惠 if 链里再补一个else if;下下周加"会员券",又补一个。一个月后优惠方法里躺了五六个分支,改"满减门槛从 100 涨到 150"时,你得翻遍整个方法找那行>= 100——漏改一处线上就有人薅羊毛。
这一课开药:策略模式。后半节顺手讲模板方法。代码全在lesson-05,clone 下来跑一遍,加新券是不是真的只加一个类,控制台当场演。
二、核心原理:把"算法"一个个装进小盒子,随插随换
2.1 策略模式是什么:出门选地铁还是打车
策略模式(Strategy):定义一族算法,把它们一个个封装起来,让它们可以互相替换。算法的变化被独立出来,不影响用它的客户。
类比:出门选地铁、打车、骑车——三个是"出行算法族",目的地一样、算法不同,随时能换。满减、折扣、无优惠就是"算优惠"这一族的三个算法。
2.2 三个角色,一句话各管什么
| 角色 | 本课对应类 | 干什么 |
|---|---|---|
| 抽象策略 Strategy | DiscountStrategy接口 | 定义统一入口:discount(金额) |
| 具体策略 ConcreteStrategy | FullReductionStrategy/PercentDiscountStrategy/NoDiscountStrategy | 各算各的:满减、8 折、原价 |
| 上下文 Context | OrderDiscountCalculator | 持有当前策略、对外统一入口、运行时可换 |
面试常被追问:"直接new FullReductionStrategy().discount(150)不行吗?"行,但"用哪种券"就写死在调用方了。上下文存在的意义就三件事:持有策略 + 运行时切换 + 统一入口。用户中途换券就是setStrategy(新券)一行。
2.3 错误写法 vs 正确写法,摆在一起看差距
// ❌ 错误写法:算法挤在一个方法里,加券就要回来改老代码doublecalculateBad(StringcouponType,doubleamount){if("满减".equals(couponType)){if(amount>=100)returnamount-20;returnamount;}elseif("折扣".equals(couponType)){returnamount*0.8;}elseif("无门槛".equals(couponType)){// 加券 = 改老方法returnamount-5;}returnamount;}// ✅ 正确写法:每种券一个类,加券 = 加新类,老方法一行不动doublecalculateGood(DiscountStrategystrategy,doubleamount){returnstrategy.discount(amount);// 调谁由外面传进来}差别在哪?错误写法里"加新券"是回头改一个已经上线、跑了半年的老方法——违反开闭原则(OCP:对扩展开放,对修改关闭)。正确写法里"加新券"是新增一个类实现DiscountStrategy,老代码碰都不碰。
那什么时候 if-else 反而更好?分支只有两三个、几乎不会再加的地方,硬上策略就是过度设计。策略买的是"可维护性",不是"运行速度"。分支会涨、算法会换,才值得上策略。
2.4 后半节:模板方法——父类定骨架,子类填空
讲完策略,再上一个姊妹模式。模板方法(Template Method):父类定好算法的骨架(步骤顺序固定),某些步骤的实现推迟到子类。子类不改流程结构,只填细节。
类比:做菜。菜谱流程固定(洗菜→切菜→下锅→调味),放什么料由这道菜自己定。
订单处理永远是这四步:校验 → 算价 → 扣库存 → 通知。普通单和预售单差异只在"算价"和"扣库存"两步,校验和通知完全一样。每个订单类型都把四步抄一遍,改通知格式要改 N 处——这就是模板方法要干掉的重复。
它有三个命门:
- 骨架方法
process()必须final:子类不能重写它、不能调换顺序。骨架被改了,"固定流程"就名存实亡。 - 抽象方法(
abstract):父类知道"要算价"但不知道"怎么算",留空让子类填——必须实现。 - 钩子方法(Hook):父类给默认实现,子类可选覆盖。和抽象方法的区别:抽象是"必须交作业",钩子是"默认交了、你想改再重写"。
2.5 两对高频混淆题,一张表背下来
策略 vs 状态模式(面试高频陷阱,答反就挂):策略是客户端主动选算法(用户选了满减券,平级独立);状态是对象内部状态自己切自己(订单从待支付变已支付,行为自动变)。模板方法 vs 策略:
| 模板方法 | 策略 | |
|---|---|---|
| 复用手段 | 继承(子类填步骤) | 组合(换一个策略对象) |
| 改流程 | 要新写一个子类 | 换个策略对象就行 |
| 粒度 | 一整套流程的骨架 | 单个可互换的算法 |
一句话:模板方法用继承"白箱复用",策略用组合"黑箱复用"。
三、关键代码:完整可运行版(含 package + import + 主类)本课工程在lesson-05/src/main/java/com/dp/lesson05/。先看抽象策略接口——它只约定"能算优惠",不管"怎么算":
packagecom.dp.lesson05;/** * 优惠策略接口(策略模式的"抽象策略"角色)。 * 把"怎么算优惠"抽象成一个方法,每种优惠券 = 一个实现类。 * 策略应该无状态:一个实例可被所有订单共用,因为算优惠只跟入参有关。 */publicinterfaceDiscountStrategy{/** * 计算优惠后的实付金额。 * @param originalAmount 订单原始金额 * @return 优惠后应付金额 */doublediscount(doubleoriginalAmount);/** 策略名:演示时打印出来,让大家看到当前用的是哪种算法 */Stringname();}三个具体策略各算各的,满减的门槛判断收在自己类内部:
packagecom.dp.lesson05;/** 满减策略:满 100 减 20 */publicclassFullReductionStrategyimplementsDiscountStrategy{@Overridepublicdoublediscount(doubleoriginalAmount){// 门槛是满减算法自己的事,封在策略内部if(originalAmount>=100){returnoriginalAmount-20;}returnoriginalAmount;}@OverridepublicStringname(){return"满减券(满100减20)";}}packagecom.dp.lesson05;/** 折扣策略:折扣率做成构造参数,一个类覆盖任意折扣率 */publicclassPercentDiscountStrategyimplementsDiscountStrategy{privatefinaldoublerate;// 0.8 = 八折publicPercentDiscountStrategy(doublerate){this.rate=rate;}@Overridepublicdoublediscount(doubleoriginalAmount){returnoriginalAmount*rate;}@OverridepublicStringname(){return"折扣券("+(int)(rate*10)+"折)";}}packagecom.dp.lesson05;/** * 无优惠策略:原价返回。 * "什么都不做"也是一种算法,显式做成策略,调用方再也不用判空—— * 这就是"空对象模式(Null Object)"的味道。 */publicclassNoDiscountStrategyimplementsDiscountStrategy{@Overridepublicdoublediscount(doubleoriginalAmount){returnoriginalAmount;}@OverridepublicStringname(){return"无优惠(原价)";}}接下来是上下文——三行核心:持有策略、可切换、委托:
packagecom.dp.lesson05;/** * 优惠计算器(上下文 Context)。 * 持有当前策略,真正算钱的活儿委托给策略去做,自己只做编排。 */publicclassOrderDiscountCalculator{privateDiscountStrategystrategy;// 当前生效的策略publicOrderDiscountCalculator(DiscountStrategystrategy){this.strategy=strategy;}/** 运行时切换策略:电商里"用户换了一张券"就是这一行 */publicvoidsetStrategy(DiscountStrategystrategy){this.strategy=strategy;System.out.println(" [计算器] 切换优惠策略 -> "+strategy.name());}/** 统一入口:把活儿全部委托给当前策略,自己不算钱 */publicdoublecalculate(doubleoriginalAmount){returnstrategy.discount(originalAmount);}publicStringcurrentStrategyName(){returnstrategy.name();}}模板方法部分。父类把四步骨架用final锁死,两个抽象步骤留空,通知是钩子:
packagecom.dp.lesson05;/** * 订单处理流程模板(模板方法的抽象类角色)。 * 骨架:校验 -> 算价 -> 扣库存 -> 通知,顺序由 final 锁死。 */publicabstractclassAbstractOrderProcessTemplate{/** 骨架方法:final 锁死,子类不能改流程顺序 */publicfinalvoidprocess(Orderorder){validate(order);// 步骤1:通用,父类实现doubleprice=calcPrice(order);// 步骤2:抽象,子类填deductStock(order);// 步骤3:抽象,子类填notify(order,price);// 步骤4:钩子,父类给默认}/** 步骤1:所有订单通用的校验,写死在父类 */protectedvoidvalidate(Orderorder){if(order.getOrderId()==null||order.getOrderId().isEmpty()){thrownewIllegalArgumentException("订单号不能为空");}System.out.println(" [流程] 校验通过:订单 "+order.getOrderId());}/** 步骤2:抽象方法,子类必须实现 */protectedabstractdoublecalcPrice(Orderorder);/** 步骤3:抽象方法,子类必须实现 */protectedabstractvoiddeductStock(Orderorder);/** * 步骤4:钩子方法(Hook)。 * 不是 abstract:父类给了默认实现,子类想改才重写。 * 分清 abstract 还是 Hook 的口诀:子类不改行不行?行 = 钩子。 */protectedvoidnotify(Orderorder,doubleprice){System.out.println(String.format(" [流程] 通知用户:订单 %s 实付 %.2f 元",order.getOrderId(),price));}}两个子类只填差异。普通单算价走策略计算器(这里是"模板方法 + 策略"两个模式配合),预售单算活动价:
packagecom.dp.lesson05;/** 普通单:算价走策略计算器,扣库存立即扣 */publicclassNormalOrderProcessextendsAbstractOrderProcessTemplate{privatefinalOrderDiscountCalculatorcalculator;publicNormalOrderProcess(OrderDiscountCalculatorcalculator){this.calculator=calculator;}@OverrideprotecteddoublecalcPrice(Orderorder){doubleprice=calculator.calculate(order.getAmount());System.out.println(String.format(" [流程] 普通单算价:原价 %.2f -> 实付 %.2f(%s)",order.getAmount(),price,calculator.currentStrategyName()));returnprice;}@OverrideprotectedvoiddeductStock(Orderorder){System.out.println(" [流程] 普通单:立即扣减库存");}// notify 没重写:吃父类默认实现,这就是钩子"可选覆盖"的演示}packagecom.dp.lesson05;/** 预售单:算活动价(不用券),只锁库存不扣 */publicclassPresaleOrderProcessextendsAbstractOrderProcessTemplate{@OverrideprotecteddoublecalcPrice(Orderorder){doubleprice=order.getAmount()*0.9;// 预售 9 折System.out.println(String.format(" [流程] 预售单算价:活动价 %.2f(原价 %.2f,不用券)",price,order.getAmount()));returnprice;}@OverrideprotectedvoiddeductStock(Orderorder){System.out.println(" [流程] 预售单:只锁库存,不扣减(发货日才扣)");}}最后是入口 Main,把策略切换和模板流程各跑一遍:
packagecom.dp.lesson05;publicclassMain{publicstaticvoidmain(String[]args){System.out.println("========== 第 5 课:策略模式——优惠计算 if-else 的末日 ==========");// 1. 初始策略 = 无优惠(没券就是没券,不用 if 判断)OrderDiscountCalculatorcalculator=newOrderDiscountCalculator(newNoDiscountStrategy());payOnce(calculator,150);payOnce(calculator,88);// 2. 用户换了满减券:运行时切换,订单服务一行不改calculator.setStrategy(newFullReductionStrategy());payOnce(calculator,150);// 满100减20payOnce(calculator,80);// 不满100,不减// 3. 又换成 8 折券calculator.setStrategy(newPercentDiscountStrategy(0.8));payOnce(calculator,88);System.out.println();System.out.println("========== 第 5 课:模板方法——订单处理流程骨架 ==========");// 4. 普通单流程:四步骨架跑一遍(此刻计算器里装的是 8 折策略)Ordernormal=newOrder("A2001","NORMAL",150);AbstractOrderProcessTemplatenormalProcess=newNormalOrderProcess(calculator);System.out.println("-- 跑普通单流程 --");normalProcess.process(normal);System.out.println();// 5. 预售单流程:四步顺序一模一样,算价/扣库存做法完全不同Orderpresale=newOrder("A2002","PRESALE",200);AbstractOrderProcessTemplatepresaleProcess=newPresaleOrderProcess();System.out.println("-- 跑预售单流程 --");presaleProcess.process(presale);System.out.println();System.out.println("========== 结束:加新券=新增策略类;新订单类型=新增子类,老代码一行不动 ==========");}/** 小工具:按当前策略算一单并打印 */privatestaticvoidpayOnce(OrderDiscountCalculatorcalculator,doubleamount){doublepay=calculator.calculate(amount);System.out.println(String.format(" 金额 %.2f 用[%s] -> 实付 %.2f",amount,calculator.currentStrategyName(),pay));}}这段代码讲了什么:前半段同一个计算器被中途换了三次策略(无优惠→满减→8 折),调用方一行没改;后半段普通单和预售单严格走四步,差异只在算价和扣库存。
四、完整演示 + 实测输出
clone 下来后,在lesson-05目录执行(Windows + PowerShell):
cd C:\Users\haoji\Desktop\课程\design-patterns-journey\lesson-05$env:JAVA_HOME="C:\jdk\jdk-17.0.12"mvn clean install-DskipTests看到BUILD SUCCESS后运行(中文乱码先设 936 编码):
[Console]::OutputEncoding=[System.Text.Encoding]::GetEncoding(936)&"C:\jdk\jdk-17.0.12\bin\java.exe"-cp".\target\classes"com.dp.lesson05.Main实测输出:
========== 第 5 课:策略模式——优惠计算 if-else 的末日 ========== 金额 150.00 用[无优惠(原价)] -> 实付 150.00 金额 88.00 用[无优惠(原价)] -> 实付 88.00 [计算器] 切换优惠策略 -> 满减券(满100减20) 金额 150.00 用[满减券(满100减20)] -> 实付 130.00 金额 80.00 用[满减券(满100减20)] -> 实付 80.00 [计算器] 切换优惠策略 -> 折扣券(8折) 金额 88.00 用[折扣券(8折)] -> 实付 70.40 ========== 第 5 课:模板方法——订单处理流程骨架 ========== -- 跑普通单流程 -- [流程] 校验通过:订单 A2001 [流程] 普通单算价:原价 150.00 -> 实付 120.00(折扣券(8折)) [流程] 普通单:立即扣减库存 [流程] 通知用户:订单 A2001 实付 120.00 元 -- 跑预售单流程 -- [流程] 校验通过:订单 A2002 [流程] 预售单算价:活动价 180.00(原价 200.00,不用券) [流程] 预售单:只锁库存,不扣减(发货日才扣) [流程] 通知用户:订单 A2002 实付 180.00 元 ========== 结束:加新券=新增策略类;新订单类型=新增子类,老代码一行不动 ==========输出解读三个细节:
- 前半段策略:同一个计算器,先无优惠(150→150),切满减(150→130、80→80 不满门槛),再切 8 折(88→70.4)——算法在运行中途被换掉,调用方一行没改。
- 后半段模板方法:普通单和预售单严格走四步,差异只在算价和扣库存两行。
- 普通单那行括号里是"折扣券(8折)":因为计算器里此刻装的还是 8 折策略——模板方法的"算价步骤"内部又委托给了策略。
五、挑战题(改参数会怎样?答案在源码里)
⭐ 把
Main里new PercentDiscountStrategy(0.8)改成(0.7),再跑一遍——88 那单实付多少?注意name()里那行(int)(rate*10)打印是"7折"还是"8折"?⭐⭐ 把
AbstractOrderProcessTemplate.process()的final去掉,让PresaleOrderProcess重写一个process()(故意把顺序倒过来:先通知再算价)——编译报不报错?跑出来和父类流程差在哪?⭐⭐ 给抽象模板加钩子
protected boolean needNotify() { return true; },在notify()开头加if (!needNotify()) return;,让PresaleOrderProcess覆盖它返回false——重跑预售单,最后那行"通知用户"还在不在?
三题改完跑一遍,现象都在lesson-05源码里。跑出来和你想的不一样?评论区贴出来。
六、生产环境进阶:Spring 里你天天用,只是没意识到
手写一个接口 + 三个实现 + 一个上下文是为了看清本质。真实项目里这两个模式早就被框架包好了:
1. Spring 注入Map<String, DiscountStrategy>——生产最常用的策略分发
@ServicepublicclassCouponService{// Spring 自动把所有 DiscountStrategy 类型的 Bean 按名字灌进来@AutowiredprivateMap<String,DiscountStrategy>strategyMap;publicdoublepay(StringcouponType,doubleamount){// 加新券 = 新写一个 Bean 实现接口,这里一行 if 都不用加returnstrategyMap.get(couponType).discount(amount);}}你写过的@Autowired Map<String, SomeStrategy>就是策略的 Spring 落地——框架帮你把"接口→多实现→按名取"全干了。
2. JDK 里你已经用过几百次策略——Comparator和拒绝策略
List<Order>orders=...;orders.sort((a,b)->Double.compare(b.getAmount(),a.getAmount()));// lambda 就是排序策略newThreadPoolExecutor(1,1,0L,TimeUnit.MILLISECONDS,newLinkedBlockingQueue<>(100),newThreadPoolExecutor.CallerRunsPolicy());// 拒绝策略Comparator是策略接口:JDK 的sort()是固定算法,"怎么比"由你传的 lambda 决定。RejectedExecutionHandler四个内置实现(AbortPolicy 抛异常、CallerRunsPolicy 调用者跑、DiscardPolicy 丢、DiscardOldestPolicy 丢最老的)也是策略。3. 模板方法的天花板——Spring 的JdbcTemplate
它把"拿连接 → 建 Statement → 执行 SQL → 关资源 → 异常翻译"这套固定流程全锁在父类里,你只写 SQL 那段。拿连接、关资源、把SQLException翻译成DataAccessException——这些脏活全是固定的,是"父类骨架";action.doInStatement(stmt)是你写的 SQL,是"子类填的步骤"。你用 JdbcTemplate 时传的那个 lambda,就是模板方法里"留空的那一步"。## 七、面试回答模板
Q1:策略模式怎么消灭 if-else?为什么说"新增优惠券 = 新增一个类"?
一句话结论:把分支搬进多态——每种算法封装成一个实现类,上下文持有策略并委托,新增行为 = 新增实现类,不改老代码。展开:坏代码里
if ("满减") / else if ("折扣")把三种算法挤在一个方法里;策略模式把每个分支抽成DiscountStrategy的一个实现,加"无门槛券"就是新写一个类,老代码一行不动,这就是开闭原则。指向本文第二节。Q2:策略模式里 Context 是干什么的?
一句话结论:上下文 = 策略的持有者 + 请求的转发者,它自己不算算法,只做编排。展开:上下文三个价值——持有当前策略、统一入口
calculate()、运行时setStrategy()切换,电商里"用户中途换券"就是这一行。指向本文第三节。Q3:模板方法和策略模式什么区别?Spring 里哪里是这俩?
一句话结论:模板方法用继承(白箱、父类定骨架子类填步骤),策略用组合(黑箱、换个策略对象就行)。展开:JdbcTemplate / RestTemplate 是模板方法;
Map<String, XxxStrategy>注入、Comparator、拒绝策略是策略。指向本文第二、六节。
八、总结表
| 坑 | 原因 | 解法 |
|---|---|---|
加一种优惠券就要回来加else if | 算法挤在一个方法里,违反开闭原则 | 每种券 = 一个策略类,上下文持有切换(本文第二节) |
直接在调用方new FullReductionStrategy() | "用哪种券"写死,丢了运行时切换能力 | 加一层 Context,setStrategy()运行时换 |
模板方法骨架忘了加final | 编译不报错,但子类重写process()把流程打乱 | 骨架方法必须final,这是命门 |
把"可选步骤"声明成abstract | 每个子类被迫实现一堆用不到的方法 | 分不清 abstract 还是 Hook 就问:"子类不改行不行?"行 = 钩子 |
九、关于这个系列
本文是「Java 后端实战精通营」设计模式系列第 5 篇。这个系列不按 23 种模式教科书式罗列,只打三张牌:面试高频模式 + Spring/JDK 源码真实落点 + 第 1 篇那份外卖烂代码逐节开药。
- 完整可运行工程:https://gitee.com/j67mk2/design-patterns(本课源码在
lesson-05/,主类com.dp.lesson05.Main) - 下一篇预告:《观察者模式:下单成功,订单服务一个 if 都不加,所有关心它的模块自动收到消息》。第 1 篇里发通知的三个
if(needSms / needEmail / needAppPush)就是下一课手术对象——手写一个极简事件总线:下单成功发布事件,短信、邮件、App 推送各自订阅,加微信通知 = 新增一个监听器。互动提问:你项目里有没有一段"加一种分支就要回来改老方法"的代码?是优惠券?支付渠道?通知方式?评论区说说,我帮你判断该上策略、观察者还是责任链。
本文部分内容由 AI 辅助生成