枚举的类型决策与工程实践
枚举解决的不是「怎么写常量」,而是「哪些值合法」该由谁约束。值域封闭、新增值会牵动已有逻辑时,交给编译器;值域开放、新值只需透传时,用原始类型。枚举定义值域,状态机定义流转——二者不应混在同一层。
1 什么是枚举
枚举(enumerated type)是一种取值被限定在固定集合内的类型。在 Java 中,枚举是特殊的类,每个枚举常量是该类的唯一实例——Color.RED 与 Color.BLUE 是两个不同的 Color 对象。编译器保证不能把 Color 赋给 Weekday,也不能传入不存在的值。
枚举的本质,是把「哪些值合法」从注释、文档、口头约定中提取出来,编码进类型系统,由编译器执行约束。
2 枚举的三项能力
枚举在代码里承担三项彼此不可替代的职责。
2.1 编译期穷举
对枚举做 switch,编译器要求覆盖所有分支。新增枚举值时,所有未更新的 switch 编译报错,迫使开发者逐个处理:
boolean isFinal(PayStatus status) {
return switch (status) {
case PAID, CLOSED, REVOKED, FAILED -> true;
case UNPAID, PAYING -> false;
// 新增 REFUNDED 时,此处编译报错:分支未覆盖
};
}
若改用字符串 if-else,新增状态不会触发任何告警,请求会静默落入 else 分支,直到线上才发现某个状态未处理。
2.2 类型安全比较
PAID 拼错成 PIAD,字符串比较不会报错;枚举常量拼错,编译不过。在支付系统里,这一差异直接对应资损风险。
另外,字符串比较为避免空指针,常把常量放前面:"PAID".equals(status),主谓倒装,不符合自然语序。枚举则没有这个问题:status != PayStatus.PAID 读作「当前值不是已支付」。
2.3 语义聚合
一个枚举类把一个维度的所有合法值聚在一处,同时携带 code(存储/传输值)、name(展示值)和 of() 反查方法。读 PayStatus 的定义即可知道支付有哪些状态,无需查数据库或外部文档。
3 枚举还是原始类型
需要表达一组命名值时,类型层面的决策只有两个:枚举(封闭) 或 原始类型(开放)。原始类型指未封装领域语义的基础类型,最常见的是 String,也可以是 Integer。
判断标准:新增值时,已有代码是否必须同步修改?
- 是 → 枚举
- 否 → 原始类型,必要时配合策略模式或数据库表
3.1 枚举:编译器替你管
符合以下两个条件时使用枚举:
值域封闭。 合法值有限且可枚举,不会在运行时动态新增。支付状态是典型的封闭值域——UNPAID、PAID、CLOSED、REVOKED 不会因接入新渠道而自动多出一个值。
新增值时已有代码必须同步修改。 这是枚举的核心价值。新增支付状态时,状态机要加迁移规则、isFinal() 要加判断、轮询逻辑要处理、对账要覆盖——已有代码必须改。此时枚举的编译报错是保障:漏了一个分支,编译不过。
3.2 原始类型:开放兼容未知值
字段类型为 String 或 Integer 时,天然兼容任意值。消费方用 if-else 匹配,以 default 兜底处理未知值,新增值无需改已有代码。
原始类型有两种组织方式:
- 有标准值集:用
final class+static final String常量组织已知值,避免裸写字符串,拼错常量名会编译报错。 - 无标准值集:直接使用裸字符串。业务类型(
bizType)是典型——不同产品线各自扩展,值域完全不封闭。新增bizType即传一个新字符串,没有任何代码需要修改。若该字段同时有状态机语义,在领域方法中做防御性判断,而非依赖枚举约束。
3.3 对比
| 枚举 | 原始类型 | |
|---|---|---|
| 值域 | 编译期固定 | 运行时可扩展 |
| 新增值 | 改代码 + 重新编译 + 全链路升级 | 不需要改已有代码 |
| 编译期检查 | switch 穷举 + 类型安全 |
无(常量类可防拼错常量名,但不能穷举) |
| 兼容未知值 | 不兼容 | 兼容 |
| 典型场景 | 支付状态、退款状态 | 业务类型 |
枚举与原始类型是类型决策——编译器管不管你。常量类只是原始类型的编码规范——已知值是否用命名引用。
3.4 特殊逻辑:第三个维度
原始类型往往意味着没有特殊逻辑——值只做透传、存储、展示,不同值之间代码行为一致。一旦出现特殊逻辑(不同值走不同分支、触发不同处理),通常需要转为枚举,或引入插件化。
策略模式只能保证已有代码不动,不能保证「零代码」。新值若需要新策略实现,仍要写新的策略类。真正不改代码的情况只有两种:新值没有特殊逻辑(透传),或新值路由到已有策略(路由可配置,如数据库表维护值→策略映射)。
关键判断:新值经常路由到已有策略,还是几乎总是需要新策略实现?
| 情况 | 选型 |
|---|---|
| 值多、策略少,新值常复用已有策略 | 原始类型 + 策略模式 + 可配置路由 |
| 值与策略基本 1:1,新值几乎必写新策略 | 枚举(编译期穷举兜底) |
原始类型 + 有特殊逻辑 = 危险信号。 要么转枚举,要么插件化。否则就是在用 if-else 链对原始类型做穷举——没有编译器兜底,新增值会静默落入 else 分支。
4 枚举与状态的区别
枚举和状态是最易混淆的一对概念。很多人把二者画等号,但它们处于不同层面。
扑克牌的值域是固定的——52 张牌,四个花色,A 到 K。但斗地主、德州扑克、21 点的规则完全不同。同一张红桃 K,在斗地主里是最大的单张之一,在 21 点里值 10 分,在德州扑克里能组成同花顺。牌面不会印「我能压 Q 但不能压 A」——这句话在 21 点里毫无意义。牌只定义有哪些值,规则由各游戏自己维护。
枚举是那副牌,状态是游戏规则。
- 枚举(类型层):一组封闭的命名常量,只回答「有哪些合法值」。
- 状态(业务层):对象在其生命周期中所处的阶段,回答「现在处于什么阶段、能流转到哪些阶段、流转需满足什么条件」。
4.1 流转规则不属于枚举
PayStatus 只定义六个合法值,「哪些状态能转到哪些状态」不在枚举里,而在领域方法的迁移守卫中:
// 迁移守卫:判断当前状态是否能流转到目标状态
public boolean canTransitTo(PayStatus target) {
// 只判断状态维度的合法性,不包含金额、渠道等上下文条件
}
守卫是状态流转前的准入检查,属于领域方法,不属于枚举。简单时只判断当前状态,复杂时可叠加金额、渠道、时间窗口等条件。
若把 canTransitTo(REVOKED) 写进枚举,问题立刻出现:付款码场景下 PAYING → REVOKED 合法,扫码支付场景下不合法。同一个 PAYING 值,在不同业务流程里流转规则不同。枚举没有上下文,canTransitTo() 返回 true 还是 false 都不对。而且真正的守卫往往是「状态是 PAYING 且金额校验通过且渠道未关单」——canTransitTo() 只能表达状态维度,容易给人「已经检查过了」的错觉。
4.2 医疗医嘱:同一值域,不同规则
医嘱状态值域固定:DRAFT、SUBMITTED、REVIEWED、EXECUTING、COMPLETED、CANCELED。这副「牌」在所有科室通用,但流转规则不同:
- 门诊:提交后直接进药房执行
- 住院:提交后经药师审核才能执行
- 急诊:可跳过审核直接执行
若把 canTransitTo(EXECUTING) 写进枚举,门诊说 true、住院说 false、急诊说 true——枚举不知道当前是哪个流程,回答不了。
正解:枚举只管定义值域,流转规则放迁移守卫层。规则变更(如住院引入新审核节点)只改守卫实现,不动枚举定义。
4.3 状态的正交分离
订单状态、支付状态各自独立枚举,而不是揉成一个 ORDERED_PAID、SHIPPED_UNPAID 的笛卡尔积。正交分离让每个维度的枚举独立演进;业务规则变更(如「必须付款才能确认订单」变为「先用后付」)只需调整迁移守卫,不修改状态枚举定义。
枚举定义值域,状态定义流转。牌不变,规则随游戏变。
5 什么时候需要中间态
状态机设计里最高频的纠结:UNPAID 和 PAID 之间,要不要加 PAYING?INIT 和 SUCCESS 之间,要不要加 PROCESSING?
判断标准:操作是否异步?异步期间系统是否需要做不同的事情?
5.1 需要中间态的三种场景
异步操作 + 轮询
支付是异步的——调渠道接口后返回「处理中」,需要轮询直到拿到终态。没有 PAYING,系统无法区分「还没发起支付」和「已经发起但渠道还没返回」——前者可以重新发起,后者不能重复发起。
UNPAID 和 PAYING 的行为差异是真实的:UNPAID 下可修改订单金额、更换渠道;PAYING 下订单被锁,不能重复发起,只能轮询或等回调。没有这一区分,要么允许重复发起(幂等性风险),要么一律锁死(体验差)。
stateDiagram-v2
[*] --> UNPAID
UNPAID --> PAYING : 发起支付
PAYING --> PAID : 成功
PAYING --> FAILED : 失败
PAYING --> CLOSED : 关单
PAYING --> REFUNDING : 退款
REFUNDING --> REFUNDED : 退款成功
REFUNDING --> FAILED : 退款失败
UNPAID --> REVOKED: 撤销
PAID --> [*]
FAILED --> [*]
CLOSED --> [*]
REVOKED --> [*]
REFUNDED --> [*]跨服务编排 + 可恢复的合法中间状态
跨服务编排(RPC + 本地操作)时,中间状态有时不是枚举值,而是字段值的组合。典型模式是「先存本地 → RPC → 回写」:
-- pay_mchid 为空表示「已创建但尚未关联支付商户号」
INSERT INTO merchant (id, name, pay_mchid) VALUES (1, '某商户', NULL);
payMchid = null 是合法的中间状态——商户已存在,只是还没关联支付商户号。系统崩溃后恢复时,扫到 payMchid = null 的记录就知道要重试 RPC。
关键不在「要不要加枚举值」,而在中间状态必须合法可观测。判断诀窍:违反这条规则后,数据是否处于不合法状态?商户无 mchid → 合法(商户可先建,支付配置后补)。租户无管理员 → 不合法(系统无法运行)→ 不能用此模式,必须强一致事务。
终态可达性保障
每个中间态都必须有一条通往终态的路。PAYING 最终会变成 PAID、FAILED 或 CLOSED;PROCESSING 最终会变成 SUCCESS 或 FAILED。若某个中间态没有可达的终态,系统会卡死。
项目里踩过一个真实的坑:isFinalPayStatus() 漏掉了 REVOKED,导致撤销后轮询不停止——系统认为 REVOKED 不是终态,继续轮询。
中间态和终态是配对设计的。加一个中间态,必须确认它能到达哪些终态;加一个终态,必须更新所有终态判断方法。
5.2 什么时候不需要中间态
同步操作不需要中间态。 商户申请提交是同步的——填表、提交、进入审核流程。没有「提交中」的异步等待期,直接从 PENDING 到 APPROVED / REJECTED。
没有可观测行为差异的中间态不需要。 若「未发起」和「已发起待确认」在系统行为上完全一样——没有轮询、没有锁定、没有超时处理——加中间态只是扩大状态空间,没有收益。
过度设计的中间态不需要。 把异步过程拆成 PAYMENT_REQUEST_SENT → PAYMENT_CHANNEL_PROCESSING → PAYMENT_CALLBACK_RECEIVED → PAID,若没有任何代码在这些中间值上分支,这些值就是噪音。中间态的价值在于有代码在它上面做不同的事情。
5.3 决策流程
flowchart TD
A[操作是否异步?] -->|否| B[不需要中间态]
A -->|是| C{异步期间是否有可观测的行为差异?}
C -->|否| B
C -->|是| D[需要中间态]
D --> E{每个中间态都有可达的终态?}
E -->|是| F[设计合理]
E -->|否| G[重新设计]5.4 起始态也要审视
充值状态的设计:
DEPOSITING → SUCCESS / FAILED (无 INIT 状态)
没有单独的 INIT——充值的发起是同步的(创建充值记录即发起),起始态本身就是「进行中」。这比 INIT → DEPOSITING → SUCCESS 更简洁,因为 INIT 和 DEPOSITING 之间没有可观测的行为差异。
起始态和中间态若无行为差异,合并为一个。 PayStatus 保留 UNPAID 和 PAYING 的区分,是因为二者行为不同(可修改 vs 已锁定)。若行为相同,不要为「语义完整」而拆成两个状态。
6 工程实践
以上是类型决策层面的思考。落地时,项目沉淀了一套具体的工程实践。
6.1 CommonEnum 基类体系
不要每个枚举各自定义 getCode()、getName()、of()。统一抽取基类接口:
public interface CommonEnum<C> {
C getCode();
String getName();
static <E extends CommonEnum<C>, C> E of(Class<E> enumType, C code) {
if (code == null) return null;
for (E e : enumType.getEnumConstants()) {
if (e.getCode().equals(code)) return e;
}
return null;
}
}
public interface StringEnum extends CommonEnum<String> {
// 标记接口:code 类型为 String
}
public interface IntegerEnum extends CommonEnum<Integer> {
// 标记接口:code 类型为 Integer
}
设计要点:
CommonEnum<C>泛型化,code类型不绑死StringEnum/IntegerEnum是零方法的标记接口,用于约束code类型和辅助类型识别of()提供统一反查,不用每个枚举自己写valueOf或fromCode
6.2 StringEnum 还是 IntegerEnum
判断标准不是「默认选哪个」,而是看值之间的关系形状。
值之间存在流转关系 → StringEnum
值与值之间有方向性,会从一个变到另一个。典型代表是单据状态:订单、支付、退款、审批。这些值适合用英文语义命名:UNPAID、PAID、CLOSED、REVOKED——每个名字就是一段业务语义。DB 里存 "PAID" 比 1 可读得多,排障时直接 SELECT * FROM pay_order WHERE status = 'PAID'。这类枚举值通常数量稳定,加一个值的代价可以接受。
public enum PayStatus implements StringEnum {
UNPAID("待支付"),
PAYING("支付中"),
PAID("已支付"),
CLOSED("已关闭"),
REVOKED("已撤销"),
FAILED("支付失败");
// ...
}
值之间是平行关系 → IntegerEnum
互不交叉,没有流转方向,各自独立存在。典型代表是来源、渠道。特征:值之间无流转关系;经常修改(新接入渠道、新开来源是高频操作);很难给每个值一个恰当的英文命名——「来源 = 1」可能是微信小程序,也可能是抖音直播,名字由业务运营定义,强行翻译既冗长又不稳定。数字编码在这里是身份标识,含义由 name(中文名)承载,code 只是稳定的引用键。
public enum ChannelSource implements IntegerEnum {
WEIXIN_APP(1, "微信小程序"),
WECHAT_MP(2, "微信公众号"),
DOUYIN(3, "抖音直播"),
KUAISHOU(4, "快手直播");
// 新增渠道只加常量,不影响已有代码
}
| 特征 | StringEnum | IntegerEnum |
|---|---|---|
| 值之间关系 | 有流转方向(状态机) | 平行分类(互不交叉) |
| 典型场景 | 单据状态、审批状态 | 来源、渠道 |
| 修改频率 | 低——状态值稳定 | 高——运营经常增减 |
| 英文命名 | 天然贴合业务语义 | 困难,名字不稳定 |
| code 的角色 | 语义标识(PAID 本身就是含义) |
身份键(数字是引用键) |
| DB 可读性 | 高 | 低,靠 name 补偿 |
同一领域概念不要一部分用 StringEnum、一部分用 IntegerEnum,混用会让统一处理逻辑变复杂。
6.3 枚举编写模板
@Getter
@RequiredArgsConstructor
public enum PayStatus implements StringEnum {
UNPAID("待支付"),
PAYING("支付中"),
PAID("已支付"),
CLOSED("已关闭"),
REVOKED("已撤销"),
FAILED("支付失败"), // 最后一个常量后保留逗号
;
private final String name;
@Override
public String getCode() {
return this.name();
}
public static PayStatus of(String code) {
return CommonEnum.of(PayStatus.class, code);
}
}
约定:
@RequiredArgsConstructor生成构造函数;@Getter生成getName();getCode()手写,返回name()- 最后一个常量后保留逗号——新增状态时
git diff只显示新增行,不显示「上一行加了逗号」的噪音改动 - 不带
Enum后缀,用业务语义命名
6.4 枚举与对象转换:MapStruct 统一处理
枚举在系统中要跨越多层边界:DB 存 code 值(String 或 Integer),领域对象用枚举类型,API 响应又输出 code 字符串。DO 的 String status ↔ Domain 的 PayStatus ↔ API Response 的 String status,每经过一层就要转换一次。
若每个 Converter 都手写枚举转换,几十个枚举就是几十份重复代码。项目的解法是:StringEnum / IntegerEnum 标记接口 + 统一的 CommonEnumMapper,让 MapStruct 自动处理所有枚举转换。
@Component
public class CommonEnumMapper {
public <E extends StringEnum> E string2Enum(String code, @TargetType Class<E> enumType) {
if (code == null) return null;
return CommonEnum.of(enumType, code);
}
public <E extends StringEnum> String enum2String(E enumValue) {
return enumValue == null ? null : enumValue.getCode();
}
public <E extends IntegerEnum> E integer2Enum(Integer code, @TargetType Class<E> enumType) {
if (code == null) return null;
return CommonEnum.of(enumType, code);
}
public <E extends IntegerEnum> Integer enum2Integer(E enumValue) {
return enumValue == null ? null : enumValue.getCode();
}
}
关键在 <E extends StringEnum> 泛型约束和 @TargetType 注解。MapStruct 看到 DO 字段是 String、Domain 字段是 PayStatus(实现了 StringEnum),会自动调用 string2Enum,并把目标类型 PayStatus.class 通过 @TargetType 传入。不需要为每个枚举写专门的转换方法。
用 @MapperConfig 全局注册:
@MapperConfig(uses = {
CommonEnumMapper.class
})
public interface CommonMapperConfig {
}
7 总结
枚举的价值不在语法层面,而在类型决策层面。值域封闭且新增值会牵动已有逻辑时,用枚举让编译器强制穷举;值域开放或新值只需透传时,用原始类型保持兼容。枚举只定义「有哪些值」,状态流转规则放在迁移守卫层,不与枚举混写。中间态的存在取决于异步期间是否有可观测的行为差异,而非语义是否「看起来完整」。
落地时,CommonEnum 基类体系 + CommonEnumMapper + MapStruct 全局注册,把枚举的 code/name 约定和跨层转换统一收口,避免每个枚举重复造轮子。
评论