策略模式一直是Java面试里逃不开的高频题,网上讲它的人多,真正讲透的少。前阵子有个读者在群里说,自己负责的订单计价模块已经堆了六层if-else,产品又要加一种新的券类型,他实在不想往那个方法里继续填分支了。我问他有没有考虑过用策略模式,他说考虑过,但翻了几篇文章都是背三要素,什么策略接口、策略类、上下文,看完回工位还是不知道代码该怎么落。
这篇我不想从八股定义开始,就从真实的业务场景出发,把策略模式到底解决什么问题、最小可用的代码长什么样、工程上怎么落地、有哪些坑容易踩,一步步聊完。适合正在准备Java面试的开发者,更适合那些已经写完几千行业务代码、被if-else逼到头疼的人。
1. 计价模块里的层层嵌套:策略模式要解决的真实痛点
1.1 一个接口从3个分支膨胀到30个分支的过程
很多设计模式文章喜欢拿形状计算面积举例子,长方形、圆形、三角形挨个写实现类,看着工整,但实际业务里很难让人有代入感。我自己更愿意用电商促销计价来说事,因为它足够贴近真实开发。
假设你现在负责一个下单系统的价格计算接口,最开始只有普通价,代码很干净:
java复制public BigDecimal calculatePrice(BigDecimal originalPrice, Long userId) {
return originalPrice;
}
后来运营说要做会员体系,普通会员不打折,白银会员95折,黄金会员88折。你写了switch判断会员等级,接口还能接受。
再后来,运营引入了优惠券体系。满300减100券、85折券、无门槛20元券,每张券还有自己的使用门槛和互斥规则。你的calculatePrice方法开始往里面塞各种判断,一个方法长到了几百行。团队里每个人都知道那个方法是雷区,但谁也不敢大改,因为牵一发动全身,测试用例也没人敢删。
这种代码在Java后端项目里太常见了。它的问题从来不是if-else本身,而是所有分支的复杂逻辑全部聚集在一个方法里,每次新增一种价格规则都要动老代码,改错一个顺序可能就把线上促销搞崩。这种“改一处、炸一片”的代码结构,才是真正的病根。
1.2 面向对象里的“开闭原则”为什么在这里失效
很多面试者背过开闭原则:对扩展开放,对修改关闭。但背归背,实际做的时候,大多数人并没有把这句原则映射到自己的代码上。
上面那个计价接口就是违反开闭原则的典型形态。每次加一个促销规则,都要把原来的calculatePrice方法打开,在if-else链里再加一层。你确实扩展了业务,却也修改了老代码。改老代码的风险不是单纯“多写几行”的问题,而是你不知道这段逻辑还有谁在调用,调用方对结果有什么隐性假设。比如原有的白银会员逻辑是“95折可以和优惠券叠加的”,新来的人不理解这个背景,在某个判断分支里提前return了,线上客诉马上就来了。
其实出现这种情况,核心原因是没有把“变化”从大方法里拆出来。计价方法被写成了一个长长的流程编排,而业务规则的边界感完全模糊了。策略模式要做的,就是把每一种可变的计价规则独立成类,让主流程不再关心规则内部的逻辑,只面向抽象规则编程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 策略模式的核心三件套:接口、实现类与上下文
2.1 从会员折扣场景认识三个角色的分工
策略模式在GoF定义里属于行为型模式,它的意图是定义一族算法,让它们可以互相替换,并且让算法的变化独立于使用算法的客户端。听起来有点拗口,翻译成大白话就是:把“做什么”和“怎么做”分开。
标准的结构有三个角色:
- 策略接口(Strategy):定义某种行为或算法的统一规范,比如计价规则约定一个方法。
- 具体策略(ConcreteStrategy):实现策略接口的各个子类,每个子类封装一种业务规则。
- 上下文(Context):持有策略接口的引用,并在合适时机调用策略方法。上下文不关心策略内部怎么实现的,只要得到结果就行。
还是拿会员折扣举例子,我们抽一个最简单的计价场景:用户购物车结算时,根据会员等级对商品总价打折。策略模式的落地是这样的。
先定义策略接口:
java复制public interface DiscountStrategy {
BigDecimal apply(BigDecimal originalPrice);
}
接口统一收口“怎么算最终价”这件事。接着写不同等级的实现类:
java复制public class NormalStrategy implements DiscountStrategy {
@Override
public BigDecimal apply(BigDecimal originalPrice) {
return originalPrice;
}
}
java复制public class SilverStrategy implements DiscountStrategy {
private static final BigDecimal DISCOUNT_RATE = new BigDecimal("0.95");
@Override
public BigDecimal apply(BigDecimal originalPrice) {
return originalPrice.multiply(DISCOUNT_RATE);
}
}
java复制public class GoldStrategy implements DiscountStrategy {
private static final BigDecimal DISCOUNT_RATE = new BigDecimal("0.88");
@Override
public BigDecimal apply(BigDecimal originalPrice) {
return originalPrice.multiply(DISCOUNT_RATE);
}
}
策略只是算法,真正调用策略的是上下文。上下文最常见的设计方式是组合一个策略对象:
java复制public class PriceContext {
private DiscountStrategy strategy;
public PriceContext(DiscountStrategy strategy) {
this.strategy = strategy;
}
public BigDecimal calculate(BigDecimal originalPrice) {
return strategy.apply(originalPrice);
}
}
客户端使用时,把不同的策略对象塞进上下文:
java复制public class Main {
public static void main(String[] args) {
BigDecimal price = new BigDecimal("100");
PriceContext normalContext = new PriceContext(new NormalStrategy());
System.out.println("普通会员:" + normalContext.calculate(price));
PriceContext silverContext = new PriceContext(new SilverStrategy());
System.out.println("白银会员:" + silverContext.calculate(price));
PriceContext goldContext = new PriceContext(new GoldStrategy());
System.out.println("黄金会员:" + goldContext.calculate(price));
}
}
这个例子已经能看出策略模式的雏形:上下文不再写if-else判断用户是什么等级,而是把等级对应的策略对象丢进来。将来新增一个钻石会员,你只需要写一个DiamondStrategy类,完全不需要改动PriceContext和原有的策略类。
2.2 上下文里到底该不该主动切换策略
上面这个写法有个使用前提:调用方自己知道该给上下文传哪个策略。但在很多业务场景里,调用方其实不知道,或者不应该知道。真正的规则通常是根据某个业务标识来选择的,比如用户会员等级、优惠券类型、支付方式渠道。
这时候有两种常见设计。
第一种是上下文里放一个选择策略的工厂方法,或者由外部工厂来决定。第二种是上下文自己持有所有策略,接收一个标志参数,从内部选出对应策略。
我的建议是,策略选择逻辑尽量不要散落在业务代码里。因为如果每次调用都要写一遍new SilverStrategy(),那和写if-else没有本质区别,只是把判断从庞大的流程方法挪到了调用方,调用方会越来越重。合理的做法是把选择逻辑收拢到一个独立的地方管理,这也就引出了后面的策略工厂和容器化装配。
2.3 为什么不推荐直接传入一个布尔值或类型码
和策略模式对应的反面设计是:上下文方法签名里传一个字段,内部用if判断策略。比如:
java复制public BigDecimal calculate(BigDecimal price, int memberLevel) {
if (memberLevel == 1) {
return price;
}
if (memberLevel == 2) {
return price.multiply(new BigDecimal("0.95"));
}
return price.multiply(new BigDecimal("0.88"));
}
这种写法的坏处非常明显:策略逻辑一旦多了,方法体就是一堆堆叠的分支,测试时要构造各种组合参数去覆盖,改一个折扣率可能影响所有调用方。而策略模式通过让“策略选择”和“策略执行”解耦,把每种规则的边界框死了:白银策略坏了不会影响黄金策略的测试,新加的策略也不会让老方法变得更臃肿。
在面试里,一个常见的追问是:“策略模式相比if-else到底优化了什么?”能回答“不是消灭了分支,而是让分支从不可读的代码变成了可替换的对象”的人,才算是真正理解了这个模式的价值。
3. 让策略模式更轻:Lambda表达式与枚举策略
3.1 Java 8之后,策略接口可以是函数式接口
很多老文章讲策略模式,代码还停留在Java 7时代,每个具体策略都要求建一个类,类一多就整出一堆文件,于是有人抱怨策略模式导致类爆炸。
其实Java 8引入Lambda之后,策略模式已经可以写得很轻。如果一个策略接口只有一个抽象方法,它就天然是函数式接口,可以直接用Lambda表达式当作策略实现来传递。
比如前面那个折扣接口,只有一个apply方法,那在调用时完全不用定义SilverStrategy类:
java复制PriceContext silverContext = new PriceContext(
price -> price.multiply(new BigDecimal("0.95"))
);
Lambda表达式可以理解成“轻量级的匿名策略实现”。如果你的策略逻辑只有一行,用Lambda比建一个类更干净。但要注意,如果策略逻辑很复杂,超过三五行,那还是建议拆成命名类,否则Lambda表达式里塞了很多行,反而不好读。
3.2 用枚举封装固定策略的写法
有一种比Lambda更进一步的做法,是把固定策略直接放进枚举里。这个技巧在很多开源项目里都能看到,特别适合那些“策略集合固定、每个策略逻辑短小”的场景。
还是拿会员折扣来说,我们可以把会员等级和对应折扣算法一起写进一个枚举:
java复制public enum MemberStrategy {
NORMAL("普通会员", 1, price -> price),
SILVER("白银会员", 2, price -> price.multiply(new BigDecimal("0.95"))),
GOLD("黄金会员", 3, price -> price.multiply(new BigDecimal("0.88")));
private final String desc;
private final int level;
private final UnaryOperator<BigDecimal> discountCalculator;
MemberStrategy(String desc, int level, UnaryOperator<BigDecimal> discountCalculator) {
this.desc = desc;
this.level = level;
this.discountCalculator = discountCalculator;
}
public BigDecimal apply(BigDecimal originalPrice) {
return discountCalculator.apply(originalPrice);
}
public static MemberStrategy match(Integer level) {
if (level == null) {
return NORMAL;
}
for (MemberStrategy strategy : values()) {
if (strategy.level == level) {
return strategy;
}
}
return NORMAL;
}
}
使用时,拿到会员等级后直接匹配:
java复制BigDecimal price = new BigDecimal("100");
Integer memberLevel = 2;
MemberStrategy strategy = MemberStrategy.match(memberLevel);
System.out.println(strategy.apply(price));
这种枚举写法的好处是,把“会员等级”这个稳定维度的行为内聚在一个文件里,字段、描述、算法放在一起。代码读起来比去目录里找四五个策略类更集中。缺点也很明显:如果策略数量很多,或者每个策略的实现逻辑都超过十行,这枚举就会变得巨长,违背单一职责。所以枚举策略更适合策略集合稳定、逻辑简单的场景。
3.3 策略接口设计成有入参出参还是无参
写策略接口的时候,有个容易纠结的问题:策略方法到底要不要接收很多参数。有的业务场景,一个计价策略需要用户ID、商品明细、优惠券信息、收货地址、会员等级,如果策略方法签名直接把这些参数都暴露出来,接口会变得很长,而且不是每种策略都用得上这些参数。
比较好的实践是封装一个上下文参数对象,把公共信息塞进去:
java复制public class OrderCalculateContext {
private Order order;
private User user;
private Coupon coupon;
private List<Promotion> promotions;
// 省略 getter/setter
}
策略接口变成:
java复制public interface PriceStrategy {
BigDecimal calculate(OrderCalculateContext context);
}
这样新增一种策略时,如果只需要context里的部分信息,接口签名不需要变。这个设计思路和策略模式本身是配套的,保持策略接口稳定,才能让后续扩展更轻松。如果一开始接口塞了十几个参数,每加一种规则就改动接口签名,那所有策略类都得跟着改,相当于把不稳定的东西放到了不该放的位置。
4. 工程实战:用策略工厂和Spring容器管理大量策略
4.1 为什么仅靠多态还不够,需要对策略做注册和选择
业务代码一旦多起来,只会new策略并不足以解决全部问题。因为调用方如果每次都要判断“订单用的券是什么类型,然后决定new哪个策略类”,那if-else只是从上下文转移到了业务入口。真正要让代码清爽,需要一个策略选择器或者策略工厂来承接这层映射关系。
先看一个比较传统的策略工厂实现,以优惠券为例。假设订单上有多种券类型,满减券、折扣券、无门槛券,每种券对应一个策略类。我们把策略的注册和创建集中到一个工厂里:
java复制public class CouponStrategyFactory {
private static final Map<String, DiscountStrategy> STRATEGY_MAP = new HashMap<>();
static {
STRATEGY_MAP.put("FULL_REDUCTION", new FullReductionStrategy());
STRATEGY_MAP.put("DISCOUNT", new DiscountCouponStrategy());
STRATEGY_MAP.put("NO_THRESHOLD", new NoThresholdStrategy());
}
public static DiscountStrategy getStrategy(String couponType) {
return STRATEGY_MAP.getOrDefault(couponType, new DefaultStrategy());
}
}
这个工厂用静态Map维护了优惠券类型与策略对象的对应关系。调用方唯一需要知道的是券类型编码,不需要知道具体类名。这样即使后面新增了一种“包邮券”,你只需要添加一个策略实现类,然后在Map里多放一行,业务调用方看到的依然只是CouponStrategyFactory.getStrategy(couponType)。
这个工厂能成立的前提是:所有策略对象是无状态的,可以共享。如果策略类内部有可变的实例变量,那就不能用一个静态Map存单例策略,每次使用时得创建新对象,否则并发场景下会出现数据串了的问题。
4.2 结合Spring:把策略自动注入成Map
在Spring项目里,我们通常不写上面的静态工厂,而是利用Spring的依赖注入能力,让容器把同一个接口的所有实现对象注入成一个Map或List。
假设我们有一个支付渠道的需求,支持微信支付、支付宝支付、银行卡支付。先定义一个支付策略接口:
java复制public interface PayStrategy {
String channelCode();
void pay(Order order);
}
每个渠道实现类都交给Spring管理,通过@Component注册。比如:
java复制@Component
public class WechatPayStrategy implements PayStrategy {
@Override
public String channelCode() {
return "WECHAT";
}
@Override
public void pay(Order order) {
// 微信支付逻辑
}
}
然后写一个选择器,注入所有PayStrategy:
java复制@Component
public class PayStrategySelector {
private final Map<String, PayStrategy> strategyMap;
public PayStrategySelector(List<PayStrategy> strategies) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(PayStrategy::channelCode, Function.identity()));
}
public void executePay(String channelCode, Order order) {
PayStrategy strategy = strategyMap.get(channelCode);
if (strategy == null) {
throw new IllegalArgumentException("不支持的支付渠道: " + channelCode);
}
strategy.pay(order);
}
}
这里有个Spring的实现细节值得留意。构造器注入List
4.3 实际业务案例:多渠道消息推送选择器
像支付这样“渠道多、规则容易扩展”的地方,Strategy往往大放异彩。另一个经典场景是消息推送。
很多系统要给用户发通知,但有多种渠道:站内信、短信、邮件、App Push。不同用户对不同渠道的偏好不一样,甚至不同业务消息只能用特定渠道。如果你在业务代码里写“如果用户没绑定手机就发邮件,如果绑定了就发短信”,那每个发消息的入口都要塞一堆渠道判断,逻辑会散落得到处都是。
我们可以用策略模式把每个渠道封装成独立的Sender:
java复制public interface MessageSender {
void send(Message message);
boolean support(User user);
}
然后每个渠道一个实现类,处理各自的接入逻辑。选择器负责拿到用户后,选择第一个 support 返回 true 的 sender 执行发送:
java复制@Service
public class MessageSendProcessor {
private final List<MessageSender> senderList;
public MessageSendProcessor(List<MessageSender> senderList) {
this.senderList = senderList;
}
public void send(User user, Message message) {
MessageSender selected = senderList.stream()
.filter(sender -> sender.support(user))
.findFirst()
.orElseThrow(() -> new IllegalStateException("没有可用的发送渠道"));
selected.send(message);
}
}
这种写法把“一个用户适合什么渠道”的判断放回了各策略内部,业务层不再纠结渠道逻辑。以后新增短信服务商、调整渠道优先级,都在策略层解决,调用入口是稳定的。
5. 使用策略模式最容易踩的坑和工程权衡
5.1 “策略模式消灭了if-else”是个幻觉
很多文章喜欢用策略模式跟if-else对立,好像用了策略模式,代码里就再也见不到分支了。这是理解上最大的坑。
策略模式本质上并不会消灭if-else,它只是把分支从业务方法里挪到了别的地方。比如上面静态工厂里的Map.put、Spring选择器里的toMap,都仍然在做类型映射,你只是没有把那几个if写出来而已。就算你用Map,底层还是会做查找。真正发生变化的是:逻辑复杂度被拆出去了,原来的长方法变得可维护。
所以面试时如果有人问你“策略模式能彻底替代if-else吗”,别顺着说能。更准确的回答是:它可以优化大量条件分支的代码结构,但选择策略的那一层仍然需要分支或映射机制。这才是符合工程实际的认知。
5.2 策略对象共享时的线程安全问题
策略模式的代码在单线程Demo里不会出问题,但在Web应用里,多个请求是并发执行的。如果策略实现类里定义了可变的成员变量,比如:
java复制public class DiscountCouponStrategy implements DiscountStrategy {
private BigDecimal couponAmount; // 危险,会共享
public void setCouponAmount(BigDecimal amount) {
this.couponAmount = amount;
}
@Override
public BigDecimal apply(BigDecimal originalPrice) {
return originalPrice.subtract(couponAmount);
}
}
一旦这个策略对象被多个线程共享,用户A设置完couponAmount后,用户B可能又改了这个字段,最后计算出来的价格就串了。
解决思路有几种。最简单的就是把策略类设计成无状态,所有参与计算的参数都通过方法入参传进来。比如满减券的满减门槛和减免金额,应该作为构造参数传入,或者放在方法参数里,而不是用setter变来变去。如果策略内部要临时保存状态,那就不要用静态工厂保持单例,改成每次获取新对象。但Spring默认注入的Bean是单例,所以更提倡前一种无状态设计。
5.3 策略类爆炸:什么时候不适合使用策略模式
策略模式不是银弹。如果一个项目里只有两三种固定分支,并且后面几乎不会扩展,强上策略模式只会增加类和跳转,让代码变难追。
比如你只是想判断一个整数是奇数还是偶数,那if-else是最直白的写法,没必要定义OddStrategy和EvenStrategy。过度设计比不加设计的危害还大,因为后来维护的人要在N个文件之间跳来跳去才能看明白一个其实很简单的逻辑。
判断是否该用策略模式,我一般看三个条件:
- 分支数量够多,通常超过三个,且每种分支的处理逻辑有一定的体量。
- 业务规则存在明显的类型维度,并且这个维度未来很可能会扩容。
- 分支逻辑需要被独立测试,或者需要在不影响其他分支的情况下单独维护。
如果这三个条件都不满足,老老实实写if-else比什么都强。团队里最容易出现的问题是大家上完设计模式课之后,恨不得把所有代码都套上模式,一个两行的判断也拆成两个类,最后类数量翻倍,维护成本反而升高。
5.4 策略找不到时的兜底设计
策略工厂和Spring选择器都有可能出现“找不到对应策略”的情况。比如数据库里存了一个历史遗留的券类型编码,对应策略类已经被下线了。这时候如果直接返回null,调用方没有判空,运行时就炸了空指针。
我习惯在策略选择处做两层兜底:第一层,命中不了时抛一个明确的业务异常,带上可读的错误信息,方便排查;第二层,如果业务上允许默认行为,可以在Map里预置一个“默认策略”对象,或者用Map.getOrDefault返回一个DefaultStrategy。这个DefaultStrategy可以什么都不做或者按最保守的规则处理,保证流程能继续走下去。
具体选择哪一种要看业务容忍度。优惠券场景里,历史脏数据出现时往往需要抛异常告警,而不是静默用默认价结算,否则财务对账会出问题。而消息推送场景里,发送失败的影响可能相对小,用默认策略降级成站内信更合理。所以兜底策略的选择,本质上也是业务决策。
6. 策略模式与相关模式的边界,以及JDK里的现成策略范例
6.1 和状态模式、模板方法模式的区别
面试中第二常问的问题是把模式放在一起辨别。策略模式经常被拿来和状态模式比较。两者代码结构很像,都是“一个上下文持有某个实现类,运行时可以替换”,但核心意图完全不同。
状态模式强调的是对象内部状态变化导致行为变化,状态的迁移是主动的,Context自身会维护状态机的流转。而策略模式强调的是算法或者规则的可替换,客户端用哪个策略通常是一次性确定下来的,策略之间往往是平级的,并没有复杂的流转关系。
举个例子,订单状态从待支付变成已支付,再变成已发货,这就是状态迁移,比较适合状态模式。而发送消息时选择短信还是邮件,适合策略模式。两者的“变化”类型不一样。
模板方法模式和策略模式也有点像,但模板方法的重点是把不变的整体流程写在父类里,把变化步骤通过继承让子类去实现;策略模式的重点是把完整的算法封装进独立对象,用组合的方式替换。前者的父类会控制整个执行骨架,后者的Context只是调用一个策略方法,不控制内部流程。
6.2 每天都在用的策略模式:Comparator、RejectedExecutionHandler
很多人没意识到,JDK底层已经大量使用了策略模式,最典型的就是Collections.sort和List.sort的Comparator。
java复制List<String> names = Arrays.asList("tom", "jerry", "bob");
names.sort((a, b) -> a.compareToIgnoreCase(b));
Comparator就是策略接口,不同的排序算法实现是策略对象,调用方传入不同的比较器就切换了排序行为,sort方法本身不需要修改。这是策略模式在标准库里的教科书级应用。
再看线程池,ThreadPoolExecutor的拒绝策略也有一套现成的策略实现。AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy分别对应任务满了之后的四种处理方式。你创建线程池的时候传入哪种RejectedExecutionHandler,就相当于选用哪套策略。包括Spring的Resource资源访问抽象,也是同一接口有多种实现,分别处理文件、HTTP URL、classpath路径。可见策略模式的影子在成熟框架里无处不在。
6.3 用决策表治理“类型判断片段”的个人经验
聊到策略模式,我一直觉得代码里其实有两种维度的变化。一种是真的复杂算法,一种只是根据类型返回不同配置。很多人把后者也强行套策略模式,反而写着别扭。我更常采用“决策表”思路:用一个枚举维护类型与规则映射,必要的时候策略类只保存计算逻辑,选择规则由枚举承担。
比如电商风控里,不同活动类型的优惠上限不一样。我先定义一个活动类型枚举:
java复制public enum ActivityType {
SECKILL(1, BigDecimal.valueOf(100)),
GROUPON(2, BigDecimal.valueOf(200)),
NORMAL(3, BigDecimal.valueOf(50));
private final int code;
private final BigDecimal maxDiscount;
ActivityType(int code, BigDecimal maxDiscount) {
this.code = code;
this.maxDiscount = maxDiscount;
}
public static ActivityType match(Integer code) {
return Arrays.stream(values())
.filter(type -> type.code == code)
.findFirst()
.orElse(NORMAL);
}
}
这种场景下,一个枚举就完成了类型到配置的映射,根本不需要为每个活动各写一个策略类。所谓知道什么时候该用、什么时候不该用、以及用哪个变种,才是模式落地的关键。
我在实际项目里见过太多为了“符合设计模式”而牺牲可读性的代码。策略模式确实是处理复杂多分支业务的利器,但使用它的前提一定是你已经识别出了业务中的稳定维度和扩展维度。先把维度定清楚,再来决定哪些类该独立,哪些判断不该拆散。代码的最终目标永远是让人看懂,模式只是工具,不是目的。
