Java 枚举与策略模式实战:告别冗余 if-else

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 和魔法字符串彻底消失,代码更稳、更好测、更好扩。

上一篇 Kubernetes 探针实战:Liveness、Readiness 与 Startup 配置
下一篇 ripgrep 实战:用 rg 极速搜索代码的完整指南
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]