1. 项目概述:从一次订单计价重构说起
策略模式这个名词,干过一段时间后端的应该都不陌生。打开IDE搜一下,规模稍微大一点的工程里基本都能看到一堆以Strategy结尾的类。这篇是这个设计模式系列的第五篇,我们专门来聊策略模式。我会从一个真实的订单计价需求切入,一步步展示从if-else主体逻辑演进到策略模式的完整过程。
我接手过一个老项目,核心功能是商城订单的价格计算。表面看很简单,下单时根据用户级别套用不同的折扣规则。但这个模块的代码已经膨胀到让人头皮发麻,一个OrderPriceCalculator类差不多有两百多行,全是if/else嵌套。每次要加新的用户类型,所有在它上面做二次开发的同事都心惊胆战——怕改错逻辑,更怕影响已有用户的折扣。这篇文章就是围绕这个场景展开的,适合正在被业务分支折磨的后端开发者,也适合想系统梳理策略模式适用边界的人。看懂之后,你会明白策略模式解决的核心痛点是什么,也会清楚它带来的新问题有哪些,不至于在重构时把代码从一个坑挪进另一个坑。
1.1 核心需求解析:一个订单计价需求引发的痛点
需求本身不复杂:系统里有普通用户、VIP用户、企业用户和内部员工四类人,每类人的订单折扣规则不同。
普通用户原价购买。VIP用户根据历史订单数量区分两档,订单超过10单打85折,否则打9折。企业用户进一步区分规模,订单超过100单打7折,其余打8折,并且订单金额满1000元还额外减100。内部员工直接打8折,但要求订单的实付金额不能低于成本价的一半,否则需要走人工审批。
你看,真正的业务逻辑永远不是一句“不同用户不同折扣”这么干净。真实场景里总有各种叠加规则、兜底规则、特例规则。最初的实现也确实是“最直观”的做法,在一个方法里写满if/else,按照用户类型一路判断下去。刚开始只有两类用户时还看得过去,加着加着就失控了。我接手时这个类的状态是:方法里有一个switch套三个if套两个for,将近两百行,注释只有一行:“加折扣规则前先问我。”更麻烦的是,里面还混着一些临时凑出来的逻辑,比如针对某个大客户的定向折扣,直接硬编码了一个特殊账号的UUID判断。
这种代码的特点是:改一行,旁边三行跟着炸。表面原因是分支太多看不清完整影响面,根因是算法逻辑和调用方、数据对象强耦合在一起。新增一个用户级别时,你必须读懂整个历史逻辑,证明自己加的这个分支和前面所有分支互不影响,才能动手。而且测试用例没法写,因为要覆盖的分支组合数量是指数级的。
1.2 为什么if-else方案最终会失控
与其抽象地讲“违反开闭原则”,不如直接看坏味道在哪。原来的代码长这样:
public class OrderPriceCalculator { public BigDecimal calculate(BigDecimal amount, UserLevel level, int orderCount) { BigDecimal result = amount; if (level == UserLevel.NORMAL) { return result; } if (level == UserLevel.INTERNAL) { // 内部员工,原价基础上打8折 result = amount.multiply(new BigDecimal("0.80")); if (result.compareTo(costPrice) < 0) { throw new IllegalStateException("need manual approval"); } } else if (level == UserLevel.VIP) { if (orderCount > 10) { result = amount.multiply(new BigDecimal("0.85")); } else { result = amount.multiply(new BigDecimal("0.90")); } } else if (level == UserLevel.ENTERPRISE) { if (orderCount > 100) { result = amount.multiply(new BigDecimal("0.70")); } else { result = amount.multiply(new BigDecimal("0.80")); } if (amount.compareTo(new BigDecimal("1000")) >= 0) { result = result.subtract(new BigDecimal("100")); } } return result; } }看着还挺整齐,但问题已经存在了。读到这段代码的人,必须先理解UserLevel这个枚举的每一个值,再顺着每个分支里的二次判断走一遍。当需求变成“某类用户在特定促销期间的折扣不同”时,就得在这个方法里再加一层判断。每个分支里面都在重复“取金额、乘折扣”这套动作,但又不敢抽成一个通用方法,因为每个分支之间总有细微差别,一抽方法就要带四五个参数,比不抽还乱。
我统计过这个类的改动次数。半年里它一共被改了23次,每次需求都提心吊胆。有一次为了支持一个新用户类型,改动影响到了VIP的判断顺序,结果线上VIP用户下单时走进了新用户类型的分支,折扣全错了。问题定位花了三个小时,原因就是分支嵌套太多,肉眼根本看不出优先级。
后来实在撑不住了,我把方法里的所有分支彻底摊开梳理了一遍,做了一个决定:不再加if/else,而是把每种用户类型的折扣计算逻辑各自抽成一个独立的类,由调用方按需选择。这就是策略模式的核心思路。
2. 策略模式的核心设计思路与原理
策略模式的定义其实很简单:定义一族算法,把它们分别封装起来,让它们之间可以互相替换。这句话读起来很顺,但真正落地时大多数人还是拿不准“封装”封装到什么程度、“替换”由谁来替换。我拆开讲。
2.1 三个角色:策略接口、具体策略、上下文
策略模式里有三个经典角色。第一个是策略接口,定义一组算法的统一入口,通常是一个接口,里面有一个核心计算方法。第二个是具体策略类,实现策略接口,内部包含具体的算法逻辑。第三个是上下文,持有一个策略引用,负责把客户端的调用转发给当前策略。
用订单计价的例子对应一下:策略接口就是DiscountStrategy,里面定义calculate(Order order)方法。具体策略包括VipDiscountStrategy、EnterpriseDiscountStrategy、InternalDiscountStrategy。上下文就是订单价格计算服务,它自己不做折扣判断,而是把计算委托给持有的策略对象。
上下文为什么要存在?这是策略模式里一个很关键的设计。客户端可以不直接使用策略接口,那样的话客户端依然要知道“有哪几种策略、各自构造参数是什么”。上下文把这个复杂性接住了:客户端只需要和上下文打交道,上下文内部再选择策略、调用策略。对于调用方来说,它根本感觉不到策略模式的存在。
这个“调用方无感”的好处,在大型系统里非常明显。调用方只关心“给出订单,得到价格”,至于内部是用if-else还是用策略模式,对调用方透明。也就是说,你可以在不改变对外接口的情况下,把一整块分支逻辑重构成策略模式,这是它比很多模式更适合用来做遗留系统改造的原因。
2.2 开闭原则在策略模式下如何落地
开闭原则说的是“对扩展开放,对修改关闭”。代码世界里从来没有绝对的“不修改”,这句话的真实意思是:当需求变化时,应该尽量保证既有代码的改动量最小、风险最可控。
if-else版本里,新增一种用户类型,你必须修改OrderPriceCalculator这个已经稳定运行的类,这就是“对修改开放”。而策略模式下,新增一种用户类型,只需要新增一个实现类,然后把它注册进策略选择的地方,原来的策略类完全不用动。
这不是说策略模式不需要修改任何代码。你总得有个地方告诉系统“新策略来了”,比如在一个工厂里新增一个map条目,或者在新策略类上加个注册注解。但关键是,修改的地方从“整个计算逻辑”缩小到了“一个注册点”,风险范围完全不同。
另外还有一层好处:策略类之间天然隔离。改动VIP策略的代码时,完全不用担心影响企业用户策略,因为它们各自在独立的类里,没有共享的局部变量,也没有前一个分支影响后一个分支的顺序依赖。
2.3 别和状态模式、模板方法模式混在一起
很多人在学习设计模式时容易把策略模式、状态模式、模板方法模式搞混,因为它们都涉及“行为的变化”。我的区分方式很简单。
策略模式关注的是“同一件事的不同做法”,客户端明确知道自己在选哪条路,切换通常发生在运行时,但决策方是客户端或外部条件。状态模式关注的是“对象自身状态变化引起的行为变化”,状态的转移由对象内部自己管理,客户端并不知道、也不关心对象当前处于哪个状态。最典型的例子是订单状态流转:已支付和已发货状态下,同一个“取消订单”操作的行为完全不同,这是状态模式。
模板方法模式则不同,它把算法的骨架放在父类,把可变步骤留给子类实现。策略模式是整体替换一个算法,模板方法是固定框架内替换某个步骤。前者是“换一套做法”,后者是“一样的流程,个别步骤换成不同的”。
我在项目里见过一个写得非常乱的状态机,本质上该用状态模式,结果用策略模式实现了。每种订单状态写一个策略类,然后由外部代码在状态流转时手动切换策略。最后代码能跑,但状态流转逻辑全散落在各处,排查问题时得把所有策略类翻一遍。所以策略模式虽好,用错场景一样是灾难。
3. 重构实战:从if-else分支到策略模式的改造实录
理论讲完,上实操。我以订单计价模块为例,带着完整的步骤和代码,尽量把当时重构的每一步决策依据也写出来。
3.1 第一步:定义策略接口,明确统一入口
定义一个策略接口,是所有后续工作的基础。它不需要复杂,但方法签名必须仔细想清楚。如果参数太少,将来某些策略需要额外数据时就得改接口,影响所有已有实现。如果参数太多,又会把不相关的东西传进来。
我当时的做法是:让策略方法只接收一个订单对象作为参数,所有策略需要的数据都从订单对象里取。这样参数列表稳定,后续扩展时只要订单对象上有对应字段就行。如果将来的需求里出现了不属于订单的数据,比如“当前时间在不在促销期内”,我会考虑引入一个更通用的DiscountContext,把订单、时间、用户上下文都放进去。这是策略模式实战中一个非常实用的技巧:参数用上下文对象而非单个字段,能有效降低接口变更频率。
public interface DiscountStrategy { BigDecimal calculate(Order order); }这里没有搞复杂的泛型,也没有刻意去加什么默认方法,因为那个阶段最重要的目标是让已有的几个计算逻辑各自归位,而不是让接口看起来多灵活。我建议你也这样起步,不要一上来就设计一套闪闪发亮的抽象体系。
3.2 第二步:实现具体策略类,让每个计算逻辑独立上场
然后是每个用户类型各自一个策略类。这里我犯过一个错:一开始把策略类写得和原if-else里的分支一模一样,包括那些和历史需求相关的代码。后来想想不对,策略类逻辑本来就该独立,之前分支里的顺序依赖已经不存在了,没必要再保留。
以VIP策略为例,原逻辑是:订单数超过10单打85折,否则打9折,还要站在上一段的思路去审查这一策略需要满足的各种边界。独立成类之后,代码就清爽了。而且类名可以非常直白地表达业务语义,新人接手时看到VipDiscountStrategy就知道这是VIP用户的折扣策略。
public class VipDiscountStrategy implements DiscountStrategy { private static final BigDecimal VIP_DISCOUNT_HIGH = new BigDecimal("0.85"); private static final BigDecimal VIP_DISCOUNT_LOW = new BigDecimal("0.90"); @Override public BigDecimal calculate(Order order) { if (order.getOrderCount() > 10) { return order.getAmount().multiply(VIP_DISCOUNT_HIGH); } return order.getAmount().multiply(VIP_DISCOUNT_LOW); } }企业用户策略稍微复杂一点,既要打折扣又要做满减:
public class EnterpriseDiscountStrategy implements DiscountStrategy { @Override public BigDecimal calculate(Order order) { BigDecimal rate; if (order.getOrderCount() > 100) { rate = new BigDecimal("0.70"); } else { rate = new BigDecimal("0.80"); } BigDecimal result = order.getAmount().multiply(rate); if (order.getAmount().compareTo(new BigDecimal("1000")) >= 0) { result = result.subtract(new BigDecimal("100")); } return result; } }内部员工策略除了折扣还有审批阈值判断:
public class InternalDiscountStrategy implements DiscountStrategy { @Override public BigDecimal calculate(Order order) { BigDecimal result = order.getAmount().multiply(new BigDecimal("0.80")); if (result.compareTo(order.getCostPrice()) < 0) { throw new NeedApprovalException("price below cost, need manual approval"); } return result; } }这三个类写完后,我明显感觉到一件事:每个策略的内部逻辑被压缩到了自己的一亩三分地里,看代码的时候不用再同时脑补另外三种用户类型的分支和边界条件。这就是策略模式在可读性上最直接的收益。
这里补充一个细节思考:为什么这些策略类没有把costPrice等字段放进构造函数或成员变量?原因很简单,策略对象会作为单例被多个线程共用,如果它内部持有可变的余额、临时计算结果之类的字段,并发场景就会出问题。这个点我后面还会单独展开,属于策略模式最容易踩的坑之一。
3.3 第三步:改造上下文,把选择和执行分开
有了策略类之后,还得有一个地方把它们接起来。策略模式里这个角色叫上下文,但在真实工程里,它往往以一个Service的形式存在。我当时的做法是抽了一个OrderPricingService,对外暴露calculate(Order order),对内根据订单用户类型查对应的策略并执行。
改造的关键点在于:查询策略的过程要专注且稳定,不能被业务逻辑干扰。第一版我是这样写的:
public class OrderPricingService { private final Map<UserLevel, DiscountStrategy> strategyMap; public OrderPricingService() { strategyMap = new EnumMap<>(UserLevel.class); strategyMap.put(UserLevel.VIP, new VipDiscountStrategy()); strategyMap.put(UserLevel.ENTERPRISE, new EnterpriseDiscountStrategy()); strategyMap.put(UserLevel.INTERNAL, new InternalDiscountStrategy()); } public BigDecimal calculate(Order order) { DiscountStrategy strategy = strategyMap.get(order.getUserLevel()); if (strategy == null) { return order.getAmount(); } return strategy.calculate(order); } }EnumMap是一个容易被忽略但非常好用的集合类。它专门针对枚举键做了优化,底层是数组,遍历和查找性能比HashMap更稳定。而且EnumMap的键可以按枚举声明顺序遍历,这在对策略做兜底或日志输出时很有用。
这里还有一个细节值得注意:strategyMap.get()的结果可能为null,因为调用方可能会传入一个尚未注册的新用户类型。我在这里做了一个默认兜底——查不到策略时,按原价返回。这个兜底逻辑看起来简单,但它避免了新用户类型上线时引发空指针事故。后面讲坑的时候还会重点说。
有同事当时问我:这个OrderPricingService怎么不算严格的策略模式上下文?它和经典定义里的上下文不太一样,经典定义里上下文持有的是单个策略,由客户端传入。而这里是上下文自己根据订单决定策略。这其实是策略模式在工程里的常见变体,叫“按需选取策略的上下文”。它的好处是客户端彻底无感知,坏处是上下文和策略注册表耦合得比较紧。但对于大多数业务项目来说,这种变体更实用。
3.4 第四步:策略选择和注册,从硬编码到扫描式
上面的代码虽然能工作,但有一个明显问题:strategyMap的初始化是手写put。每次新增策略类,都要记得回来改这里,忘了就等着线上出问题。
如果项目里没有使用Spring这类容器,手写注册表是可以接受的,因为它集中、清晰、容易理解。但我那个项目用的框架支持自动装配,于是我换成了让Spring收集所有策略Bean的方式。
@Component public class OrderPricingService { private final Map<String, DiscountStrategy> strategyMap; public OrderPricingService(Map<String, DiscountStrategy> strategyMap) { this.strategyMap = strategyMap; } public BigDecimal calculate(Order order) { DiscountStrategy strategy = strategyMap.get(order.getUserLevel().name()); if (strategy == null) { return order.getAmount(); } return strategy.calculate(order); } }这段代码里注入的不再是单个DiscountStrategy,而是Map<String, DiscountStrategy>。框架会自动把容器中所有DiscountStrategy类型的Bean收集起来,key是Bean的名字,默认就是类名首字母小写。这样新增一个策略类,只要加上@Component注解并遵守驼峰命名,就能被自动注册,不再需要手动改OrderPricingService。
我自己很喜欢这种方式,因为它把“注册”这一步从人工记忆变成了容器扫描,从机制上避免了漏注册。但也要注意:策略Bean的key是类名,如果团队成员习惯了给Bean取别名,比如写了@Component("vipStrategy"),那么Map的key就不再是vipDiscountStrategy,查询时很容易踩到key不匹配的坑。
后来我把策略接口增加了一个type()方法,让每个策略自己声明自己的业务类型,注册Map时用这个方法的返回值做key。这样策略的命名和注册key解耦,比依赖类名稳得多。
public interface DiscountStrategy { BigDecimal calculate(Order order); String type(); } @Component("vip") public class VipDiscountStrategy implements DiscountStrategy { @Override public String type() { return "VIP"; } }3.5 进阶玩法:用Lambda和枚举进一步压缩模板代码
如果你接手的是一个不需要严格OO风格、团队也接受了函数式编程的项目,策略模式在Java 8之后的写法可以更轻量。策略接口里只有一个方法的时候,它天然就是一个函数式接口,具体策略类可以直接用Lambda表达式或者方法引用替代。
假设现在需求极其简单,每种用户级别就是返回一个折扣系数,根本不需要单独写一个类。那可以这样:
public enum UserLevel { NORMAL(amount -> amount), VIP(amount -> amount.multiply(new BigDecimal("0.90"))), ENTERPRISE(amount -> amount.multiply(new BigDecimal("0.85"))), INTERNAL(amount -> amount.multiply(new BigDecimal("0.80"))); private final UnaryOperator<BigDecimal> discount; UserLevel(UnaryOperator<BigDecimal> discount) { this.discount = discount; } public BigDecimal apply(BigDecimal amount) { return discount.apply(amount); } }这种写法的优点非常明显:类数量从N个策略类压缩到1个枚举,代码量大幅下降,而且从枚举定义里就能一屏看全所有用户类型的折扣规则。但它也有代价:当某个策略的计算逻辑变复杂,比如企业用户要同时判断订单数、金额满减、还要和成本价比价,塞在Lambda里就会变得很难阅读。我的判断标准是:策略逻辑超过五行,或者包含两个以上的业务条件,就不该塞在枚举/Lambda里,老老实实建类。
还有一点要提醒:不要把枚举策略和类策略混在一个Context里处理,否则选择逻辑会变得分裂。我见过一个项目,一部分策略写在枚举里,一部分策略写在类里,Context的代码不得不用if (userLevel == UserLevel.X) strategy = ... else strategyMap.get(...)来处理,反而比原来的if-else更绕。
4. 策略模式实战中容易踩的五个坑
策略模式不是银弹。我用这个模式重构过很多模块,也见过不少翻车现场。下面的五个问题是我觉得最有代表性的,每一个都对应真实故障或维护噩梦。
4.1 策略类爆炸:每加一个规则就要新建一个类吗
这是策略模式被吐槽最多的一点。原本一个方法里加个分支就行,重构之后每次新增规则都要新建类、改注册、补测试,工作量反而变大了。于是有的团队开始抵触策略模式,觉得它就是“把简单问题复杂化”。
我的应对方式分三层。
第一层:如果策略只是简单的一行计算,比如固定折扣率,直接放进枚举,不要建类。第二层:如果策略之间存在共用逻辑,比如很多策略都要做“满减后再打折”,用模板方法模式辅助,把共用骨架放到抽象父类里,子类只实现差异部分。第三层:真正业务逻辑独立、将来会各自变复杂的策略,才独立成类。
关键是不要一上来就预设每个用户类型一个策略类。很多项目的策略维度不止一个,比如“用户级别”和“促销活动”两个维度交叉,如果每个组合都建一个策略类,那确实是灾难。更合理的做法是把策略拆到单一维度,组合逻辑放在上下文或者专门的组合策略里。
4.2 策略选择逻辑到底该放在哪里
我见过完全意义上的“策略模式反面教材”:客户端代码里全是选择策略的if/else。比如在Controller里根据用户级别不停地if,选完策略再传到Service里。这样做等于把策略模式的收益全部抵消了,选择逻辑依然散落在各处,只是多了一堆策略类而已。
正确的做法是让选择逻辑集中在一个地方。要么在上下文中根据输入参数选,要么在工厂里选,要么用框架的自动收集机制注册。原则只有一个:客户端不应该知道策略类的存在。如果你看到业务Controller里出现了策略类的名字,那基本说明设计走偏了。
我个人的习惯是:把“选择策略”这件事放到一个专门的策略工厂里。如果框架支持自动装配,就把工厂做成一个Component,持有所有策略。如果不用框架,就维护一个注册表。这样后续想加缓存、加监控、加默认策略,都有明确的位置。
4.3 有状态策略与无状态策略:并发场景的隐形炸弹
策略模式本质上是让多个策略对象在运行时被共享调用,尤其配合容器注入时,所有请求用的往往是同一个策略实例。这就产生了一个容易被忽视的问题:策略类不能持有可变的中间状态。
犯过这个错的案例我印象很深。当时某同事实现了一个策略类,在类里加了一个private BigDecimal tempResult;用来缓存计算过程中的中间值。单测跑起来一切正常,可一上生产,线程A算完折扣往tempResult里写了个值,线程B刚好读到这个还在中途的值,价格计算立刻出错,而且特别难复现。
从那以后我定了条规矩:策略类必须无状态。所有临时计算结果用局部变量,所有策略共享的配置放在构造函数参数或外部配置里,不做任何可变的成员变量。如果你确实需要在策略里持有数据,那说明这个策略不该作为单例共享,应该每次创建新实例,或者把数据放到参数对象里传递。
4.4 别为了消除if-else而使用策略模式
这个坑我太熟悉了。我曾经也陷入过“代码洁癖”,看到一个方法里有5个if-else,第一反应就是用策略模式把它干掉。后来想明白了:if-else本身没有错,错的是分支里的逻辑太复杂、分支会频繁变化。
如果一段分支逻辑只涉及两个选择,而且半年都不会新增,那把它改造成策略模式就是在制造不必要的抽象。比如项目里有个exportReport(format)方法,只有PDF和EXCEL两种格式,且几年都没变过。这种场景用个简单的if-else反而比建两个策略类、一个工厂、一个Context更符合实际。
判断是否使用策略模式的实用标准就三条:一是分支逻辑确实复杂,每个分支都值得独立理解和维护;二是分支变化频率高,或者已经出现频繁新增的现象;三是这些分支在未来的演进方向上彼此独立。三条都不满足,就别动刀。
4.5 空指针和默认策略:注册表查询的最后一公里
策略注册表有个典型的边界情况:查不到对应的策略时怎么办。很多人的第一版代码是直接get()然后放心地往下走,结果一个没注册的新类型传进来,马上空指针。
处理方式并不复杂:查询时明确兜底策略。可以是默认原价策略,也可以抛一个业务异常,这要看业务语义。比如“未知用户类型”本身属于不该发生的情况,那就可以抛异常提示;如果属于正常的渐进上线流程,就返回一个默认策略。
如果使用框架自动收集策略的方式,还需要额外检查一个问题:Map可能是空的。如果容器里一个策略实现类都没扫到,注入的Map是空Map而不是null,代码里直接遍历没问题,但get()返回值一定是null,还是要兜底。我在测试中就踩过一次,因为包扫描路径配错,所有策略类都没被扫码,注入了一个空Map,服务启动很顺利,但每个下单请求都走到了兜底逻辑,价格全按原价算。
5. 常见问题与排查技巧实录
这部分直接放一个速查表,然后再挑几个我实际遇到过的排查案例展开讲讲。
| 常见问题 | 表现 | 根因 | 排查方向 | 解决方案 |
|---|---|---|---|---|
| 策略未注册 | 新增用户类型下单报错 | 策略类没被扫描或没put | 检查注册表和包扫描路径 | 使用容器自动收集,查不到兜底 |
| 策略Map为空 | 所有订单都按默认价计算 | 包扫描配置错误 | 启动日志查看Bean数量 | 修改扫描路径,增加启动期校验 |
| 并发价格错乱 | 偶发折扣不对,很难复现 | 策略类内部有可变字段 | 审查策略类成员变量 | 策略无状态化,用局部变量 |
| 策略key对不上 | 部分用户类型命中默认策略 | 枚举名和策略注册key不一致 | 打印Map全量key比对 | 策略接口自带type声明 |
| 策略选择散落 | Controller里直接new策略类 | 选择逻辑没有收敛 | 全局搜索Strategy引用 | 引入工厂或上下文 |
| 新策略覆盖不全 | 测试没跑到新策略 | 测试只覆盖了老分支 | 按策略类维度补用例 | 策略独立单测 + 注册表集成测 |
5.1 排查案例一:启动后所有订单都走默认策略
这个故障的排查过程值得一说。现象是某天上线后,线上突然没有任何折扣了,所有订单都是原价。由于策略模式有兜底逻辑,系统没有报错,但这反而让问题更难发现。
我先查了策略注册表,发现Map里确实有数据,但只有两个策略,而用户级别有四个。进一步看,新的策略类根本不在包里。再查包扫描路径,发现新增的策略类被放在了一个新包下,而框架的扫描路径没有包含这个包。这个问题的教训是:策略类的新增依赖于“放到指定包路径下”的约定,而约定靠人记,总有记漏的时候。
后来我在服务启动完成时加了一个断言:检查注册表里的策略数量和用户级别枚举数量是否一致,不一致就直接启动失败。这样相当于把约定变成强制校验,后续再漏注册,服务根本起不来。
5.2 排查案例二:并发环境下VIP折扣突然变成了企业折扣
这个问题的排查当时花了一整天,非常折磨人。现象是有用户反馈VIP订单偶尔显示打的是7折,也就是企业大客户折扣。看代码看不出问题,因为策略选择的逻辑完全正确,EnumMap的key也没错。
后来把目光转向策略对象本身,发现某个策略类里有一个private Map<String, BigDecimal> rateCache,用来缓存金额和折扣系数的映射。两个请求同时到达,线程A把VIP的金额放进了缓存,线程B读缓存时刚好拿到这个还没更新的值,就用了错误的折扣系数。
这类问题的共同点是:表面现象都和策略选择有关,真正的根因却在共享数据的可见性上。排查时不要盯着策略匹配逻辑,先把所有策略类的成员变量过一遍,找那种“被多个线程共写共读”的字段。
5.3 测试策略代码的正确打开方式
策略模式代码的测试比if-else好写得多。最核心的测试单元是每个策略类,直接构造对应对象,传入不同参数,断言输出。这样每个策略的规则都能独立覆盖,不会因为分支嵌套而漏测。
比如测试EnterpriseDiscountStrategy,只需要验证三件事:订单数超过100时打7折,订单数不超过100时打8折,金额满1000时有额外满减。三个用例覆盖完,整个策略的行为就锁死了。
更近一步,我会在集成测试里只测注册表的行为:给定一个用户类型,查出来的是不是期望的策略类。这样既保证策略本身的正确性,也保证策略选择的正确性。
这里还有一个小技巧:策略类最好提供Package-private的构造函数或者直接使用默认构造器,方便测试中直接new。如果策略构造函数里依赖了比较复杂的外部服务,可以考虑把外部依赖抽象进构造函数参数里,测试时传入mock对象。
6. 一点收尾的个人体会
策略模式是我在实际项目里用得最多的设计模式之一,但同时也是被滥用得最严重的一个。我见过把简单的两分支if-else硬生生重构出六个类的项目,也见过策略模式落地后让团队半年不用改价格计算代码的正面案例。差别不在模式本身,在于你有没有想清楚变化的边界在哪里。
我个人的体会是:策略模式的核心价值不是消除if-else,而是把“变化点”从一大段逻辑里单独拎出来,给它一个固定的入口、一个清晰的归属。判断要不要用,不要看当前有多少个分支,要看未来这段逻辑是不是真的会长出新的分支。
最后分享一个小技巧:重构时可以先把原来的if-else完整保留着,把新策略类和原逻辑并行跑一段时间,线上对比计算结果是否一致。等确认完全对齐了,再删掉旧代码。这样做虽然多花一点时间,但能让你在重构时敢于快步前进。如果你也在焦虑手头那段分叉复杂的业务逻辑,不妨按这篇文章里的思路走一遍,先把一个最简单、最独立的策略拆出来,就会感受到不一样的体验。