先问你一个问题:一个上线三个月、迭代了十几版的会员系统,核心结算方法里挤满了 if-else,加一个新会员等级得小心翼翼地上线,你怕不怕?
我不只遇到过,还曾亲自把这种代码交上去,后来被测试同学一记“新增等级后旧会员折扣全错”的 Bug 拍在脸上。也就是从那时候开始,设计模式里的策略模式(Strategy Pattern)被我当成后端代码里最重要的行为型模式之一。这篇东西我就围绕它展开,从它到底解决什么问题、三个核心角色怎么理解,到 Java 手写实现、和简单工厂模式怎么区分搭配,最后再分享一些 Spring 项目里的落地姿势和踩坑记录。不管你是正在补设计模式期末作业,还是被公司里乱七八糟的分支逻辑折磨得想重写项目,这篇都能给你一套可以直接抄作业的思路。
1. 策略模式到底是在解决什么问题?
1.1 几乎所有项目都会遇到的价格计算场景
举个最常见的例子:电商系统里做订单折扣,需求刚上线时特别简单,普通会员不打折,黄金会员打 95 折,钻石会员打 8 折。产品经理说得很轻松,但也暗示了之后会不断加新规则。于是第一版核心代码只需要一个方法,返回最终价格就行。当所有人都在庆祝上线的时候,没人意识到这个“以后加规则很轻松”的话,会成为三个月后全体开发人员的噩梦。
最直接的实现是什么?if-else 或者 switch。这个方法体初期看起来干净利落:
public double finalPrice(String userType, double amount) { if ("NORMAL".equals(userType)) { return amount; } if ("GOLD".equals(userType)) { return amount * 0.95; } if ("DIAMOND".equals(userType)) { return amount * 0.8; } throw new IllegalArgumentException("未知会员类型"); }这种写法真的有问题吗?如果“会员类型只有三种且永远不变”,那它完全没问题,反而还简单直观。但真实业务不是这样的。运营会告诉你“下个月上超级会员,打 7 折,老会员要参与满减活动”,最终还是会在同一个方法里叠加 an extraelse if。
再过两个迭代,这个方法就开始膨胀:折扣、满减、运费、优惠券……几百行的 if-else 堆在一起,没有一个人敢动它,仿佛这代码是自己长出来的,谁碰谁出事。这种情况就是典型的“面向分支编程”,代码越写越累。
1.2 分支代码的“坏味道”到底出在哪
我看过不少新手写到这里就开始背概念,说 if-else 拆开就是策略模式。这种理解方向是对的,但更重要的是你得明白 if-else 到底哪里让人难受,否则你就算套上策略模式的壳,写出来的代码照样是一坨。
分支代码的坏味道,我总结成四点:
- 违背开闭原则。每来一个新规则,必须打开既有核心方法去改,改一个测试良好的方法,稍不留神就把旧逻辑改挂了。
- 分支和方法职责严重耦合。本来的核心逻辑是“算价格”,结果里面塞满了类型判断和不同规则,逻辑越混越多。
- 不好测试。测试同学想测“钻石会员打 8 折”,必须把所有分支都跑一遍场景构造,不同条件的组合测试成本极高。
- 代码可读性随分支数量指数下降。超过三层嵌套的 if-else,读代码的人基本要靠猜。
所以策略模式干的事,说穿了也很朴素:把“每一套算法或规则”从大方法里抽出来,封装成独立的对象,让它们能互相替换,并且由调用方在运行时决定用哪一套。算法本身不再和固定的 if-else 写死在一起。
2. 策略模式的核心思想与三个角色
2.1 一句话理解策略模式
策略模式的英文是 Strategy,在 GoF 的《设计模式》里它属于行为型模式。官方定义说得很文绉绉:定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换。这定义本身没错,但太抽象,我换个生活化的例子。
假设你每天要上班,目的地是公司,但具体怎么去有多种方案:坐地铁、搭公交、打车、骑共享单车。这几种方式都是“到达公司”这个流程里的不同算法,它们做的事情一样,但实现完全不同。对你来说,今天下雨就打车,早高峰就坐地铁,完全是看情况选一种。这和策略模式的思想一模一样:你(调用方)根据需要选择某个具体策略(交通工具),让策略去执行具体的到达逻辑。
在代码里,那辆“车”就是策略对象,“到达公司”这个动作就是策略接口里的统一方法,“你”就是调用方或者上下文。
2.2 策略模式的三个角色
策略模式通常由三个角色组成,这个面试也常问,我给你拆开揉碎讲:
| 角色 | 名称 | 核心职责之我见 |
|---|---|---|
| Strategy | 抽象策略 | 定义算法统一的入口方法,让外部不关心具体实现 |
| ConcreteStrategy | 具体策略 | 真正干活的类,每个类封装一种规则或算法 |
| Context | 上下文 | 持有 Strategy 引用,负责调用算法,但不关心具体是哪个策略 |
这里最容易忽略的是 Context 到底该做什么。它并不是策略的粉丝,它更像是一把“枪架”,真正决定用什么策略的是外部调用方。如果你在 Context 里面写死“如果是黄金会员用 A 策略,如果是钻石会员用 B 策略”,那 Context 就又变成了一个 if-else 收容所,策略模式相当于白搭。
从依赖关系的角度看,上层调用方依赖的是 Strategy 接口,而不是某个具体的 DiscountStrategy 类;Context 依赖的也是 Strategy 接口。这就是典型的依赖倒置原则,也是策略模式“让代码面向接口编程”的底层原因。
3. Java 手写一个策略模式:从重构到落地
3.1 第一步:定义策略接口
理解了理论,我们直接上代码。还是回到折扣计算的场景,先把策略接口抽出来:
/** * 折扣策略接口 */ public interface DiscountStrategy { /** * 根据原始价格计算最终价格 * * @param originalPrice 原始价格 * @return 最终价格 */ double calculate(double originalPrice); }这个接口设计越窄越好,很多新手会把各种业务字段塞进方法参数里,比如传一个完整订单对象,结果所有策略类都依赖订单里的十几个字段,策略之间也互相耦合。接口方法设计时,参数和返回值最好足够稳定,具体策略内部自己再去做额外逻辑。
有一点要提醒:真实项目里算金额和折扣,千万别用 double,业务金额计算一定要用 BigDecimal,否则你可能赔上公司业绩和测试妹子的头发。我在这里用 double 纯粹为了让代码看起来不啰嗦,方便理解模式本身。
3.2 第二步:实现具体策略类
接下来把每一种会员折扣规则封装成独立类:
/** * 普通会员:不打折 */ public class NormalDiscountStrategy implements DiscountStrategy { @Override public double calculate(double originalPrice) { return originalPrice; } }/** * 黄金会员:95折 */ public class GoldDiscountStrategy implements DiscountStrategy { @Override public double calculate(double originalPrice) { return originalPrice * 0.95; } }/** * 钻石会员:8折 */ public class DiamondDiscountStrategy implements DiscountStrategy { @Override public double calculate(double originalPrice) { return originalPrice * 0.8; } }每个策略类只负责一件事,黄金会员的折扣逻辑变了,改 GoldDiscountStrategy 就行,完全不会碰到钻石会员的代码。如果你做设计模式大作业,能把这一段讲清楚,老师基本就明白你没有背答案。
3.3 第三步:创建Context上下文
Context 是策略和业务之间的桥梁。在很多项目里,Context 可以是一个 service,也可以是某个纯 Java 类,核心职责就是持有策略对象,在需要时调用它的方法:
/** * 订单价格计算上下文 */ public class OrderContext { private DiscountStrategy strategy; public OrderContext(DiscountStrategy strategy) { this.strategy = strategy; } public void setStrategy(DiscountStrategy strategy) { this.strategy = strategy; } public double calculateFinalPrice(double originalPrice) { return strategy.calculate(originalPrice); } }有人会问:这个 Context 好像有点多余?直接在外面调用strategy.calculate(price)不就行了吗?不一定不行,但 Context 的意义在于屏蔽策略调用细节,并给代码留出扩展空间。比如你后面需要在计算前做日志、给最终结果做四舍五入、统一校验入参,都有地方放,不用散落在调用方代码里。
3.4 调用方使用与新增策略的体验
调用方使用起来非常舒服:
public class Application { public static void main(String[] args) { double originalPrice = 1000.0; DiscountStrategy gold = new GoldDiscountStrategy(); OrderContext context = new OrderContext(gold); double finalPrice = context.calculateFinalPrice(originalPrice); System.out.println("黄金会员最终价格:" + finalPrice); // 用户可以随时切换策略 context.setStrategy(new DiamondDiscountStrategy()); System.out.println("钻石会员最终价格:" + context.calculateFinalPrice(originalPrice)); } }现在如果产品经理说“下个月要加一个超级会员,打 7 折”,你要做什么?只需要新建一个SuperDiscountStrategy类,实现DiscountStrategy接口,然后在调用方或工厂里传入新策略就行。之前的策略类、Context 类全部不用动,甚至完全不影响线上已经跑着的逻辑。这个体验和我当初改那个巨型 if-else 相比,一个天上一个地下。
4. 策略模式 vs 简单工厂模式:别再傻傻分不清
4.1 创建型与行为型的本质区别
很多人在学设计模式时,看到策略模式和简单工厂模式代码长得差不多,一下子就懵了。其实它们解决的完全是两类问题,类图相似纯属表象。
简单工厂模式是“创建型模式”,它把“创建哪个对象”的决策逻辑封装起来,让调用方不需要知道对象是如何 new 出来的。而策略模式是“行为型模式”,它把“做哪件事”的算法封装起来,让算法可以互相替换。一个是解决对象怎么来的,一个是解决行为怎么变的。
我做个对比表格你品一下:
| 对比维度 | 简单工厂模式 | 策略模式 |
|---|---|---|
| 类型 | 创建型 | 行为型 |
| 关注点 | 对象创建 | 算法封装与替换 |
| 核心角色 | 工厂、产品抽象、具体产品 | 策略接口、具体策略、上下文 |
| 解决的问题 | 调用方与具体实现类的耦合 | 调用方与具体算法的耦合 |
| 典型流程 | 根据条件返回一个对象 | 持有策略对象,调用策略方法 |
如果你在写代码时主要纠结的是“该 new 哪个类,这个类怎么初始化”,优先考虑简单工厂;如果你纠结的是“这个流程有几种做法,不同情况下执行不同算法”,那就用策略模式。
4.2 项目里的“工厂+策略”黄金组合
实际开发里,两者很少单独出现,经常是“简单工厂 + 策略模式”组合使用,这几乎是后端业务系统中最高频的组合之一。
策略负责定义算法并实现算法细节,工厂负责根据入参决定到底返回哪个策略对象。还是拿会员说事:
/** * 折扣策略工厂 */ public class DiscountStrategyFactory { public static DiscountStrategy getStrategy(String memberType) { if ("GOLD".equals(memberType)) { return new GoldDiscountStrategy(); } if ("DIAMOND".equals(memberType)) { return new DiamondDiscountStrategy(); } // 默认普通会员 return new NormalDiscountStrategy(); } }调用方改成这样,连 Context 里的策略都不用自己 new:
DiscountStrategy strategy = DiscountStrategyFactory.getStrategy(userType); OrderContext context = new OrderContext(strategy); double finalPrice = context.calculateFinalPrice(amount);这套组合的好处是:新增会员等级时,你通常只需要新增一个具体策略类,然后去工厂里注册一条对应关系,核心业务逻辑完全不用动。如果你觉得工厂里的 if-else 会越加越多,也可以把工厂内部替换成枚举或 Map 注册表来减分支,这部分我在第 5 节里细讲。
5. 让代码更优雅:枚举策略与Spring环境下的落地
5.1 枚举实现策略:小而美
看到这里你应该发现了,策略模式有一个“副作用”:策略类多了以后,类文件数量会膨胀。如果只是几个简单规则,再拆那么多类反而显得过度设计。这时候“枚举策略”是一种不错的瘦身方案。
枚举里可以定义抽象方法,让每个枚举项实现自己的算法,代码紧凑且内聚:
public enum MemberDiscountEnum { NORMAL { @Override public double calculate(double originalPrice) { return originalPrice; } }, GOLD { @Override public double calculate(double originalPrice) { return originalPrice * 0.95; } }, DIAMOND { @Override public double calculate(double originalPrice) { return originalPrice * 0.8; } }; public abstract double calculate(double originalPrice); }调用方特别省事:
double price = MemberDiscountEnum.valueOf("GOLD").calculate(1000);它的缺点也显而易见:如果某个策略逻辑有三五百行,那这个枚举会膨胀到让人不想打开。枚举策略适合“策略数量少、逻辑简单、类型固定”的场景,比如状态机路由、常见编码规则映射。真要每个策略都很复杂,老老实实用实现类拆开。
5.2 在Spring项目中用依赖注入组织策略
在 Spring 生态里写策略模式,比手写 new 优雅得多。最经典的写法是让 Spring 容器自动收集所有策略 Bean,然后注入成一个 Map。
首先给策略实现类加上组件注解,并指定好 Map 的 key:
@Component("normal") public class NormalDiscountStrategy implements DiscountStrategy { @Override public double calculate(double originalPrice) { return originalPrice; } }@Component("gold") public class GoldDiscountStrategy implements DiscountStrategy { @Override public double calculate(double originalPrice) { return originalPrice * 0.95; } }@Component("diamond") public class DiamondDiscountStrategy implements DiscountStrategy { @Override public double calculate(double originalPrice) { return originalPrice * 0.8; } }然后写一个调度器,Spring 启动后会自动把容器里所有DiscountStrategy类型的 Bean 注入到 Map 中,key 就是注解里指定的字符串:
@Service public class DiscountStrategyDispatcher { private final Map<String, DiscountStrategy> strategyMap; @Autowired public DiscountStrategyDispatcher(Map<String, DiscountStrategy> strategyMap) { this.strategyMap = strategyMap; } public double apply(String memberType, double originalPrice) { DiscountStrategy strategy = strategyMap.get(memberType.toLowerCase()); if (strategy == null) { strategy = new NormalDiscountStrategy(); } return strategy.calculate(originalPrice); } }调用方核心逻辑就一句话:
double price = dispatcher.apply("GOLD", 1000);这段代码最大的好处是:以后新增策略类,只需要加一个@Component("super")的类,Dispatcher 完全不用改。Spring 的 DI 能力直接帮我们把“策略注册表”建好了,省去了手写 Map 注册的麻烦。我项目里做消息推送渠道、第三方支付、优惠券玩法,都是这么玩的。唯一要注意的是,Map 的 key 必须全局唯一,别不小心定义了两个@Component("gold"),否则启动时会直接报冲突。
6. 常见问题与排查技巧实录
6.1 策略类多了,管理成本变高怎么办
策略本身也有代价:一个规则一个类,时间久了策略类会非常多,光看着包名都能得密集恐惧症。我常说这是“幸福的烦恼”——至少比巨型 if-else 容易改,但管理成本确实存在。
我的做法是先分业务包,比如promotion.discount、promotion.freight、promotion.coupon,策略按业务域归位;其次,简单固定的规则用枚举策略,只有复杂策略才单独拆类;再次,用函数式接口做小型策略。比如 Java 8 之后,如果策略只是比如“乘以 0.8”这样一句话,就不必非得写一个类,可以用 Lambda 或者方法引用作为策略传进去:
DiscountStrategy diamond = price -> price * 0.8;这种方式在局部业务里极度轻量,但在大型项目里可读性不如具名类,得看团队习惯了。
6.2 上下文和策略职责容易出现错位
最常见的错误写法是:在 Context 里又写 if-else 判断用户类型,然后再决定调谁:
// 这是个反面例子,千万不要学 public double calculateFinalPrice(String userType, double originalPrice) { if ("GOLD".equals(userType)) { return new GoldDiscountStrategy().calculate(originalPrice); } // 分分钟变成老古董 }这样做的结果是把“策略选择逻辑”又塞回了上下文。Context 的正确姿势应该是“只调不选”,它接收一个已经创建好的策略对象,然后执行。真正决定用哪个策略的人,应该是调用方,或者一个负责路由的工厂/分发器。
6.3 策略接口设计太宽,参数越传越多
经验不足的开发很容易把策略接口设计成一个大杂烩,比如一开始用calculate(Order order),后来发现有的策略需要会员信息,有的需要优惠券,于是又改成calculate(Order order, Member member, Coupon coupon),再将来加一个参数就崩了。
我的经验是,策略接口的参数尽量稳定且抽象,甚至可以定义一个DiscountContext参数对象,里面放所有策略可能要用的数据。策略之间按需取值,而不是各自声明不同的参数列表。
6.4 忘了处理空策略,NPE直接送上门
用 Map 或工厂获取策略时,如果传入类型不在注册表里,获取的结果就是 null,直接调用calculate必然空指针。这类问题特别隐蔽,通常只有脏数据才能触发。我在项目里统一采用“默认策略兜底”,也就是找不到时给一个不做任何处理的默认实现,比如普通会员策略。
下面是这段时间我整理的策略模式常见问题速查表,文字描述不直观,表格更适合让团队小伙伴放在文档里当排查手册用:
| 现象 | 可能原因 | 排查思路与解决 |
|---|---|---|
| 调用策略时空指针 | 策略 Map 或工厂没有对应类型 | 打印策略 Map 的 keySet,检查注册名是否写错;入参统一做默认策略兜底 |
| 新增策略后没生效 | 策略类没有加@Component,或在包扫描路径外 | 检查类注解与包扫描配置,启动日志里确认 Bean 是否注册 |
| Spring 启动时 Bean 冲突 | 两个策略类的 Map key 相同 | 检查@Component注解的 value 是否唯一 |
| 策略类直接互相调用,业务混乱 | 策略拆分粒度不合理 | 把公共逻辑下沉到抽象类或独立 Service,策略类之间不要互相依赖 |
| 上下文里出现大量 if-else | Context 负担了策略选择逻辑 | 将策略选择职责交给工厂/分发器,Context 只做策略调用 |
| 策略对象是单例但内部维护了可变状态 | Spring 默认单例,策略类被多线程共享 | 策略类设计成无状态,不要把中间计算结果放在成员变量里 |
这份速查表不光是理论,很多是我自己踩过的坑,尤其“策略类内部搞出状态”这条,真出过线上事故。当时有个优惠策略把计算结果暂存到成员变量,导致并发请求互相覆盖,排查到凌晨才定位。
最后顺手聊两句我的真实体会
策略模式学起来不难,难的是“什么时候该用”。它适合处理那些“规则在持续增加、算法会经常变化”的场景,但如果你面对的是一段稳定且简单的小逻辑,别硬套策略模式,否则类爆炸比 if-else 爆炸还烦人。
我个人的判断标准很简单:当代码里出现第二个相似的分支时,就开始考虑怎么把分支抽象成策略;当上下文的 if-else 超过三四个时,就必须动手重构。写代码不是去跟谁比拼用了多少设计模式,而是让接你代码的人少掉点头发。策略模式是我用过之后回购率最高的模式之一,只要你在真实项目里体会过“加新功能不用改旧代码”的爽感,就很难再回到那个堆 if-else 的日子了。