Java 枚举与策略模式(Strategy Pattern)的结合,是消灭业务代码里层层嵌套 if-else / switch 最优雅的手段之一。本文用可运行的示例,讲清如何让「类型即行为」,把分散在各个分支里的业务逻辑收敛成可扩展、可测试、类型安全的代码,并给出与 Spring 整合与单元测试的完整落地方案。
一、为什么需要它:if-else 地狱
电商、支付、风控这类业务,常常根据「用户类型」「渠道类型」「状态」走不同逻辑。新手最容易写成下面这样:
public BigDecimal calculateDiscount(String userType, BigDecimal amount) {
if ("NORMAL".equals(userType)) {
return amount.multiply(new BigDecimal("0.98"));
} else if ("VIP".equals(userType)) {
return amount.multiply(new BigDecimal("0.90"));
} else if ("SVIP".equals(userType)) {
return amount.multiply(new BigDecimal("0.80"));
} else {
return amount;
}
}
问题显而易见:每加一种用户类型就要改这个方法,违反开闭原则;字符串魔法值容易拼错且编译期发现不了;单元测试要覆盖所有分支也很麻烦。更要命的是,类似的分支判断往往散落在下单、营销、报表多个方法里,改一处漏一处。枚举 + 策略模式可以一次性解决这三个痛点。
二、枚举基础回顾:它不只是常量集合
很多开发者把 enum 当成「带名字的常量」,其实 Java 的枚举本质是一个继承自 java.lang.Enum 的类,它可以拥有字段、构造器和普通方法。正是这一点,让枚举能够承载行为。
public enum UserType {
NORMAL("普通用户", 0.98),
VIP("会员", 0.90),
SVIP("超级会员", 0.80);
private final String label;
private final double rate;
UserType(String label, double rate) {
this.label = label;
this.rate = rate;
}
public String getLabel() { return label; }
public double getRate() { return rate; }
}
注意:枚举的构造器默认且只能是 private,这意味着枚举实例在 JVM 中是单例且线程安全的,可以放心地把状态或行为挂在上面。如果你习惯用 Stream 与函数式风格组织业务,可以回顾 Java 函数式编程实战 里的组合思路,二者并不冲突。
三、枚举 + 策略模式:把行为塞进枚举
最经典的做法是给枚举定义一个抽象方法,让每个常量用自己的实现「覆写」它。调用方只依赖枚举类型,完全看不见分支判断。
public enum DiscountStrategy {
NORMAL {
@Override public BigDecimal apply(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.98"));
}
},
VIP {
@Override public BigDecimal apply(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.90"));
}
},
SVIP {
@Override public BigDecimal apply(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.80"));
}
};
public abstract BigDecimal apply(BigDecimal amount);
}
使用端极其清爽,新增类型只改枚举一处,调用方零改动:
public BigDecimal discount(UserType type, BigDecimal amount) {
return DiscountStrategy.valueOf(type.name()).apply(amount);
}
3.1 用函数式接口让写法更轻
如果每个策略只是一段计算,没必要为 enum 定义抽象方法,直接持有 Function 或自定义函数式接口更简洁:
@FunctionalInterface
public interface DiscountFn {
BigDecimal calc(BigDecimal amount);
}
public enum DiscountStrategy {
NORMAL(a -> a.multiply(new BigDecimal("0.98"))),
VIP(a -> a.multiply(new BigDecimal("0.90"))),
SVIP(a -> a.multiply(new BigDecimal("0.80")));
private final DiscountFn fn;
DiscountStrategy(DiscountFn fn) { this.fn = fn; }
public BigDecimal apply(BigDecimal amount) { return fn.calc(amount); }
}
这种写法把「策略」降级为一段 Lambda,代码量最少,适合逻辑简单、无状态的分支场景。把 Java Stream API 实战 里的流式思维套进来,还能把多个策略组合成管道。
四、真实案例:支付方式路由
枚举策略在「多支付渠道」场景特别好用。下面把微信、支付宝、银联三种渠道的签名与下单差异收敛进枚举:
public enum PayChannel {
WECHAT {
@Override public String sign(Map<String, String> params) {
return HmacSHA256(params, wechatKey);
}
@Override public PayResult pay(Order order) {
return wechatClient.createOrder(order);
}
},
ALIPAY {
@Override public String sign(Map<String, String> params) {
return RSA2(params, alipayPrivateKey);
}
@Override public PayResult pay(Order order) {
return alipayClient.pageExecute(order);
}
},
UNIONPAY {
@Override public String sign(Map<String, String> params) {
return SM3WithSM2(params, unionKey);
}
@Override public PayResult pay(Order order) {
return unionClient.gateway(order);
}
};
public abstract String sign(Map<String, String> params);
public abstract PayResult pay(Order order);
}
业务层只写 PayChannel.valueOf(req.getChannel()).pay(order),新增渠道时绝不用翻遍代码找 if。这也是策略模式「封装变化点」思想的落地。
五、枚举实现状态机:订单流转
订单状态(待支付→已支付→发货→完成/退款)天然适合用枚举状态机表达,每个状态只声明「接下来能去哪」:
public enum OrderStatus {
WAIT_PAY(Set.of()),
PAID(Set.of(SHIPPED, REFUNDING)),
SHIPPED(Set.of(COMPLETED, REFUNDING)),
COMPLETED(Set.of()),
REFUNDING(Set.of(REFUNDED)),
REFUNDED(Set.of());
private final Set<OrderStatus> next;
OrderStatus(Set<OrderStatus> next) { this.next = next; }
public boolean canTransitTo(OrderStatus target) {
return next.contains(target);
}
}
// 业务校验
if (!current.canTransitTo(target)) {
throw new IllegalStateException("非法状态流转: " + current + " -> " + target);
}
把非法流转在编译与运行两道关口都拦住,比散落的 if (status == X && next == Y) 健壮得多。
六、与 Spring 结合注入策略 Bean
当策略需要调用其他服务、访问数据库时,Lambda 就不够了,应当把策略实现成 Spring Bean,再用枚举做路由键。一个常用模式是启动时把所有策略收集到 Map 里:
@Service
public class DiscountRouter {
private final Map<UserType, DiscountService> registry = new EnumMap<>(UserType.class);
@Autowired
public DiscountRouter(List<DiscountService> services) {
for (DiscountService s : services) {
registry.put(s.supports(), s);
}
}
public BigDecimal discount(UserType type, BigDecimal amount) {
return registry.getOrDefault(type, DefaultDiscount.INSTANCE).apply(amount);
}
}
每个具体策略用 @Service 标注并实现 supports() 返回对应枚举。新增策略只要加一个类,框架自动注册,无需改动路由代码。关于自动装配的原理细节,可参考 Spring Boot 自动配置原理与自定义 Starter 实战。
七、什么时候不该用它
枚举策略不是银弹。下面几种情况应谨慎或改用别的方式:
| 场景 | 建议 |
| 分支类型会动态增减(如运营后台配置) | 放数据库 + 策略 Bean 注册,别硬编码进枚举 |
| 策略需要复杂依赖或生命周期管理 | 用 Spring Bean + Map 注册,而非枚举内 Lambda |
| 分支极少且几乎不变 | 直接 if-else 反而更直观,不必过度设计 |
| 同一类型有多种正交维度组合 | 考虑责任链 / 规则引擎,枚举会爆炸 |
八、常见误区
| 误区 | 正确做法 |
用 ordinal() 当分支依据 | ordinal 随枚举声明顺序变化,改顺序即出 bug;用 name() 或显式字段 |
| 枚举里持有可变状态 | 枚举是全局单例,可变状态会引发并发问题,优先无状态 |
| 把大段 IO 逻辑塞进枚举常量 | 枚举适合薄逻辑;重逻辑交给 Bean,枚举只做路由 |
| 跨服务传输枚举不校验 | 反序列化未知值会抛异常,用 valueOf 前先判断 contains |
九、单元测试怎么写
枚举策略最爽的一点,是天然适配参数化测试——每个枚举值都是现成的测试数据:
@ParameterizedTest
@EnumSource(UserType.class)
void everyTypeHasDiscount(UserType type) {
BigDecimal result = DiscountStrategy.valueOf(type.name())
.apply(new BigDecimal("100"));
assertNotNull(result);
assertTrue(result.compareTo(BigDecimal.ZERO) > 0);
assertTrue(result.compareTo(new BigDecimal("100")) <= 0);
}
参数化测试保证「新增枚举常量却忘了实现逻辑」会被立刻发现。JUnit5 的更多用法可看 JUnit5 单元测试实战。
十、落地清单
| 方案 | 适用 | 扩展成本 |
| 原生 if-else / switch | 分支极少且稳定 | 改方法,易错 |
| 枚举抽象方法 | 类型固定、逻辑简单 | 加枚举常量即可 |
| 枚举 + 函数式接口 | 无状态纯计算 | 加枚举常量即可 |
| Spring Bean + Map 注册 | 需依赖、动态扩展 | 加类即自动注册 |
十一、枚举策略 vs 接口策略:选型速记
一句话记忆:分支类型在编译期就确定、且数量有限,用枚举最省心;分支类型要在运行期从配置或数据库加载,用「接口 + Map 注册」更灵活。边界清楚了,代码既不会过度设计,也不会在加类型时处处漏改。
// 枚举策略:类型固定,编译期可知
DiscountStrategy.valueOf(type.name()).apply(amount);
// 接口策略:类型可配置,运行期加载
registry.get(config.getChannel()).apply(order);
总结:当你的分支是「按类型走不同逻辑」且类型相对固定,优先用枚举承载策略;当策略需要外部依赖或要动态扩展,升级为 Spring Bean 注册表。两种写法都能让你的 switch 和魔法字符串彻底消失,代码更稳、更好测、更好扩。




