ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

策略模式实战指南:用Java优雅重构if-else代码

策略模式实战指南:用Java优雅重构if-else代码

维护一段满是 if-else 的代码是什么感受?产品提一个需求,你得从入口方法一路往下摸到七八个分支,小心翼翼地加一行判断,生怕影响其它逻辑。上线后出问题,排查看得眼睛疼。很多同学第一次听到「策略模式」这个词,就是在被这种折磨逼到要重构的时候。作为一名常年做业务系统的开发,我可以负责任地说:策略模式是我在重构遗留代码时最常用到的武器,没有之一。它属于行为型设计模式,核心思路也很简单——把一组可以互相替换的算法封装起来,让调用方依赖稳定的接口,而不是具体的实现。适合正在被 if-else 围剿的 Java 工程师,也适合刚开始接触设计模式、想建立代码设计感的新人。

1. 你手里的代码,为什么越来越难改

1.1 一段让人头痛的会员折扣逻辑

假设你负责一个电商项目,订单价格计算里有这么一段。不同等级的会员,享受不同折扣。很多人第一次写出来的代码,长这样:

public double calculateOrderPrice(Order order, Member member) { double price = order.getTotalPrice(); if (member.getLevel().equals("GOLD")) { return price * 0.80; } else if (member.getLevel().equals("SILVER")) { return price * 0.90; } else if (member.getLevel().equals("BRONZE")) { return price * 0.95; } else { return price; } }

这段代码能跑,但每次需求一变更,你就得在这里动刀。过两天产品说,新增一个「钻石会员」等级,打七折,你得改这段逻辑;再过一个季度,产品说「铂金会员」满减改规则,你又要来改这里。改一次两次还能忍,但时间一长,会出现几个信号:这段方法越来越长,判断条件越叠越深,其它业务也开始复用这个方法,却传进来各种各样的会员等级。到这一步,代码已经不是单纯的「逻辑」,它变成了一个谁都不敢碰的大型分支结构。

这类代码的问题在哪里?首先,它直接违背了面向对象设计里最基础的开闭原则——对扩展开放,对修改关闭。你每新增一个折扣策略,就必须修改这个已经稳定的核心方法。其次,这段代码把「订单金额计算」和「会员等级判定」全部耦合在一起,改的时候一不小心就会引发连带问题。再者,测试起来也费劲,你没法单独测某个折扣规则,只能一整套流程走。

1.2 本质:把变化的算法和稳定的流程拆开

策略模式想解决的就是这个事。它的官方定义很简洁:定义一系列算法,把每一个算法封装起来,并且使它们之间可以相互替换。它的核心思想,是把「不变的流程」和「可变的规则」拆开。

我用一个生活例子来解释。你去一家餐厅吃饭,整个用餐流程是固定的:进门,点餐,等待,用餐,买单。这个流程基本不会变。但菜谱是变的,今天有红烧肉,明天有清蒸鱼,后天下架了也没人惊讶。餐厅不会把每一道菜的做法写进「点餐流程」里,它只会把菜谱挂在墙上,让服务员去读。策略模式里那个「稳定的点餐流程」,就是上下文类(Context);那张随时更新的菜谱,就是策略接口;每一道菜的具体做法,就是具体的策略实现类。

放在代码里,就是我们把那段会员折扣 if-else 里的每个分支,拆成独立的策略类。会员等级是 GOLD,就调用金卡策略;会员等级是 SILVER,就调用银卡策略。核心订单金额计算流程,完全不关心你具体是谁,它只认折扣策略这个接口。

1.3 它能解决什么,不能解决什么

策略模式不是万能的,先搞清楚边界,才不会用歪。

它能解决这几类问题:

  • 代码里出现大量条件分支,而且分支内部是相对独立的算法或规则。
  • 同样一种行为,在不同场景下有不同的实现方式,且这些实现可以随时切换。
  • 你希望新增一种规则时,不需要改动已经稳定的核心代码,只需新增一个策略实现。
  • 你希望每个算法都能够单独测试、单独维护,而不是混在一大段逻辑里。

