上周帮一个小组做代码走查,看到支付模块的Service里堆了七八层if-else,每接入一种支付渠道就往里塞一段,方法长得要三屏才能拉完。新渠道上线时,负责人小心翼翼地插代码,生怕碰坏前面的正常流程。我说这个问题的本质不是代码风格不够好,而是结构选型出了偏差。行为型设计模式里的策略模式,就是专门对付这种“算法成堆、选择逻辑混乱”的场景的。
这篇文章是23种设计模式系列的倒数第二篇,聊行为型里的策略模式。我不会上来就贴教科书定义,而是按“问题 -> 结构 -> 实战改造 -> 模式辨析 -> 避坑经验”这条线走,每一段都给能直接跑通的代码。不管你是正在补设计模式基础,还是项目里已经遇到这个坏味道,都能从中拿到可用的东西。
1. 一段写满if-else的代码,是怎么逼你上策略模式的
1.1 先看一段典型的“坏味道”代码
先来看一段很有代表性的代码。很多项目里真实存在,表现形式可能略有不同,但骨架基本一致:
public class PaymentService { public void pay(String channel, double amount) { if ("balance".equals(channel)) { // 校验余额,扣余额,记录流水 System.out.println("余额支付:" + amount); } else if ("bankCard".equals(channel)) { // 校验银行卡信息,调用银行接口,处理回调 System.out.println("银行卡支付:" + amount); } else if ("scan".equals(channel)) { // 生成二维码,等待扫码结果 System.out.println("扫码支付:" + amount); } else if ("nfc".equals(channel)) { // 近场通信相关逻辑 System.out.println("NFC支付:" + amount); } else { throw new IllegalArgumentException("不支持的支付渠道: " + channel); } } }注释里的内容,在真实项目里往往就是几十行甚至上百行的核心逻辑。我见过更夸张的版本:每个分支里还有自己的try-catch、重试机制、埋点逻辑,整个方法逼近300行,滚动起来毫无头绪。
这段代码的问题一目了然。第一,每次新增渠道都要打开PaymentService修改,万一改串了,直接影响现有渠道。第二,单元测试想覆盖所有分支,需要构造一堆场景,测试代码比业务代码还长。第三,分支之间的公共逻辑——参数校验、日志埋点、事务控制——和渠道特有逻辑搅在一起,想复用只能复制粘贴。第四,如果条件再叠加用户等级、金额区间这些维度,if-else直接变成“金字塔”。
1.2 问题本质:算法与“选择算法”的地方绑死了
仔细看看这些分支,你会发现每个分支都是一套完整且独立的“算法”。余额支付有余额支付的校验和扣减流程,扫码支付有扫码支付的二维码生成和结果轮询流程,它们之间几乎没有重叠。
问题在于,算法本身和“选择算法的判断逻辑”被硬塞到了同一个方法里。选择逻辑一多,方法就膨胀;算法一变化,改动就被放大。今天加一个渠道,明天换一个校验顺序,后天又要调整异常处理,所有风险都集中在一处。
策略模式的处理思路很简单:把每一套算法各自封装成一个类,让这些类实现同一个接口;外界在需要的时候选择其中一个,再交给一个统一的上下文去执行。这样一来,“选择算法”和“使用算法”的关注点被拆开了,算法之间也彻底解耦,互不干扰。
GoF对策略模式的经典描述里有一句很关键的话:“定义一族算法,分别封装起来,让它们可以相互替换,让算法的变化独立于使用算法的客户端。”这里说的“客户端”,在具体代码里往往就是Service或者Controller。
1.3 什么场景才值得上策略模式
先泼一盆冷水:不是所有if-else都该死。判断标准其实很朴素,我就按三个条件来筛查:
- 分支里的逻辑足够“重”,不是一个赋值、一个返回就能结束的简单动作
- 分支数量会持续增长,今天三个,下个月可能六个
- 业务上确实存在运行时动态选择算法的需求
如果这三个特征一个都不沾,老老实实写if-else,代码反而直白可读。模式是来驾驭变化的,不是为了消灭分支而存在。这也是策略模式在实战中被误解最多的地方——很多人只记住了“把if-else换成策略类”,却忽略了这套替换带来的类数量上升和跳转成本。
2. 策略模式的三个角色:一个最小骨架就长这样
2.1 三个角色分别负责什么
策略模式的结构可以概括成三个角色。把这三个角色理解透,比记住任何一段完整代码都重要。
第一个是策略接口(Strategy),定义算法长什么样。在Java里通常就是接口或抽象类,里面声明的是一组公共方法,比如“执行支付”。它不关心具体实现,只定义契约。
第二个是具体策略(ConcreteStrategy),实现接口的类,每一类算法对应一个实现。回到支付场景,余额支付是BalanceStrategy,银行卡支付是BankCardStrategy,扫码支付是ScanStrategy。每个类内部维护自己的完整算法。
第三个是上下文(Context),持有当前策略的引用,对外提供一个统一入口。客户端把选好的策略交给Context,由Context去调用策略方法。Context不知道策略内部怎么实现,只知道“这个策略能满足当前需求”。
依赖方向很清晰:客户端依赖Context,Context依赖策略接口,具体策略实现策略接口。反过来,具体策略完全不依赖Context和客户端。
2.2 最小可运行示例
写一个最干净的例子,不带任何业务干扰,帮助大家看清楚骨架本身。首先是策略接口:
public interface PayStrategy { void pay(double amount); }三个具体策略:
public class BalanceStrategy implements PayStrategy { @Override public void pay(double amount) { System.out.println("使用余额支付:" + amount + "元"); } } public class BankCardStrategy implements PayStrategy { @Override public void pay(double amount) { System.out.println("使用银行卡支付:" + amount + "元"); } } public class ScanStrategy implements PayStrategy { @Override public void pay(double amount) { System.out.println("使用扫码支付:" + amount + "元"); } }然后是上下文:
public class PayContext { private PayStrategy strategy; public PayContext() {} public PayContext(PayStrategy strategy) { this.strategy = strategy; } public void setStrategy(PayStrategy strategy) { this.strategy = strategy; } public void executePay(double amount) { strategy.pay(amount); } }客户端这样使用:
public class Client { public static void main(String[] args) { PayContext context = new PayContext(new BalanceStrategy()); context.executePay(100); // 运行时切换策略 context.setStrategy(new ScanStrategy()); context.executePay(50); } }运行后控制台依次输出“使用余额支付:100.0元”和“使用扫码支付:50.0元”。整个过程中,PayContext的代码没有改过一行,只是换了个策略对象,行为就完全不同了。这就是策略模式最核心的威力。
2.3 为什么需要一个Context,而不是直接让客户端持有策略
有朋友会问:客户端直接new一个策略对象调用pay方法不就好了吗,为什么还要套一层Context?
关键在于“统一收口”。在真实业务里,策略不只是“调一下方法”那么简单。通常在调用策略之前要做参数校验、开启事务、埋点日志,调用之后要处理结果、捕获异常、提交事务。这些公共环节如果散落在每个客户端,就会复制粘贴成灾。
Context就是承载这些公共逻辑的地方。客户端只需要“把合适的策略交给Context,然后调一个入口方法”,剩下的事情Context统一安排。用生活例子类比:策略是司机,Context是导航仪,客户端是乘客。乘客告诉导航仪“我要打车还是坐地铁”,导航仪负责叫车或者规划线路,乘客不需要自己联系司机。换乘方式的时候,乘客只改一个决定,导航仪协调剩下的流程。
2.4 这个骨架里的多态是怎么回事
很多初学者盯着PayStrategy strategy = new BalanceStrategy();这一行发呆,不太明白到底发生了什么。
其实这是Java多态的经典体现。变量声明类型是接口PayStrategy,实际指向的是BalanceStrategy对象。调用strategy.pay(amount)时,JVM会根据运行时的真实对象找到BalanceStrategy里的pay方法执行,而不是看变量的声明类型。一句话:编译看左边,运行看右边。
多态是策略模式的底层机制。正因为有了多态,PayContext才能在不修改自身代码的前提下,用同一个executePay方法去执行任何一个实现了PayStrategy接口的类。这也解释了为什么策略类必须实现同一个接口,否则上下文无法统一调用。
3. 实战改造:把支付模块从“多重分支”变成“一行选择”
3.1 从哪个分支开始动手
回到第一节那个PaymentService。改造第一步不是写代码,而是观察每个分支。观察点有两个:分支的逻辑是否足够重?分支之间的边界是否清晰?
比如余额支付分支里有“余额校验、冻结/扣减、流水记录”三件事,边界清晰,适合整个搬进BalanceStrategy里。扫码支付分支里有“生成二维码、等待回调、处理回调结果”,同样是独立闭环。改造时直接按边界搬即可。
有一件事要特别注意:分支里那些公共逻辑,比如日志记录、参数校验、事务开启,千万不要复制到每个策略类里,那是在制造新的重复。正确的做法是把公共逻辑留在Context或调用入口,策略类只保存“这一个渠道特有”的逻辑。
3.2 定义策略接口与改造后的Service
为了后续扩展方便,策略接口不用pay(double amount)这种裸参数,而是用一个请求对象包一层:
public class PayRequest { private String orderNo; private String channel; private double amount; // 构造器、getter、setter 省略 }策略接口:
public interface PayStrategy { void process(PayRequest request); }这样一来,将来新渠道需要额外参数(比如银行卡要卡号,扫码要回调地址)时,在PayRequest里加字段即可,接口签名不用动。
各渠道的策略类:
public class BalanceStrategy implements PayStrategy { @Override public void process(PayRequest request) { // 1. 校验余额 // 2. 扣减余额 // 3. 记录资金流水 System.out.println("余额支付:" + request.getAmount()); } }BankCardStrategy、ScanStrategy结构类似,不再重复贴代码。每个类只关注一件事:把自己的渠道流程跑完整。
改造后的支付Service:
public class PaymentService { private final PayStrategyRegistry registry; public PaymentService(PayStrategyRegistry registry) { this.registry = registry; } public void pay(PayRequest request) { PayStrategy strategy = registry.get(request.getChannel()); strategy.process(request); } }到这里,PaymentService的核心逻辑只有三行。所有渠道的具体逻辑都在各自的策略类里,新增渠道不再碰PaymentService,开闭原则顺理成章地满足了。
3.3 策略注册表:把“选择”也统一收口
如果客户端每个调用方都要自己根据channel去判断该用哪个策略,那只是把坏味道挪了个位置。所以需要一个集中管理策略的地方,我这里用Map实现一个轻量注册表:
public class PayStrategyRegistry { private static final Map<String, PayStrategy> STRATEGIES = new HashMap<>(); static { STRATEGIES.put("balance", new BalanceStrategy()); STRATEGIES.put("bankCard", new BankCardStrategy()); STRATEGIES.put("scan", new ScanStrategy()); } public static PayStrategy get(String channel) { PayStrategy strategy = STRATEGIES.get(channel); if (strategy == null) { throw new IllegalArgumentException("不支持的支付渠道: " + channel); } return strategy; } }这里有几个设计点值得展开。第一,Map的key用渠道标识,value用策略实例,查表效率是O(1),比连环if-else清爽得多。第二,策略实例在类加载时创建一次,之后一直复用,因为策略类本身是无状态的,复用一个实例没有任何问题。第三,找不到策略的时候直接抛出明确异常,而不是返回null让调用方到处判空。
调用侧变得非常干净:
PayStrategy strategy = PayStrategyRegistry.get(request.getChannel()); strategy.process(request);3.4 在IoC容器里更优雅的做法:注入策略Map
如果项目用的是Spring这类IoC容器,注册表可以更进一步,连手动put都不用了,直接让容器把同一接口的所有实现注入进一个Map:
@Component public class PaymentService { private final Map<String, PayStrategy> strategyMap; public PaymentService(Map<String, PayStrategy> strategyMap) { this.strategyMap = strategyMap; } public void pay(PayRequest request) { PayStrategy strategy = strategyMap.get(request.getChannel()); if (strategy == null) { throw new IllegalArgumentException("不支持的支付渠道: " + request.getChannel()); } strategy.process(request); } }只要策略实现类注册为Bean,IoC容器会把所有PayStrategy类型的Bean收集起来,Map的key默认是Bean名称,value是策略实例。以后新增渠道,只需要新增一个实现类并注册Bean,老代码一行都不用改。这就是依赖注入结合策略模式最典型的落地形态。
用这种方式要留意一个细节:如果某个地方单独注入了PayStrategy,而容器里又存在多个策略Bean,容器会因为候选不唯一报错。这时候需要在注入点用限定注解明确指出要哪一个,或者干脆全部用Map注入,避免歧义。
3.5 改造前后的对比
改造前:一个方法几百行,分支逐步叠加,测试要覆盖所有分支组合才能安心发布,改一处全链路回归。
改造后:一个方法三行,渠道逻辑各归其位,新增渠道加类不修改旧代码,测试直接对着单一策略类写。
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 新增渠道 | 修改核心Service代码 | 新增策略类并注册 |
| 单元测试 | 构造各种分支组合 | 单独测试每个策略类 |
| 公共逻辑 | 与分支逻辑混杂在一起 | 收口在调用入口统一处理 |
| 代码定位 | 在大方法里反复滚动查找 | 按类名直接跳转 |
| 变更影响面 | 改动集中在核心服务 | 改动局限在新增类内部 |
4. 近亲颇多:策略模式与状态模式、模板方法、工厂模式的边界
4.1 最容易混淆的:状态模式与策略模式
行为型模式里,策略模式最容易被拿出来和状态模式比较。两者结构确实像,都是“一个上下文持有一个接口的引用”,连类图都长得差不多,但意图完全不同。
状态模式的灵魂是“状态流转”。状态对象内部持有其他状态的引用,对象状态的改变会引发行为的改变,关键是状态之间的切换是自动的、由业务规则驱动的。比如订单从“待支付”流转到“已支付”,再从“已支付”流转到“已发货”,状态对象自己知道下一个状态是什么,客户端不需要干预。
策略模式的灵魂是“算法替换”。策略之间的切换由客户端主动决定,策略之间互相不感知,没有“下一个策略”的概念。这次用余额支付就用余额支付,下次想用扫码就扫码,完全看调用方的选择。
用生活例子区分最直观:状态模式像地铁运行,列车从一个站开到下一个站,下一站是明确的;策略模式像你要去某个地方,选择打车还是坐公交,这是个自由决定,随时可以后悔换一种方式。
| 对比维度 | 策略模式 | 状态模式 |
|---|---|---|
| 核心意图 | 封装可互换的算法 | 封装与状态相关的行为 |
| 切换者 | 客户端或上下文主动切换 | 状态对象自身流转 |
| 策略/状态之间关系 | 互不知晓 | 可能知道相邻状态 |
| 典型场景 | 折扣计算、支付渠道、消息发送 | 订单状态机、审批流转 |
4.2 模板方法:同骨架换部件,还是整体换算法
模板方法模式也容易和策略模式扯在一起。模板方法的重点在于:定义一个算法的骨架,把某些步骤延迟到子类实现。它的重心是“骨架不变,个别步骤可变”;策略模式的重心是“算法整体一个接一个定义,互相独立,整体替换”。
打个比方:做蛋糕,烘焙流程大体固定——配料、搅拌、烘烤、冷却,变的是配料比例和烘烤温度,这是模板方法;而蛋糕、饼干、布丁是三种完全不同的甜品,做法几乎不共享,选哪个就用哪套完整流程,这是策略模式。
代码层面还有个显著区别:模板方法靠继承实现,子类复用父类骨架;策略模式靠组合实现,上下文持有策略对象引用。经典设计原则里一直强调“组合优于继承”,所以当骨架稳定但步骤多变的时候,优先考虑策略模式而不是模板方法的情况并不少见。
4.3 工厂模式:负责“造”,策略模式负责“用”
工厂模式和策略模式分工不同,却经常合作。工厂是创建型模式,关注“如何创建对象”;策略是行为型模式,关注“如何组织和使用算法”。
上面实现的PayStrategyRegistry,其实就是“简单工厂+策略模式”的合作产物。它既承担了创建策略对象的工作,也承担了选择算法的收口。客户端拿到的是一个策略接口引用,根本不关心具体实现类是怎么new出来的。
一句话总结这三对关系:工厂解决“用哪个实现类的实例”,策略解决“算法之间怎么组织、怎么替换”,模板方法解决“骨架里哪些步骤由子类自定义”。摸清边界,设计模式才不会用成一锅粥。
5. 上手策略模式之后,四个实战细节值得反复提醒
5.1 策略对象要设计成无状态的
最容易翻车的点:在策略类里写可变状态字段。比如在BalanceStrategy里放一个“本次支付金额”的成员变量,多线程环境下两个请求同时进来,金额互相覆盖,资金流水直接对不上。
策略实现类在注册表或IoC容器里通常是单例的,多个调用方共享同一个实例。所以策略类内部尽量不要有可变的成员变量,所有业务数据都应该通过process(request)的参数传入,用完即走。需要临时缓存的中间数据,用局部变量承载,或者用线程局部存储并确保在请求结束后清理。
5.2 策略参数不一致时,接口怎么设计
一个很现实的难题:不同渠道需要的参数差别很大。余额支付需要用户ID和金额,银行卡支付还需要卡号、有效期、验证码,扫码支付要订单ID和回调地址。如果把这些参数全写进PayRequest,这个类会逐渐膨胀成“上帝对象”。
我的做法是:策略接口的参数统一用一个上下文请求对象,里面只放通用字段;渠道差异明显的字段,在请求对象里放一个扩展字段对象或者Map。这样接口签名保持稳定,策略类自己从请求对象里取它需要的部分。要避免的做法是,为了“参数刚好匹配”给每个策略定义不同的接口方法,那会让上下文彻底失去统一调用的可能。
5.3 策略数量膨胀后的治理
策略模式有一个副作用:类的数量会随着算法数量线性增长。二三十个策略类的时候,命名规范和注册方式就变得至关重要。
我一般这样做:策略类名遵守“动作+后缀”的规范,比如BalancePayStrategy、BankCardPayStrategy,一眼看清职责;用枚举或者配置文件记录渠道与策略的对应关系,做配置化注册,而不是散落在多个类里;如果策略实在太多,并且不少策略逻辑相近,先考虑用模板方法模式收拢公共骨架,再考虑组件化拆分。
策略数量多到超过人的理解上限时,问题就变成了模块划分问题,而不是模式选择问题。先把策略按业务域拆成不同包、不同模块,不要让所有策略类堆在一个文件夹里。
5.4 什么时候该克制:策略模式不是if-else的万能解药
最后想吐槽一个现象:为了“消除if-else”而使用策略模式,会把简单问题复杂化。我见过有人把两个简单分支也拆成四五个类的代码,读起来要先跳七八个文件,反而比原来的if-else难维护得多。
策略模式真正适合的信号是:分支里是一整套较重的算法,并且这些算法会持续演化。如果分支又少又轻,直接写if-else,代码反而更诚实。可以借用一个判断标准:如果下次加需求时发现“又要加一个分支了”,而且改动点遍布多个方法,那策略模式值得考虑;如果只是加一行判断,老老实实写清楚就好。
我在实际项目里的习惯是,先把分支里的逻辑写出来,观察它有没有形成独立闭环。独立闭环足够重,才值得拆成策略类。这个习惯帮我避免了很多无意义的抽象。再多看几个例子,回到自己代码里,不要急着重构。策略模式的价值体现在“变化”二字,只有当变化发生、且变化方向明确时,它带来的灵活性才是正向收益。这篇从问题到实战基本把策略模式讲透了,下一篇是行为型模式也是全系列的收尾,到时候见。