ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis-Plus枚举映射:告别魔法数字,实现类型安全的状态管理

MyBatis-Plus枚举映射:告别魔法数字,实现类型安全的状态管理 说真的每次看到项目代码里出现status 1、if (status 2)这种“魔法数字”我心里都发毛。订单状态1是什么支付类型2又是什么新手接手代码必须对着数据库注释猜猜错就是线上事故。我在维护一个老订单系统时被这种写法折磨过很久后来彻底换成了枚举 MyBatis-Plus 的组合数据库存业务 codeJava 代码里全是类型安全的枚举双向转换由 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标记一个字段比如codeMP 就会自动把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. 五步落地从枚举类到完整 CRUD2.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意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() { ListOrderEntity list orderMapper.selectList( new LambdaQueryWrapperOrderEntity() .eq(OrderEntity::getStatus, OrderStatusEnum.PAID) ); System.out.println(list.size()); }MP 在处理eq参数时会自动把OrderStatusEnum.PAID转成对应的code值2最终生成的 SQL 就是WHERE status 2。整个过程中业务代码里始终没有出现一个魔法数字。如果是in查询也一样ListOrderEntity list orderMapper.selectList( new LambdaQueryWrapperOrderEntity() .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 能正确转换成 codeSQL 日志也没问题。但如果你在 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/descJackson 默认序列化枚举 name枚举类加JsonFormat(shape JsonFormat.Shape.OBJECT)前端传数字报反序列化错误缺JsonCreator方法枚举类加fromCode静态方法条件构造器枚举查询无效XML 手写 SQL 覆盖了 TypeHandler改用 LambdaQueryWrapper 或显式指定 typeHandler多数据源枚举全部失效数据源没注册枚举处理器每个 SqlSessionFactory 配置default-enum-type-handler修改枚举插入顺序后老数据对不上之前一直用 ordinal 存库写 SQL 迁移数据并按第 1 章方式改EnumValue5. 更复杂一点的扩展多字段枚举与状态流转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 还能做到一些很优雅的查询。比如查“所有可取消状态的订单”ListOrderEntity cancelableOrders orderMapper.selectList( new LambdaQueryWrapperOrderEntity() .in(OrderEntity::getStatus, OrderStatusEnum.values()) .eq(OrderStatusEnum::canCancel, true) // 这里注意不能这样用 );上面这种写法是我故意放在这里的错误示例枚举方法不能被直接用作查询条件。正确做法是先过滤出允许的枚举列表ListOrderStatusEnum cancelableStatuses Arrays.stream(OrderStatusEnum.values()) .filter(OrderStatusEnum::canCancel) .collect(Collectors.toList()); ListOrderEntity cancelableOrders orderMapper.selectList( new LambdaQueryWrapperOrderEntity() .in(OrderEntity::getStatus, cancelableStatuses) );MP 会自动把列表里的枚举转成 code 集合生成类似status IN (1,2)的 SQL。这个场景里你不需要在 SQL 层面写持久层判断业务语义都在枚举里逻辑非常清楚。5.3 枚举变化时的平滑演进建议最后聊一个架构层面的问题枚举这东西加一个值很容易改一个值很麻烦。比如线上已经有很多订单处于“已支付”状态突然产品说要改成“支付成功”数据库里 status2 的含义要不要改如果直接改枚举名历史数据完全不受影响因为数据库存的是 2 不是PAID字符串但如果你之前偷懒没加EnumValue数据库里存的就是 ordinal 对应的 0、1、2那么一旦调整枚举声明顺序历史数据全乱。所以我的建议是给枚举加EnumValue并始终使用业务 code不要依赖声明顺序数据库字段备注和枚举注释对齐code 含义写清楚不要复用已被使用的 code新增状态一律往后排涉及枚举含义变化时先发数据库迁移脚本刷新注释再发代码版本这套规则看起来简单但真能坚持做下来的团队不多。我见过不少项目开始很规范后来为了赶进度直接往枚举里塞临时状态code 还复用旧的最后线上的单子状态没人说得清。希望你在用枚举之前先跟团队对齐这些约定。我个人在实际项目里最大的体会是用了枚举 MyBatis-Plus 之后整个代码库对“状态”的理解变得高度一致。Java 层看到的是PAID数据库里存的是2接口返回的是{code:2,desc:已支付}三个维度对上了沟通成本直线下降。如果你现在还在被魔法数字折磨不用一步到位重构全部字段先拿一个订单状态字段试点跑顺了再推广。最后补一个小提醒不要把枚举当作大业务表的关联键来用它天生适合做状态、类型这种低基数字段放那种地方它能让你的代码干净一大截。
返回列表