但它不能解决的也很明显。它不能帮你消灭所有的 if-else,只是让分支的位置发生转移。原来分支写在核心流程里,现在分支写在「选择策略」的地方。如果没有需求变化,没有多种算法实现,只是为了用设计模式硬套一个,那只会让代码变得更绕,维护成本更高。所以,策略模式适合的场景,永远是那些「规则会生长」的地方。

2. 策略模式的三个角色与标准结构

2.1 三个角色各自的职责

策略模式的标准结构中,角色并不多,三个:

第一个是策略接口,也叫 Strategy。它定义了一个算法家族的公共方法,只是一个抽象约定,不关心具体算法怎么实现。第二个是具体策略,也叫 ConcreteStrategy。每个具体策略实现策略接口,封装一种独立的算法或规则。第三个是上下文,也叫 Context。它负责持有一个策略对象的引用,把算法调用委托给策略对象。Context 里通常有一个方法可以替换当前持有的策略。

这三者的关系,可以理解为:Context 是「导演」,Strategy 是「剧本」,具体策略是「演员」。导演只负责安排谁上场,不负责替演员演戏。

2.2 一个可以复制的最小实现

我用会员折扣场景,给你写一个最朴素的完整实现。

先定义策略接口:

public interface DiscountStrategy { double calculatePrice(double originalPrice); }

然后写三个具体策略:

public class GoldMemberDiscount implements DiscountStrategy { @Override public double calculatePrice(double originalPrice) { return originalPrice * 0.80; } } public class SilverMemberDiscount implements DiscountStrategy { @Override public double calculatePrice(double originalPrice) { return originalPrice * 0.90; } } public class NormalMemberDiscount implements DiscountStrategy { @Override public double calculatePrice(double originalPrice) { return originalPrice; } }

最后写上下文:

public class PriceCalculator { private DiscountStrategy strategy; public PriceCalculator(DiscountStrategy strategy) { this.strategy = strategy; } public void setStrategy(DiscountStrategy strategy) { this.strategy = strategy; } public double calculate(double originalPrice) { if (strategy == null) { throw new IllegalStateException("strategy must not be null"); } return strategy.calculatePrice(originalPrice); } }

调用方式是这样的:

PriceCalculator calculator = new PriceCalculator(new SilverMemberDiscount()); double result = calculator.calculate(100.0);

这样改完之后,原来的 if-else 去哪里了?它并没有完全消失,而是从核心计算逻辑里被挤了出去。新增钻石会员时,你只需要新建一个 DiamondMemberDiscount 类,然后在调用方选择它。核心的 PriceCalculator 一行代码都不用动。

2.3 为什么 Context 要面向接口编程

很多初学者会问:Context 里直接持有具体的 GOLD 折扣对象不行吗?非要放一个抽象接口?

答案是,如果你真的直接持有具体类,那策略模式就废了。Context 一旦依赖具体类,你就把整个系统重新拖回了高度耦合的状态。你每新增一个策略,就要回头改 Context,开闭原则再次被破坏。面向接口编程的本质,是让高层模块不依赖低层模块,两者都依赖抽象。在这个例子里,PriceCalculator 依赖 DiscountStrategy 这个抽象,会员折扣的具体变化被彻底挡在核心计算之外。

这正是依赖倒置原则的体现。你可能会觉得这个原则听上去很虚,但策略模式就是你亲手实践它最直接的方式。Context 只跟接口打交道,具体的策略实现都是可以随时替换的零件,这就是「组合优于继承」的工程价值——替换组合里的一个零件,比修改一个继承链的安全得多。

2.4 策略实例的状态与线程安全

关于策略对象,有一个细节容易被忽略:策略类应该尽可能设计成无状态的。无状态的意思是,策略对象内部不保存跟具体业务调用相关的数据。它就像一个计算器,你给我输入,我给你输出,除此之外什么都不记。无状态的策略对象可以被多个线程共享,你在项目里可以用单例的方式持有它,不用担心数据错乱。

但有些场景下策略类必须持有状态,比如某个策略需要读取一份配置表,或者策略运行中需要临时缓存中间结果。这时候就要注意了,这个有状态的策略不能在设计上用共享单例,否则并发请求会产生严重的数据污染。我的建议是,优先设计无状态策略;如果实在需要状态,就把状态控制在局部变量和入参对象里,不要轻易放进成员变量。这算是我踩过的坑里比较隐蔽的一个,线上出现过偶发的计算金额不对,最后定位到是策略对象被多线程共享,里面放了一个可变的中间字段。

