1. 策略模式到底解决什么问题
1.1 从一段不断膨胀的if-else说起
几乎每个后端程序员都经历过这种时刻:一个计价方法、一个订单处理流程,或者一个消息推送逻辑,本来只有一两个分支,后来业务方不断提需求,代码就变成了下面这样。
以最常见的电商折扣计算为例:
public BigDecimal calculateFinalPrice(Order order) { if ("VIP".equals(order.getUserType())) { return order.getAmount().multiply(new BigDecimal("0.85")); } else if ("SVIP".equals(order.getUserType())) { return order.getAmount().multiply(new BigDecimal("0.75")); } else if ("NEW_USER".equals(order.getUserType())) { return order.getAmount().multiply(new BigDecimal("0.9")); } else if ("DISABLED".equals(order.getUserType())) { return order.getAmount().multiply(new BigDecimal("0.95")); } else { return order.getAmount(); } }这段代码初看没毛病,逻辑清晰、可读性尚可。但业务不可能停在四五个分支:过几天加了“店庆折扣”,再隔两周加了“会员日叠加折扣”“内购折扣”,到时候这栋if-else大楼会越盖越高,最终长成下面这样:判断条件嵌套着判断条件,每个分支里再塞一段几十行的业务逻辑。后续维护的人想看清楚全貌都要来回滚动好几屏,改一个分支怕影响另一个分支,测试用例补了一堆还是心里没底。
这种代码最要命的地方不在于“长”,而在于每一次新增规则都要去改动一个已经稳定运行的公共方法。今天改折扣,明天影响支付;明天加渠道,后天影响报表。这就是我们常说的“违反开闭原则”——对扩展不友好,对修改太开放。
而策略模式,就是专门用来收拾这种局面的。
1.2 策略模式的三个角色和核心思路
策略模式的思路用一句话概括:定义一组算法,把每个算法封装到独立的类里,让它们可以互相替换。它把一个大流程里的“可变部分”抽出来,变成一个个独立策略对象,原来的大流程只负责持有策略并调用,不关心具体怎么算。
具体由三个部分组成:
- 策略接口(Strategy):定义算法家族的统一调用入口。比如上面折扣计算,可以定义一个
calculate(Order order)方法。 - 具体策略(ConcreteStrategy):每种算法一个类。VIP折扣一个类、SVIP折扣一个类、新用户折扣一个类。
- 上下文(Context):持有一个策略引用,把客户端请求转发给策略执行。上下文本身不实现任何具体算法,它更像一个“中间人”。
打个比方:策略模式就像给机器换插头。主机(上下文)只认“插头规格”,你插两脚的还是三角的都能通电,具体什么电压转换,那是每个插头自己负责的事。而if-else那种写法,相当于直接在主机里焊死一套电路,换个电器就要拆开重新焊。
这种设计带来的好处非常直接:
- 新增算法不需要改动已有代码。再增加一个折扣类型,只需要新增一个策略类,原有代码一行都不用改。
- 每个策略可以独立测试。想验证VIP折扣逻辑,直接拿VIP策略类单测,不需要整个流程跑通。
- 运行时可以动态替换。只要在调用前把上下文里的策略引用换掉,下一次计算就走新逻辑,非常适合配置化、规则化的场景。
1.3 别滥用策略模式
策略模式虽好,但也不是万能的。我得泼一盆冷水:如果你系统里这个分支只有两三种情况,而且基本不会增加,用策略模式反而增加复杂度。原本一个if就完事,现在要写一个接口、两三个实现类、一个上下文,类数量翻了几倍,新手来看代码还要绕半天才能找到真正的算法在哪。
还有一个常见的误区是“硬套策略模式”。比如某个策略类和另一个策略类的差别,仅仅是数值参数不同。这时候把它们拆成两个类纯属自找麻烦,不如一个类做成参数化。我在下文“策略类爆炸”那条坑里会专门展开。
那么到底什么时候值得用?我的判断标准有两个:
- 这个分支在未来大概率会持续增加,或者产品明确说了接下来半年要上多个版本活动规则。
- 这些分支里的算法比较复杂,不是一行公式就能算完的,而是各有一段相对完整的业务处理流程。
如果你看到的是那种一年动不了几次的“恒定分支”,老老实实写if-else反而更实在。设计模式的初衷是应对变化,不是为了写代码写得有仪式感。
2. 一个完整的最小实现:从接口到上下文
2.1 定义策略接口与具体策略类
先看接口怎么定义。接着上面折扣计算的场景,我定义一个计价用的策略接口:
public interface DiscountStrategy { BigDecimal calculate(Order order); }接口就一个方法,输入完整订单信息,返回折后金额。为什么要传整个订单而不是传金额?因为真实的策略往往需要看多个字段:用户等级、订单金额、商品品类、下单时间。如果接口只传金额,后面想加规则就得改接口,所有实现类全部跟着改,那接口就变成瓶颈了。
来两个具体策略:
public class VipDiscountStrategy implements DiscountStrategy { private static final BigDecimal DISCOUNT_RATE = new BigDecimal("0.85"); @Override public BigDecimal calculate(Order order) { return order.getAmount().multiply(DISCOUNT_RATE); } } public class NewUserDiscountStrategy implements DiscountStrategy { private static final BigDecimal DISCOUNT_RATE = new BigDecimal("0.90"); @Override public BigDecimal calculate(Order order) { return order.getAmount().multiply(DISCOUNT_RATE); } }有个细节值得注意:我把折扣率定义为static final常量。这是因为这些策略类本身是无状态的——内部没有任何可变字段,谁调用都只产出结果,不记录中间状态。无状态的类天然线程安全,完全可以做成全局共享的实例,连new都省了。这一点在后面的工程化实践里会再提到。
2.2 上下文类:客户端只接触的门面
有了策略实现,还需要一个上下文。我用一个PriceCalculator来充当这个角色:
public class PriceCalculator { private DiscountStrategy strategy; public void setStrategy(DiscountStrategy strategy) { this.strategy = strategy; } public BigDecimal calculate(Order order) { if (strategy == null) { throw new IllegalStateException("策略未设置"); } return strategy.calculate(order); } }上下文类的职责非常纯粹:持有当前策略,把计算请求转发出去。它不知道也不关心当前用的是哪个具体策略。客户端调用时,先决定用什么策略,然后塞给上下文,再触发计算:
PriceCalculator calculator = new PriceCalculator(); calculator.setStrategy(new VipDiscountStrategy()); BigDecimal price = calculator.calculate(order);这样的好处是,客户端和具体算法之间隔了一层上下文,客户端手里始终拿的是PriceCalculator,而不是直接依赖VipDiscountStrategy这种具体类。将来如果上下文的流程里需要在计算前后加日志、加埋点、加事务,都只改上下文一处,不用动任何策略类。
2.3 用Map替代if-else进行策略路由
上面的例子实现了策略模式,但你会发现客户端仍然要自己写new哪个策略。如果客户端那边又冒出了一长串switch来判断用户类型,那我们只是把if-else搬了个家,复杂度依然还在客户端。
工程上解决这个问题最朴素也最好用的办法,就是用Map做策略注册表:
Map<String, DiscountStrategy> strategyMap = new HashMap<>(); strategyMap.put("VIP", new VipDiscountStrategy()); strategyMap.put("NEW_USER", new NewUserDiscountStrategy()); strategyMap.put("SVIP", new SVipDiscountStrategy()); // 使用时 DiscountStrategy strategy = strategyMap.get(order.getUserType()); if (strategy != null) { return strategy.calculate(order); } return order.getAmount(); // 默认无折扣这样一来,原来if-else一层层比较逻辑,变成了“查Map拿实例”的统一动作。新增一种折扣类型,就是往Map里多放一条put,连方法体都不用进。查找的时间复杂度是O(1),效率也比一串if判断高。
这其实就是策略模式强大的地方:它把“算法的扩展”从修改代码变成添加代码,配合Map等注册机制,客户端不需要感知策略具体类,只需要知道一个类型编码。类似思路可以套用在渠道商选择、支付路由、消息推送渠道、审批流节点等各种各样的业务场景。
3. 工程实战:策略模式和工厂、Spring的组合玩法
3.1 策略工厂:把创建策略的职责收拢起来
实际项目里,Map常驻内存的注册表往往直接放在一个工厂类里。这个工厂负责两件事:接收类型编码,返回对应策略实例。它的存在让策略的“查找逻辑”从客户端代码中剥离,客户端只需要记住一个最大概的接口名称:
public class DiscountStrategyFactory { private static final Map<String, DiscountStrategy> STRATEGIES = new HashMap<>(); static { STRATEGIES.put("VIP", new VipDiscountStrategy()); STRATEGIES.put("SVIP", new SVipDiscountStrategy()); STRATEGIES.put("NEW_USER", new NewUserDiscountStrategy()); STRATEGIES.put("DISABLED", new DisabledDiscountStrategy()); } public static DiscountStrategy getStrategy(String userType) { return STRATEGIES.get(userType); } }然后客户端就变成了:
DiscountStrategy strategy = DiscountStrategyFactory.getStrategy(order.getUserType()); if (strategy != null) { return strategy.calculate(order); } return order.getAmount();这时候你再回头看最初的if-else,代码行数少了,职责也清晰了。而且关键的一点:以后加策略只需要改动工厂的静态初始化块以及新增类,客户端、上下文、其他策略类全部不用动。
工厂里也可以加一个保底逻辑:如果查到null,就返回一个“默认无折扣”策略,而不是把null抛给调用方。这样做的好处是业务方不用到处判空,兜底行为统一由工厂负责,错误概率也低很多。
3.2 有状态策略的线程安全问题
我在前面提过,常见策略都可以做成无状态、全局共享实例。但你必须清楚一个前提:如果某个策略内部定义了可变的成员变量,比如把某次计算的临时结果存在字段里,那在多线程并发访问同一个共享实例时,就会互相串数据,引发诡异的问题。
举一个错误示范:
@Service public class VipDiscountStrategy implements DiscountStrategy { private BigDecimal currentAmount; // 危险!成员变量被多个线程共享 @Override public BigDecimal calculate(Order order) { this.currentAmount = order.getAmount().multiply(...); return currentAmount; } }如果两个请求同时进来,A请求刚写入currentAmount,B请求立刻覆盖,A后续再读时拿到B的数据。这种Bug极难复现,因为你得靠运气才能抓到线程切换的时机。
正确的做法也很简单:策略类里只定义不变量,所有临时计算结果都在方法内部作为局部变量。局部变量存活于线程栈,天然隔离。如果实在需要携带请求上下文,那就先new一个新策略实例再调用,或者把临时数据封装在一个传入传出的上下文对象里。
3.3 Spring项目里的最优雅实现
如果项目用了Spring,那连手写工厂的静态Map都不用,直接利用Spring的依赖注入,把策略接口的所有实现类收集成一个Map,按Bean名字或者自定义注解关联业务编码。
这里有两种常见做法:
做法一:用BeanName做key
@Service public class DiscountService { private final Map<String, DiscountStrategy> strategyMap; // Spring会把这个接口的所有实现类注入进来 // key 默认是 Bean 的首字母小写,所以 get("vipDiscountStrategy") public DiscountService(Map<String, DiscountStrategy> strategyMap) { this.strategyMap = strategyMap; } public BigDecimal calculate(Order order) { String key = strategyKey(order.getUserType()); DiscountStrategy strategy = strategyMap.get(key); if (strategy == null) { throw new IllegalArgumentException("不支持的策略类型"); } return strategy.calculate(order); } }做法二:用自定义注解做key
定义注解:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface StrategyType { String value(); }策略类上标注:
@StrategyType("VIP") @Service public class VipDiscountStrategy implements DiscountStrategy { ... }Service里注入所有策略,自己组装Map:
public DiscountService(List<DiscountStrategy> strategies) { this.strategyMap = strategies.stream() .collect(Collectors.toMap( s -> s.getClass().getAnnotation(StrategyType.class).value(), Function.identity() )); }用注解的好处在于key是显式的,不再依赖类名,业务编码和策略类的对应关系一眼就能看懂。新增策略时只需要写新类、加注解,其他代码全部不用改。这种“自动发现 + 按编码路由”的组合,是我在业务项目里最推荐的一种落地方案。
4. 常见问题与实操心法
4.1 坑一:策略接口的方法参数不够用
很多初学者把策略接口的方法参数定得太“死”,比如只传一个BigDecimal amount。结果做第二个策略时发现还需要用户等级、商品品类等字段,只能改接口,连带着所有实现类一起改。改一次两次还能接受,改得多了你就会发现接口形同虚设。
更好的做法是考虑用一个上下文对象来传递所有可能用到的输入,像我的例子里直接传整个Order。如果业务结构复杂,还可以单独定义一个DiscountContext:
public class DiscountContext { private User user; private Order order; private List<CartItem> items; // 各种业务输入 }策略方法签名统一是calculate(DiscountContext ctx),具体某个策略需要哪些字段,自己从ctx里取。这样即使以后增加新输入,也不用改策略接口,只需要在上下文对象里加字段。代价是策略类之间复用上下文时,会有一定程度的字段冗余——但比起改所有策略类签名,这点冗余完全可接受。
4.2 坑二:Map查不到策略时直接返回null
有些团队在工厂里很随意地return STRATEGIES.get(type),客户端判断if (strategy != null)后走默认逻辑。这其实是把“null处理”的责任扔给了调用方,调用方一旦漏判,线上就会出现空指针异常。
我的建议是工厂里内置一个兜底策略:
public class DefaultDiscountStrategy implements DiscountStrategy { @Override public BigDecimal calculate(Order order) { return order.getAmount(); // 不打折 } } // 工厂中 public static DiscountStrategy getStrategy(String userType) { return STRATEGIES.getOrDefault(userType, new DefaultDiscountStrategy()); }这样下游永远不会拿到null,也不会因为某个未知渠道突然打过来而直接报错。所谓兜底策略,本质上是把“未知情况的处理策略”也显式建模了,非常符合策略模式“一切算法都可封装”的思想。
4.3 坑三:策略类爆炸和过度拆分
策略模式如果用得过头,会带来另一种坏味道:每个策略一行算法,却拆出十几个类。这么搞代码确实“符合模式”了,但可维护性没有提升,反而让阅读代码的人需要打开一堆文件才能拼凑出全貌。
我的经验是,判断两个策略是否应该合并,看它们的差异点是什么。如果差异只是数值参数不同,合并成一个类,内部放参数即可。比如VIP 9折、SVIP 8折、企业客户7折,完全可以做成同一个“等级折扣策略”,构造时传入折扣率:
public class LevelDiscountStrategy implements DiscountStrategy { private final BigDecimal rate; public LevelDiscountStrategy(BigDecimal rate) { this.rate = rate; } @Override public BigDecimal calculate(Order order) { return order.getAmount().multiply(rate); } }但注意,这里的“合并”指的是折扣这一类算法完全相同的场景。如果各个策略的处理流程本身差异很大,比如一个是“按比例打折”,另一个是“满减后叠加免邮”,那就不要强行合并,硬合并的结果是策略类里塞满if,退回到了起点。
4.4 常见症状排查速查表
| 症状 | 可能原因 | 排查顺序 |
|---|---|---|
| 调用策略后结果和预期不符 | 策略Map里的key映射错了 | 先确认策略类上的注解值或BeanName,再确认传入的业务编码 |
| 偶发数据串味 | 策略类内部有可变成员变量 | 检查策略类所有字段,临时数据必须局部变量化 |
| 新增策略不生效 | Spring没扫到新类或Map没更新 | 先看类有没有加 @Service,再看策略名是否重复 |
| 客户端抛空指针 | 工厂直接返回null | 改用 getOrDefault + 默认策略 |
| 策略类数量爆炸 | 参数化策略被拆成多个类 | 合并逻辑相同、仅参数不同的策略为带参数的单类 |
4.5 判断是否值得改造的实用建议
最后分享一个我自己的判断公式,不是教条,是长期看代码后的直觉。面对一个系统,要不要引入策略模式,我会先问三个问题:
- 这个分支以后会不会继续增加?产品说“现在就这样了,以后不好说”,那多半会加。
- 每一个分支的算法是不是都在50行以上?如果每个分支简单到只有一行返回,那重构带来的收益抵不上类数量增加的成本。
- 团队里其他人能不能一眼看懂这个Map的注册方式?模式是给团队用的,不是展示给代码检查工具看的。如果团队普遍不熟悉策略模式,引入前最好先做一次内部分享,不然下一个接手的人会觉得你在炫技。
按我的经验,最值得改造的典型场景是:订单计价、优惠券计算、支付渠道路由、消息推送渠道选择、审批流节点处理、数据导出格式转换、异常告警级别判定。这类场景共性很强,规则多、变化频繁、而且往往要求后续扩展不影响核心链路。
我去年接手过一个老系统,里面一个订单计价方法有将近五百行,嵌套了十几层if-else,各种活动规则交叉作用。当时带着两个人,把计价流程按“基础价计算、活动优惠、优惠券叠加、运费计算”四层拆分,每一层内部再用策略模式整理,整个过程大概花了两周。改完之后同一段逻辑的测试用例从原来很难构造变成了每个策略类独立构造、独立验证。上线后新增了三次营销活动,都是只加类、注册一条Map,核心流程再也没碰过。那次彻底让我确信:真正好用的策略模式,不是写出来的,是应对变化逼出来的。