说真的,每次看到项目代码里出现status = 1、if (status == 2)这种“魔法数字”,我心里都发毛。订单状态1是什么?支付类型2又是什么?新手接手代码必须对着数据库注释猜,猜错就是线上事故。我在维护一个老订单系统时被这种写法折磨过很久,后来彻底换成了枚举 + MyBatis-Plus 的组合,数据库存业务 code,Java 代码里全是类型安全的枚举,双向转换由 MP 自动搞定,再也不用写那一堆switch转换方法了。这篇就记录我在实际项目里怎么用的,踩过哪些坑,以及为什么这么设计。
这篇文章适合这几类人:正在学 SpringBoot + MyBatis-Plus 的朋友,被魔法数字困扰想重构状态字段的后端开发,以及想在团队里推行枚举落地但不知道从哪儿下手的同学。内容用的是我改造订单系统时的真实案例,你照着敲一遍就能跑通。
1. 为什么不用手写转换,而要交给 MyBatis-Plus 处理枚举
1.1 状态字段的三种常规存法,各有各的毛病
先说说我在不同项目里见过的状态字段存法,这里直接对比一下。
第一种是存纯数字,Java 实体类里声明成Integer status。这是最常见的写法,也是维护成本最高的。查询的时候status = 1,判空的时候status.equals(1),写业务的时候全是数字,你要是不过一遍文档根本不知道 1 是什么。有人会补充一个常量类来缓解,比如OrderStatusConstant.CREATED = 1,但常量类管不住取值,你给 status 赋一个99,编译器照样放行,等到数据进库了才发现状态乱套。
第二种是存字符串,比如"PAID"、"CREATED"。这种语义上清楚一些,但有几个明显的坑:字符串写错大小写比较不出来、索引体积更大、查询效率略低,而且一旦代码里枚举名改了(比如从PAID改成PAY_SUCCESS),数据库里的历史数据全部对不上。除非你有严格的迁移脚本跟着发,否则我建议别这么干。
第三种是直接存 Java 枚举的命名,也就是name(),用VARCHAR(20)存PAID。这其实跟第二种是一回事,只不过编译器稍微帮你加了点约束。但它同样有重命名即爆炸的问题,而且 MyBatis 默认往数据库写枚举的时候,用的其实是ordinal(),也就是枚举声明顺序的编号,这在后面会详细讲。
这三种方式共同的痛点是什么?Java 代码和数据库之间的“翻译”工作全部散落在业务代码里。你插入一条数据要调order.setStatus(OrderStatusEnum.PAID.getCode()),查询出来又要写一个OrderStatusEnum.fromCode(order.getStatus()),每个用到的 Service 都要重复一遍。一旦项目里有几十个枚举,你光写转换方法就写到手软。
1.2 MyBatis-Plus 的通用枚举处理思路
MyBatis-Plus 解决这个问题的核心思路很直接:底层的TypeHandler专门处理枚举类型。你在枚举类里用@EnumValue标记一个字段,比如code,MP 就会自动把OrderStatusEnum.PAID转成code的值写进数据库;反过来查询的时候,数据库的2会自动映射回PAID。
这个方案比手写转换好在哪里?最直观的一点是:你的实体类字段可以直接声明成枚举类型。
private OrderStatusEnum status;然后你写业务的时候,赋值、比较、条件构造全部用枚举,编译器帮你拦住拼写错误,代码可读性也强很多。数据库里存的是哪个值,由枚举类里的@EnumValue统一控制,改一处全局生效,不用在业务代码里翻来覆去找转换逻辑。
除了@EnumValue注解,MyBatis-Plus 还提供了一种办法:让枚举实现IEnum接口,重写getCode()方法。两种方式效果差不多,我实际项目里更习惯用@EnumValue,理由很朴素:不用多看一个接口,注解标记一目了然,而且 MP 多年版本一直兼容,已经足够稳了。IEnum适合那种处理外部系统传入的复杂枚举,一般场景用不上。
MP 为什么能自动识别?简单说,MP 在扫描到枚举字段时,会尝试触发内部注册的MybatisEnumTypeHandler。这个 handler 的构造逻辑会优先从枚举类里寻找标注了@EnumValue的字段,找到了就以它为数据库映射值;找不到就退回 MyBatis 默认的EnumTypeHandler。而默认的EnumTypeHandler用的恰恰是ordinal(),这也就是为什么网上很多教程里说“不加注解存进去的是 0、1、2”——那不是 MP 的问题,是枚举没告诉它用哪个字段。
2. 五步落地:从枚举类到完整 CRUD
2.1 第一步:创建带 @EnumValue 的枚举类
我项目里的订单系统有一个非常典型的状态字段,这里拿它举例:
import com.baomidou.mybatisplus.annotation.EnumValue; public enum OrderStatusEnum { CREATED(1, "已创建"), PAID(2, "已支付"), SHIPPED(3, "已发货"), DELIVERED(4, "已送达"), COMPLETED(5, "已完成"), CANCELED(6, "已取消"); @EnumValue private final int code; private final String desc; OrderStatusEnum(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }有两个细节需要特别说明:第一,@EnumValue理论上可以同时标记多个字段,但我强烈建议只标记一个,让“Java 枚举”和“数据库值”保持严格的一一对应,一旦多个字段都被标记,语义容易混乱,后续维护会非常难受。第二,code的类型和数据库字段类型必须匹配。上面例子用的是int,数据库里就用int或tinyint,如果你的业务值带前导零或者固定位数编号,可以考虑字符串,但通常不需要。
我见过有人在这里放一个枚举类的无状态字段,比如getDesc(),在业务里读 description 用,这在 Java 层没问题。但是如果误把desc打了@EnumValue,那数据库存的全是中文描述,查询条件也要用中文去匹配,这是一种极其脆弱的方案,千万别这么干。
2.2 第二步:实体类直接使用枚举类型
实体类这边不需要做什么特殊处理,字段类型直接声明成枚举:
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @Data @TableName("order_entity") public class OrderEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; private String orderNo; private OrderStatusEnum status; }有人会担心 MP 在生成 SQL 时认不认识这个字段。放心,MP 内部会为这个status字段动态绑定对应的 TypeHandler,你不需要显式写@TableField(typeHandler = ...)。这一点对新手特别友好——几乎没有学习成本,枚举类写好注解,实体类直接声明类型,剩下的全交给底层。但如果你的项目里有多个数据源,或者手动自定义过 MP 的配置,就需要按后面第 4 章说的去检查一下。
数据库表结构对应的最简单版本是这样:
CREATE TABLE `order_entity` ( `id` bigint(20) NOT NULL, `order_no` varchar(64) DEFAULT NULL, `status` int(11) DEFAULT NULL COMMENT '订单状态: 1-已创建,2-已支付...', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意status字段备注里我把每一个 code 对应的含义写清楚了,这是个好习惯,DBA 和新人看到字段就知道业务含义,不至于一定要翻代码。
2.3 第三步:配置全局枚举处理器
如果是普通的单数据源工程,用mybatis-plus-boot-starter3.3 以上的版本,其实你什么都不用配,MP 会默认注册MybatisEnumTypeHandler。不过为了严谨,也为了后续排查方便,我建议在application.yml里显式声明:
mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler这里有个容易被忽略的概念需要理清:MyBatis 原生提供了一个EnumTypeHandler和一个EnumOrdinalTypeHandler,它们分别是把枚举存成 name 字符串和 ordinal 数字,都不符合我们“用业务 code 映射”的需求。MP 提供的是MybatisEnumTypeHandler,它有一套自己的判断逻辑:先找@EnumValue,再找IEnum接口,最后回退到 name 或 ordinal。
所以如果你看到自己项目里配置的是别的手写 TypeHandler,或者项目沿用了很早的 MyBatis 配置,建议先确认它是不是继承自 MP 的这个处理器,避免枚举映射不生效。我见过一个老项目就是这样,前人在application.yml里自己配置了一个EnumTypeHandler,结果所有枚举都写成了字符串 name,重构的时候翻了一晚上 SQL 日志才发现。
2.4 第四步:插入与查询实测
以上配置完成后,写一个测试类直接跑:
import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class OrderMapperTest { @Autowired private OrderMapper orderMapper; @Test void testInsertWithEnum() { OrderEntity order = new OrderEntity(); order.setOrderNo("ORD-20250601-001"); order.setStatus(OrderStatusEnum.PAID); orderMapper.insert(order); System.out.println("主键: " + order.getId()); } }跑完之后,你去看数据库,status字段存进去的是2,不是PAID,也不是ordinal值(PAID在枚举里声明的序号是 1,但存进去是 2)。这一点可以非常直观地确认@EnumValue生效了。
再测查询:
@Test void testSelectById() { OrderEntity order = orderMapper.selectById(1L); System.out.println(order.getStatus()); // PAID System.out.println(order.getStatus().getDesc()); // 已支付 }查询出来的order.getStatus()直接就是一个OrderStatusEnum,后续想判断状态可以直接if (order.getStatus() == OrderStatusEnum.PAID),这就是类型安全的好处。
2.5 第五步:条件构造器中的枚举使用
如果你用 MP 的 LambdaQueryWrapper,枚举用在查询条件里也非常顺手:
@Test void testSelectByEnumCondition() { List<OrderEntity> list = orderMapper.selectList( new LambdaQueryWrapper<OrderEntity>() .eq(OrderEntity::getStatus, OrderStatusEnum.PAID) ); System.out.println(list.size()); }MP 在处理eq参数时,会自动把OrderStatusEnum.PAID转成对应的code值2,最终生成的 SQL 就是WHERE status = 2。整个过程中,业务代码里始终没有出现一个魔法数字。
如果是in查询也一样:
List<OrderEntity> list = orderMapper.selectList( new LambdaQueryWrapper<OrderEntity>() .in(OrderEntity::getStatus, OrderStatusEnum.CREATED, OrderStatusEnum.PAID) );枚举集合会被自动拆成对应的 code 数组。
3. 枚举返回给前端:序列化的几个坑
3.1 默认序列化为什么让前端很难受
数据库这块搞定了,新问题马上冒出来:接口返回给前端的是什么?SpringBoot 默认用 Jackson 序列化,枚举类型默认序列化的是name()字符串。也就是说,前端拿到的status字段是"PAID",而不是{"code":2,"desc":"已支付"}或者2这种更容易直接展示的值。
这意味着前端要自己维护一份“PAID 代表已支付”的映射。刚开始可能还好,枚举一多,前端同事就得一遍遍来问你“这个状态有什么含义”。所以如果你做的是管理后台,接口直接把 code 和 desc 都给全,前后端协同会舒服很多。
3.2 用 @JsonFormat 输出 code 和 desc
最简单的方案是在枚举类上加一个@JsonFormat(shape = JsonFormat.Shape.OBJECT):
import com.fasterxml.jackson.annotation.JsonFormat; @JsonFormat(shape = JsonFormat.Shape.OBJECT) public enum OrderStatusEnum { CREATED(1, "已创建"), PAID(2, "已支付"), // ... }加完之后,接口返回的status字段会变成一个 JSON 对象:
{ "code": 2, "desc": "已支付", "name": "PAID" }为什么会把name也带出来?因为 Jackson 对枚举序列化成对象时,除了 getter 方法能找到的code、desc,还会默认把枚举自带的name、ordinal这些属性也暴露出去。大多数情况下前端只需要code和desc,多一个name问题不大;如果你非常在意,可以在枚举里加@JsonIgnore标注不需要的 getter,或者写一个专属的 DTO 去收口。
但要注意一个点:@EnumValue管的是 MyBatis-Plus 的数据库映射,@JsonFormat管的是 Jackson 的 JSON 序列化,它们是两个维度的配置,可以共存,互不影响。我见过有人误以为加了@EnumValue前端就会自动收到 code,实际上完全不是一回事。
3.3 前端传数字进来怎么反序列化
接口不仅要返回,还要接收前端提交的状态值。前端往往传的是数字,比如提交{"status": 2}。但 Jackson 默认反序列化枚举,只能处理枚举name()字符串或数字索引。直接传数字2给OrderStatusEnum,大多数情况下会抛出反序列化异常或映射到错误的值。
解决办法是在枚举类里加一个带@JsonCreator的静态工厂方法:
import com.fasterxml.jackson.annotation.JsonCreator; public enum OrderStatusEnum { // 省略字段和构造方法 @JsonCreator public static OrderStatusEnum fromCode(int code) { for (OrderStatusEnum status : OrderStatusEnum.values()) { if (status.code == code) { return status; } } return null; } }这样当前端传{"status": 2}时,Jackson 会调用这个fromCode(2)拿到OrderStatusEnum.PAID。这个枚举对象进入到实体类,再交给 MP 插入或更新时,又会自动转回 code2写库,整条链路非常顺。
这里提一个经验:fromCode找不到对应值的时候,返回null比抛异常更安全。为什么?因为接第三方系统时,对方可能传过来一个你还没定义的未知状态,你直接抛异常可能导致整个接口 500;返回 null 字段,至少业务还能继续走,后续再做参数校验或者记录日志都可以。
4. 常见问题与排查实录
4.1 插进数据库的是 0 和 1,而不是业务 code
这是刚上手 MP 枚举时遇到最多的问题。现象:实体类已经用了枚举,但数据库存进去的值是 0、1、2,而且跟枚举声明的顺序完全一致,不是 code。
原因几乎只有一个:枚举类没有加@EnumValue,或者注解没有正确引入。这时候 MP 的MybatisEnumTypeHandler找不到映射字段,就沿用了 MyBatis 默认的EnumTypeHandler,把ordinal()给写进去了。
排查方式很简单,开启 MP 的 SQL 日志,看 insert 语句的参数到底传的是什么。如果传的是 0、1 这种与声明顺序一致的数字,马上检查枚举类引的包是不是com.baomidou.mybatisplus.annotation.EnumValue。有次我一个同事就是没注意 import,引成了别的包里的EnumValue,编译器也没报错,结果折腾了一下午。
4.2 查询条件传了枚举但走偏了索引
有一种情况比较隐蔽:你用LambdaQueryWrapper.eq(OrderEntity::getStatus, OrderStatusEnum.PAID),MP 能正确转换成 code,SQL 日志也没问题。但如果你在 XML 里手写了一个查询 SQL,比如:
select * from order_entity where status = #{status}传参时确实也会经过 TypeHandler 转换,这在多数情况下没问题。但如果你手写 SQL 的时候给参数指定了jdbcType或者typeHandler,就可能覆盖掉 MP 自动绑定的 handler,导致枚举被当成普通对象处理。遇到这种情况,直接在#{}里强制指定一个可靠的转换策略,或者干脆不要手写这条 SQL,换用 MP 的条件构造器,能省很多心。
4.3 多数据源、多模块分包导致枚举处理器失效
如果你用的不是单数据源,而是 dynamic-datasource 或者手工配置了多个 SqlSessionFactory,那就不能只依赖自动配置了。因为每个数据源都会构建一套独立的Configuration,枚举处理器不一定被注册到每个数据源上。
我的经验是:在每一个SqlSessionFactory的配置里,都显式声明default-enum-type-handler;或者写一个ConfigurationCustomizer,对每个数据源的Configuration手动注册:
@Bean public ConfigurationCustomizer mybatisPlusConfigurationCustomizer() { return configuration -> configuration.setDefaultEnumTypeHandler(MybatisEnumTypeHandler.class); }多模块分包还有一个坑:如果你把枚举类放在了common模块,而 MyBatis-Plus 扫描的 Mapper 包在业务模块,一般没问题;但如果你手工配置了类型处理器扫描路径,漏掉了公共模块的枚举包,就会出现“部分枚举正常、部分枚举异常”的诡异情况。这种问题排查起来最耗时间,建议一开始就把枚举类的包路径规划清楚,别散得到处都是。
4.4 常见问题速查表
| 现象 | 根因 | 解决办法 |
|---|---|---|
| 插入数据库变成 0、1 | 枚举类缺@EnumValue或引错包 | 检查注解,确认是 MP 包下的@EnumValue |
前端收到PAID而不是 code/desc | Jackson 默认序列化枚举 name | 枚举类加@JsonFormat(shape = JsonFormat.Shape.OBJECT) |
| 前端传数字报反序列化错误 | 缺@JsonCreator方法 | 枚举类加fromCode静态方法 |
| 条件构造器枚举查询无效 | XML 手写 SQL 覆盖了 TypeHandler | 改用 LambdaQueryWrapper 或显式指定 typeHandler |
| 多数据源枚举全部失效 | 数据源没注册枚举处理器 | 每个 SqlSessionFactory 配置default-enum-type-handler |
| 修改枚举插入顺序后老数据对不上 | 之前一直用 ordinal 存库 | 写 SQL 迁移数据,并按第 1 章方式改@EnumValue |
5. 更复杂一点的扩展:多字段枚举与状态流转
5.1 带多个业务字段的枚举设计
当系统复杂到一定阶段,一个枚举只带 code 和 desc 往往不够用。比如订单状态除了“已创建、已支付”这些描述,你可能还需要“这个状态下是否允许取消”“前端展示用什么颜色”“是否需要发短信通知”等信息。如果这些逻辑散落在 if-else 里,每加一个状态就要改一遍判断逻辑,非常容易漏。
这部分逻辑可以全部收进枚举类:
public enum OrderStatusEnum { CREATED(1, "已创建", true, "#888888", true), PAID(2, "已支付", true, "#1677ff", false), SHIPPED(3, "已发货", false, "#faad14", false), COMPLETED(5, "已完成", false, "#52c41a", false), CANCELED(6, "已取消", false, "#ff4d4f", false); @EnumValue private final int code; private final String desc; private final boolean cancellable; private final String color; private final boolean notifyOnEnter; // 构造方法与 getter 省略 public boolean canCancel() { return cancellable; } }这样业务代码里判断是否允许取消,一行搞定:
if (order.getStatus().canCancel()) { // 执行取消逻辑 }状态流转的逻辑也能集中管理。我习惯在枚举里加一个“允许流转到哪些状态”的方法:
public boolean canTransitTo(OrderStatusEnum target) { switch (this) { case CREATED: return target == PAID || target == CANCELED; case PAID: return target == SHIPPED || target == CANCELED; case SHIPPED: return target == DELIVERED || target == COMPLETED; default: return false; } }在 Service 里统一校验:
if (!order.getStatus().canTransitTo(newStatus)) { throw new IllegalStateException("订单不能从 " + order.getStatus() + " 流转到 " + newStatus); }这种设计把状态的规则收敛到了一个文件里,新增状态时只改枚举,Service 层几乎不动,代码维护成本明显下降。很多人觉得枚举只能放常量,其实这种带行为的枚举在 Java 世界里是非常自然、非常好用的工具。
5.2 与 LambdaQueryWrapper 结合的范围查询
枚举配合 MP 还能做到一些很优雅的查询。比如查“所有可取消状态的订单”:
List<OrderEntity> cancelableOrders = orderMapper.selectList( new LambdaQueryWrapper<OrderEntity>() .in(OrderEntity::getStatus, OrderStatusEnum.values()) .eq(OrderStatusEnum::canCancel, true) // 这里注意不能这样用 );上面这种写法是我故意放在这里的错误示例,枚举方法不能被直接用作查询条件。正确做法是先过滤出允许的枚举列表:
List<OrderStatusEnum> cancelableStatuses = Arrays.stream(OrderStatusEnum.values()) .filter(OrderStatusEnum::canCancel) .collect(Collectors.toList()); List<OrderEntity> cancelableOrders = orderMapper.selectList( new LambdaQueryWrapper<OrderEntity>() .in(OrderEntity::getStatus, cancelableStatuses) );MP 会自动把列表里的枚举转成 code 集合,生成类似status IN (1,2)的 SQL。这个场景里,你不需要在 SQL 层面写持久层判断,业务语义都在枚举里,逻辑非常清楚。
5.3 枚举变化时的平滑演进建议
最后聊一个架构层面的问题:枚举这东西,加一个值很容易,改一个值很麻烦。比如线上已经有很多订单处于“已支付”状态,突然产品说要改成“支付成功”,数据库里 status=2 的含义要不要改?如果直接改枚举名,历史数据完全不受影响,因为数据库存的是 2 不是PAID字符串;但如果你之前偷懒没加@EnumValue,数据库里存的就是 ordinal 对应的 0、1、2,那么一旦调整枚举声明顺序,历史数据全乱。
所以我的建议是:
- 给枚举加
@EnumValue并始终使用业务 code,不要依赖声明顺序 - 数据库字段备注和枚举注释对齐,code 含义写清楚
- 不要复用已被使用的 code,新增状态一律往后排
- 涉及枚举含义变化时,先发数据库迁移脚本刷新注释,再发代码版本
这套规则看起来简单,但真能坚持做下来的团队不多。我见过不少项目开始很规范,后来为了赶进度,直接往枚举里塞临时状态,code 还复用旧的,最后线上的单子状态没人说得清。希望你在用枚举之前,先跟团队对齐这些约定。
我个人在实际项目里最大的体会是:用了枚举 + MyBatis-Plus 之后,整个代码库对“状态”的理解变得高度一致。Java 层看到的是PAID,数据库里存的是2,接口返回的是{"code":2,"desc":"已支付"},三个维度对上了,沟通成本直线下降。如果你现在还在被魔法数字折磨,不用一步到位重构全部字段,先拿一个订单状态字段试点,跑顺了再推广。最后补一个小提醒:不要把枚举当作大业务表的关联键来用,它天生适合做状态、类型这种低基数字段,放那种地方,它能让你的代码干净一大截。