3. 从 Demo 到实战:支付路由与注册表改造

3.1 实战场景:多渠道支付

会员折扣的 demo 理解起来容易,但真正的业务场景往往要复杂不少。我这里再说一个实战中非常常见的例子——支付渠道选择。

你做一个电商系统,订单要支持余额支付、银行卡支付、第三方支付,每种支付方式的处理流程完全不一样:有的需要弹起收银台,有的需要调用银行接口,有的只需要扣减本地余额。如果不用策略模式,你会写一个长长的 switch,比如:

switch (payChannel) { case "BALANCE": // 扣余额 case "BANK_CARD": // 调银行接口 case "THIRD_PARTY": // 调第三方 }

这时候策略模式就非常有用了。定义 PaymentStrategy 接口,里面有 pay(Order order) 和 refund(Order order) 两个方法,每个渠道实现一个策略类。OrderService 这个上下文里只保存 PaymentStrategy,调用方法时直接委托。

这个场景比折扣更好的地方是,支付渠道是典型的「新增频繁、流程稳定」的场景。每接入一个新支付渠道,你只需要写一个新策略类,在配置里加一项映射,核心下单流程不需要碰。

3.2 用 Map + 工厂消灭创建分支

策略模式用多了,你会发现一个问题:调用方每次都要 new 一个策略对象,而「选择哪个策略」的逻辑,最终还得有一处 if-else 或 switch。很多人把策略模式套上之后,发现核心计算逻辑是干净了,但创建策略的地方又变成了另一个战场。

你选择哪个策略,是基于支付渠道这个 key 来决定。这个映射关系,非常适合用 Map 来存储。我把这种写法叫「注册表模式」,它跟策略模式是天然的搭档。看代码:

public class PaymentStrategyFactory { private final Map<String, PaymentStrategy> strategyMap = new HashMap<>(); public PaymentStrategyFactory() { strategyMap.put("BALANCE", new BalancePaymentStrategy()); strategyMap.put("BANK_CARD", new BankCardPaymentStrategy()); strategyMap.put("THIRD_PARTY", new ThirdPartyPaymentStrategy()); } public PaymentStrategy getStrategy(String channel) { PaymentStrategy strategy = strategyMap.get(channel); if (strategy == null) { throw new IllegalArgumentException("unsupported channel: " + channel); } return strategy; } }

这样,原来那个创建策略的 switch 就彻底变成了初始化时向 Map 里放对象。以后新增一个支付渠道,流程变成两步:新建一个策略实现类;在工厂构造方法里加一行 put。核心业务代码不需要改动,创建策略的地方也只需要加一行。

3.3 Java 8 函数式写法:轻量策略不必建类

还有一个实战中很常见的演进方向:如果策略接口只有一个方法,Java 8 之后你完全可以用 Lambda 表达式来替代独立策略类。

比如折扣策略接口:

public interface DiscountStrategy { double calculatePrice(double originalPrice); }

这在 Java 8 里会自动被识别为函数式接口,调用方可以直接这样:

PriceCalculator calculator = new PriceCalculator(price -> price * 0.80);

不需要再单独创建 GoldMemberDiscount 这个类。这种写法规整一大批临时策略,特别适合那种只在一个方法内使用、没有复用价值的规则。我自己的习惯是:如果这个策略后续可能被多个地方引用,或者它本身业务逻辑很复杂,就单独建一个类;如果它只是调用方内部一个临时的小规则,用 Lambda 更清爽,代码更紧凑,也更符合函数式思维。不要为了设计模式而去设计模式,Lambda 出现之后,策略模式的实现成本已经大幅降低了。

3.4 策略与工厂、枚举的组合套娃

实际项目里,你见到的往往不是纯粹的策略模式,而是策略模式和简单工厂、注册表、枚举等集合在一起,形成一套组合方案。最经典的组合是:枚举定义策略类型,Map 完成策略注册,工厂负责对外提供策略实例。外层调用方完全感知不到策略类的存在,它只知道渠道编码和入口方法。这套方案也是我目前在项目中用的最多的一套,建议你可以直接照着这个组合去改造自己的代码。不过要提醒一句:不要无限套娃。我看到过有人为了把策略做得「足够优雅」,搞了策略 + 工厂 + 抽象工厂 + 建造者四层结构,最后连他自己都说不清某个策略是怎么被加载的。策略模式的价值是让代码直观、可维护,而不是让调用方猜谜。

4. 策略模式与几个"长得像"的模式的区分

4.1 一张表看清四种模式

很多初学者容易把策略模式跟状态模式、模板方法模式、命令模式搞混。它们都属于行为型模式,代码结构上确实有相似之处。

模式核心思想关注点典型场景与策略模式的本质区别
策略模式算法族封装,互相替换算法怎么选折扣、支付、排序客户端主动选择策略
状态模式状态驱动行为切换状态怎么流转订单状态机状态自动流转,行为切换是结果
模板方法固定流程骨架,子类填空流程怎么编排数据导入、报表生成用继承固定流程,策略用组合换算法
命令模式请求封装为对象动作怎么触发操作撤销、任务队列关注动作的发起和执行分离

这张表的核心区别,不在代码形态,而在语义。策略模式里,选择权在调用方手上;状态模式里,状态变化会让行为自动改变,调用方根本不关心具体处于哪个状态。你可以这样记:策略模式是「我指定一个算法来用」,状态模式是「我自己根据状态决定该用什么行为」。

4.2 策略模式 vs 状态模式

这两个模式是重灾区,因为它们的类图几乎一模一样,都是 Context 持有某个接口,接口有多个实现,运行时可以替换。但它们的意图完全不同。

状态模式解决的是「随着内部状态的变化,对象的行为也跟着变化」的问题。典型例子是订单状态机,一个新订单可以变成已支付、已发货、已完成、已取消。订单在已支付状态下能执行支付吗?不能。已发货状态下能执行发货吗?不能。状态之间的流转是有方向、有约束的,不可能随意从已取消状态跳到已发货状态。

策略模式解决的是算法的平替。你调用的对象,它的行为可以换,但换不换、什么时候换,完全由外部调用方决定。订单折扣策略可以从金卡策略切换到银卡策略,金卡和银卡之间没有必然的先后关系,谁也不会触发谁。

有一个使用上的判断标准:如果行为切换是由对象内部的某个状态字段变化引起的,而且切换路径是有限的、有规则的,那大概率是状态模式。如果行为切换纯粹是外部传入的选择,没有状态约束,那大概率是策略模式。

4.3 策略模式 vs 模板方法

模板方法模式是设计模式中另外一个家喻户晓的模式。它的核心是:父类定义了一个算法的骨架,把其中某些步骤延迟到子类实现。比如数据导入,整体流程固定:读文件、解析、校验、入库。但读文件的方式可能是 Excel 也可能是 CSV,解析规则不一样。模板方法会把读文件和解析做成抽象方法,子类各自实现。

策略模式和它的区别在于:模板方法用的是继承,策略模式用的是组合。模板方法是父类牢牢掌握流程顺序,子类只能填空;策略模式是完整的算法可以被整个替换。你可以理解成模板方法是「一套流程里挖了几个坑」,策略模式是「整个流程就是一个零件,插上哪个用哪个」。两者可以互相转换,但各有取舍。继承会让父类和子类之间的耦合变得很深,改父类骨架时容易影响所有子类;组合的好处是灵活,坏处是需要额外管理策略对象的生命周期。

4.4 策略模式 vs 命令模式

命令模式也是一种容易混淆的模式。它把请求封装成对象,从而可以用不同的请求对客户端进行参数化。典型场景是任务队列、操作日志、撤销。命令模式和策略模式在代码形式上也会出现一个接口、N 个实现类的形态。

它们的核心区别在于关注点。策略模式关注的是「怎么做」,也就是算法的替换;命令模式关注的是「做什么事」,也就是把一次操作封装成一个对象,这个对象可以被传递、排队、记录。策略模式里,调用方关心的是结果;命令模式里,调用方常常还关心触发的时间、顺序,甚至要支持撤销。如果你发现你的多个实现类之间并不存在「算法」关系,而是「动作」关系,那可能应该用命令模式而不是策略模式。

5. 实战经验:踩过的坑和避坑清单

5.1 坑一:策略类爆炸

策略模式用得越多,策略类越多。如果每次新增一个小规则都新建一个类,半年之后,策略包下面可能有几十个类,类名五花八门,维护起来同样痛苦。类爆炸的本质是「多度建模」——把每个细节都变成一个类,而实际情况根本不需要。

解决这个问题的思路有几个。第一,优先考虑用枚举策略。如果策略的选择条件是枚举或固定的字符串,直接把策略逻辑写到枚举里,一个枚举项对应一段逻辑。第二,善用 Lambda 和内部函数对象。简单的策略不再单独建类,直接传递一个 Lambda 表达式。第三,如果多个策略之间存在共性,可以提炼一个抽象模板策略,把公共部分上移到抽象类里,子类只实现差异部分。推荐顺序是:优先用 Lambda 和枚举,类爆炸趋势挡不住的时候,再考虑抽象类和模板去收敛。

5.2 坑二:策略接口设计太粗糙

策略接口的设计直接决定整套方案能不能持续演进。我见过最粗糙的接口长这样:

Object handle(Object context);

这个接口看似万能,但每个策略内部都要做一堆类型判断和强制转换,整来整去,if-else 换个形态又回来了。好的策略接口应当是参数明确、返回值明确。如果多个策略需要传入的信息比较分散,就封装一个策略上下文参数对象,而不是让策略方法挂一大串参数。参数对象可以携带全部输入,各策略按需取用。这样接口本身非常稳定,策略之间也不会因为参数清单变化而被频繁修改。

5.3 坑三:拿策略模式硬套简单场景

我见过不少同学学了策略模式之后,恨不得把所有 if-else 全部换成策略。但实际上,如果这个分支逻辑只有两三种情况,而且大概率一年内不会变,那用 if-else 就是最直观的写法。引入策略模式反而会把代码拆得七零八落,阅读时需要跳转好几个文件才能拼凑出完整逻辑。

判断该不该用策略模式的黄金标准只有一个:这个分支是否经常需要扩展。经常加新规则,那策略模式就是正确的投资;一年也不动一次的规则,综合收益反而是负数。设计模式本身是工具,不是业绩指标。不要在重构的冲动下,把一个稳定的简单逻辑变成复杂的策略组。

5.4 坑四:上下文与业务上下文混为一谈

策略模式里的 Context,是一个纯粹的逻辑承载者。它只负责持有策略、调用策略。但业务系统里面到处都是上下文,什么请求上下文、会话上下文、事务上下文。如果不加区分,很容易把业务上下文对象直接塞进策略方法里,导致策略类对业务上下文产生强依赖,失去了可复用性。

我的建议是,让策略类只依赖必要的入参。能传普通对象就传普通对象,能传基本类型就传基本类型。策略类尽量保持干净,不直接依赖具体的 HttpServletRequest 这样的大容器对象。这样策略类才可以被放在任何环境里复用,也方便单元测试。

5.5 识别框架中的策略模式

策略模式不只是你自己手写代码时用,你其实每天都在用。Java 标准库里的 Comparator 接口,就是一个教科书级别的策略模式:sort 方法接收一个 Comparator 参数,具体的比较策略由调用方提供,sort 本身只负责排序流程。线程池里的拒绝策略 RejectedExecutionHandler 也是,不同线程池可以指定不同的拒绝策略,从 AbortPolicy 到 CallerRunsPolicy,内核不需要改,换一个策略实现就行。各类 IoC 容器里的处理器映射机制,本质上也大量使用了策略模式。理解这一点很重要:你在框架里看到各种策略接口的时候,就会意识到设计模式不是学院派理论,而是真实工程里反复被验证过的解决方案。

6. 动手练习:手写一个运费计算器

6.1 需求与费率规则

纸上谈兵没意思,我给你设计一个适合练手的案例,你可以自己动手写一遍。场景是电商运费计算,有三种运费策略。

普通快递:按重量计费,每公斤 8 元,不足 1 公斤按 1 公斤算。 冷链运输:基础费 20 元,每公斤 12 元,起步价 50 元。 同城急送:固定费用 15 元,超过 10 公里每公里加 2 元。

这个案例的好处是,策略之间的算法差异足够大,能体现策略模式的价值。而且计算规则里有取整、起步价、额外费用这些细节,实现起来不会觉得空洞。

6.2 编码步骤与核心实现

第一步,定义运费策略接口:

public interface ShippingCostStrategy { double calculate(FreightOrder order); }

FreightOrder 是入参对象,包含重量(公斤)、距离(公里)等字段。第二步实现三个策略类。普通快递:

public class StandardShippingStrategy implements ShippingCostStrategy { @Override public double calculate(FreightOrder order) { double weight = Math.ceil(order.getWeight()); return weight * 8.0; } }

冷链运输:

public class ColdChainShippingStrategy implements ShippingCostStrategy { @Override public double calculate(FreightOrder order) { double fee = 20.0 + order.getWeight() * 12.0; return Math.max(fee, 50.0); } }

同城急送:

public class SameCityShippingStrategy implements ShippingCostStrategy { @Override public double calculate(FreightOrder order) { double fee = 15.0; if (order.getDistance() > 10) { fee = fee + (order.getDistance() - 10) * 2.0; } return fee; } }

第三步写上下文:

public class ShippingCostCalculator { private ShippingCostStrategy strategy; public void setStrategy(ShippingCostStrategy strategy) { this.strategy = strategy; } public double calculate(FreightOrder order) { if (strategy == null) { throw new IllegalStateException("shipping strategy not set"); } return strategy.calculate(order); } }

写一个简单的测试代码,分别验证三个策略:

ShippingCostCalculator calculator = new ShippingCostCalculator(); FreightOrder order = new FreightOrder(5.2, 8.0); calculator.setStrategy(new StandardShippingStrategy()); System.out.println("普通快递: " + calculator.calculate(order)); calculator.setStrategy(new ColdChainShippingStrategy()); System.out.println("冷链运输: " + calculator.calculate(order)); calculator.setStrategy(new SameCityShippingStrategy()); System.out.println("同城急送: " + calculator.calculate(order));

按照规则推算,普通快递重量 5.2 公斤往上取整是 6 公斤,费用 48 元;冷链运输是 20 加 62.4 元,再取起步价 50 元,所以是 82.4 元;同城急送距离 8 公里没超过 10 公里,就是 15 元。运行结果应该都能对上。

6.3 测试与重构验证

写完之后,你还可以做一个重构练习:把调用方里选择策略的 if-else 换成 Map 注册表,或者试试用 Lambda 代替独立策略类。同一个功能,用三种不同的姿势实现一遍,你对策略模式的理解会有一个质的提升。另外一个值得做的练习是单元测试,每个策略写一个独立的测试类,只验证一种规则。相比原来把整个运费计算逻辑揉在一个方法里,现在每个测试可以给特定的输入,断言特定的输出,边界情况也更容易补齐。

6.4 继续扩展的方向

这个运费计算器还可以继续演进。比如运费策略里有混合规则,同一个订单既可以走普通快递,又可以选择加急,这时候可以用策略模式结合责任链模式或装饰器模式来实现规则叠加。还有运费试算与最终计费需要相同计算逻辑但不同上下文,这时候可以考虑把策略接口再抽象一层,让不同业务共用一套运费规则。这些扩展都可以作为后续练手的方向,但建议先做到把基础版本写明白,再考虑套上更复杂的组合。一步到位写得太花哨,反而容易丢失策略模式本身的核心价值。

我在实际项目里做了一些策略模式落地工作,最大的体会是:它是我重构遗留系统时,改动面最小、侵入性最低的选择。你不需要把整个系统大动干戈,只需要找到那堆最顽固的 if-else,抽出一个接口,把分支逐个搬进策略类,再把调用方改成只依赖接口。每搬完一个分支,跑一遍测试,确认绿色再继续。别想着一次重构到位,一个小分支一个小分支地推进,反而最稳妥。还有个小技巧分享给你:做一个策略模式改造前,先在代码里找找是否已经存在 Map 的 key 到实现类的映射痕迹,它往往就是天然的策略选择入口,顺着它去抽接口,方向和边界都会清晰很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进