MyBatis-Plus JSON字段自动映射实体类:TypeHandler原理与实战
发布时间:2026/10/9 4:56:07 锦皓数字建站

做 Java 后端的兄弟十有八九都跟数据库里的 JSON 字段打过交道。业务扩展属性、配置快照、第三方回调原文、埋点数据都爱往一个 TEXT 或者 JSON 类型的列里塞。最痛苦的不是写入而是读取MyBatis-Plus 查出来默认是个 StringService 层每次都要手动new ObjectMapper().readValue()写回去又得writeValueAsString()代码里到处是 try-catch、魔法字段名和重复的工具方法。我之前做电商的商品中心时商品详情、规格属性、销售参数全堆在一张表的 ext_info 字段里每个查询接口都得经过一道手工 JSON 转换后来统一改用 TypeHandler 实现了 JSON 自动转实体类实体里直接声明ListSpecItem、MapString, String这种强类型字段CRUD 代码瞬间干净了一大截。这篇文章就把这套玩法的选型思路、源码原理和避坑经验完整聊一遍适合正在被 JSON 字段映射折腾的同学直接拿去做参考。1. 为什么要做 JSON 自动转实体类1.1 数据库设计中的 JSON 字段困境随着业务迭代MySQL 5.7 引入的原生 JSON 类型以及各种 NoSQL 存储的普及把结构化数据和半结构化数据混在一张表里的设计越来越常见。典型场景包括用户画像的标签集合一个字段存一个数组订单里的商品快照下单时把商品详情、价格、促销信息整个冗余进去配置中心的规则引擎数据前端表单提交的动态表单结构第三方回调日志要保留原始报文精确定位问题。表面上用一个 String 字段接收就完了但麻烦在后头。首先是类型不安全从数据库读出来之后没人能保证这个字符串里到底是不是你期望的那个结构一个手滑把数组当对象解析运行期直接爆异常。其次是代码重复每个用到的 Service 都要写 ObjectMapper 的读取逻辑处理 null、处理异常、处理未知字段换个 JSON 库又得全部重写。第三是可维护性差字段结构调整的时候所有读这个 JSON 的地方都得跟着改漏改一个就是线上事故。而“自动转实体类”就是把这些转换过程全部下沉到 ORM 框架层面让数据库字段和 Java 字段天然对齐。Service 里拿到手就是强类型的业务对象写入时传对象进去框架帮你序列化成 JSON 字符串再落库。这个思路不止为了少写几行代码更重要的是把“数据结构”和“存储格式”两个问题彻底分开让上层业务只关心数据结构本身。1.2 手动转换的痛点与自动化收益手动转换最常见的样子是这样// 读取 ProductExtInfo extInfo JSON.parseObject(product.getExtInfoJson(), ProductExtInfo.class); // 修改 product.setExtInfoJson(JSON.toJSONString(extInfo));看起来不复杂项目一大了就变味。先是每个类都 import 了不同的 JSON 工具有的是 fastjson有的是 Jackson有的是 Hutool然后异常处理风格混乱有人 swallow 掉异常返回 null有人直接抛运行时异常。更隐蔽的是性能问题每次查询都 new 一个 ObjectMapper在高并发下这个初始化成本被无限放大。用 TypeHandler 之后这些转换全部收口到一行配置里。MyBatis 在写库时自动调用setNonNullParameter序列化读库时自动调用getNullableResult反序列化。对于上层代码来说感知不到 JSON 的存在就像数据库里真的存了这些对象一样。拿我的商品中心举例改造后 ext_info 字段的读写逻辑从每个接口里消失全部由 Mapper 层接管新增一个商品扩展属性只需要改实体类不用动 Service 代码。2. 方案选型内置 JacksonTypeHandler 还是自定义 TypeHandler2.1 MyBatis-Plus 内置的 JacksonTypeHandlerMyBatis-Plus 从 3.2.0 左右开始内置了com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler它基于 Jackson 实现可以直接通过注解挂在实体字段上TableName(value product, autoResultMap true) public class Product { TableId(type IdType.AUTO) private Long id; TableField(typeHandler JacksonTypeHandler.class) private ProductExtInfo extInfo; }这里有两个配置点必须同时出现缺一个都会失效实体类上标注TableName(autoResultMap true)作用是告诉 MyBatis-Plus 要为该实体生成包含 typeHandler 的结果映射 ResultMap而不是走默认的自动映射字段上标注TableField(typeHandler JacksonTypeHandler.class)作用是向这个字段注册对应的类型处理器。对于大多数只存一层普通实体、Map、List 的场景内置的 JacksonTypeHandler 是够用的。它在反序列化时会根据实体类字段的泛型信息确定目标类型比如ListSkuAttr就能转成对应的ListSkuAttr而不是一个裸的ListLinkedHashMap。2.2 自定义 TypeHandler 的适用场景内置的 JacksonTypeHandler 也不是万能药。我遇到过几个必须自己写 TypeHandler 的场景项目里统一用 fastjson不想为了一个字段引入 Jackson需要加密存储写库时把 JSON 串加密读库时先解密再反序列化需要定制日期格式或者把空字符串统一转成 null字段类型有多层泛型嵌套内置 handler 反序列化时类型推断不准。此时继承BaseTypeHandlerT重写四个方法即可setNonNullParameter、getNullableResult(ResultSet, String)、getNullableResult(ResultSet, int)、getNullableResult(CallableStatement, int)。后面我会给出一个可复用的完整实现。整体原则是能用内置就用内置维护成本最低有特殊需求先评估是不是少数派再决定要不要自己动手。2.3 三个方案的对比总表方案优点缺点适用场景MyBatis-Plus 内置 JacksonTypeHandler零开发量官方维护依赖 Jackson类型推断有局限最常见的 JSON 字段映射自定义 BaseTypeHandler灵活可加解密、可定制需要写代码和测试有特殊转换需求全局配置 ObjectMapper 拦截器自动覆盖所有实体侵入性强模板复杂不适合多数项目3. 实操落地注解配置与核心代码3.1 环境准备与依赖我的主力组合是 Spring Boot 2.7 MyBatis-Plus 3.5.3 MySQL 8.0独立依赖只需要 Jackson。如果你的项目导入了mybatis-plus-boot-starter它本身会带上 Jackson 的传递依赖但主动声明一份更稳妥方便控制版本。pom.xml 里核心几个依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency提醒一句MyBatis-Plus 3.5.3 之后部分版本对 Spring Boot 3.x 有单独的 starter。如果你的项目是 Spring Boot 3请使用mybatis-plus-spring-boot3-starter否则会出现自动装配失效的问题。3.2 实体类与数据库表设计数据库表设计时JSON 字段的类型优先使用 MySQL 原生 JSON而不是 TEXT。虽然 TypeHandler 对两者都能处理但原生 JSON 类型在插入时会校验合法性查询时可以走 JSON 相关的函数和索引后续扩展空间更大。下面是建表 SQLCREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, name varchar(128) NOT NULL, ext_info json DEFAULT NULL, sku_list json DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对应 Java 实体类Data TableName(value product, autoResultMap true) public class Product { TableId(type IdType.AUTO) private Long id; private String name; TableField(typeHandler JacksonTypeHandler.class) private ProductExtInfo extInfo; TableField(typeHandler JacksonTypeHandler.class) private ListSkuItem skuList; }这里有两个细节容易被忽略ProductExtInfo和SkuItem必须是可被 Jackson 反序列化的普通 POJO也就是要有无参构造和标准的 setter或者字段上加上对应注解skuList的泛型参数一定要写清楚写成裸List在反序列化时拿到的是ListLinkedHashMap后续强转会踩坑。3.3 内置 JacksonTypeHandler 的最小可用示例Mapper 接口不用额外配置继承 BaseMapper 就够public interface ProductMapper extends BaseMapperProduct { }写入时的测试代码ProductExtInfo extInfo new ProductExtInfo(); extInfo.setSource(manual); extInfo.setTags(List.of(热卖, 包邮)); Product product new Product(); product.setName(新款保温杯); product.setExtInfo(extInfo); product.setSkuList(List.of( new SkuItem(红色, S, 49.9), new SkuItem(蓝色, L, 59.9) )); productMapper.insert(product);查询时的测试代码Product product productMapper.selectById(1L); ProductExtInfo extInfo product.getExtInfo(); ListSkuItem skuList product.getSkuList();到这一步你不需要写一行 JSON 转换逻辑。写入时 MyBatis-Plus 自动调用 Jackson 把extInfo和skuList序列化进json列查询时自动反序列化成强类型对象。实际生产项目里我会在单元测试里把这两个用例固定下来防止后面改实体结构时破坏映射。3.4 自定义 TypeHandler 的完整实现项目里统一用 fastjson 的话可以自己实现一个基于 fastjson 的 TypeHandler。下面是我在某个老项目里直接用的通用版本以 fastjson 2 的 API 为例import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONWriter; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.MappedJdbcTypes; import org.apache.ibatis.type.MappedTypes; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; MappedTypes(List.class) MappedJdbcTypes(JdbcType.VARCHAR) public class FastjsonListTypeHandler extends BaseTypeHandlerList? { Override public void setNonNullParameter(PreparedStatement ps, int i, List? parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, JSON.toJSONString(parameter, JSONWriter.Feature.WriteMapNullValue)); } Override public List? getNullableResult(ResultSet rs, String columnName) throws SQLException { String json rs.getString(columnName); return parseJson(json); } Override public List? getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String json rs.getString(columnIndex); return parseJson(json); } Override public List? getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String json cs.getString(columnIndex); return parseJson(json); } private List? parseJson(String json) { if (json null || json.isEmpty()) { return null; } return JSON.parseArray(json); } }这里有个明显的局限Handler 本身拿不到字段的目标类型返回的是通用 List需要你在 Service 里再转一次。更完善的方案是结合 MyBatis-Plus 的TableInfo拿字段类型在 Handler 内部用反射解析泛型或者在 resultMap 注册时把javaType带进去。但说实话除非项目已经有 fastjson 的强依赖否则我建议优先用内置的JacksonTypeHandler自己维护 TypeHandler 的隐性成本不低。4. 深入细节源码级原理解析4.1 TypeHandler 在 MyBatis 执行链路中的位置MyBatis 的ResultSetHandler拿到数据库结果后会为每一列查找对应 Java 属性的 typeHandler然后调用它的getNullableResult完成映射。写入时PreparedStatementHandler同样依赖 typeHandler 调用ps.setString()之类的预处理方法。TypeHandler 是连接 JDBC 类型与 Java 类型之间的一座桥。MyBatis-Plus 的注解TableField(typeHandler JacksonTypeHandler.class)会把 typeHandler 注册到实体类的TableInfo里在生成 ResultMap 时以 typeHandler 的实例挂到对应的ResultMapping上。所以你的实体类上如果有多个 JSON 字段且类型各不相同只要设置了autoResultMap true最终生成的 ResultMap 就会为每个字段配置正确的 ResultMapping互不干扰。4.2 JacksonTypeHandler 如何识别目标类型内置的JacksonTypeHandler继承自AbstractJsonTypeHandlerObject它的关键解析方法大致是Override protected Object parse(String json) { Type type getFieldType(); return JacksonUtils.toObject(json, type); }getFieldType()来自实体类字段的信息主要分三条路径字段声明为ProductExtInfo目标类型就是ProductExtInfo.class字段声明为ListSkuItemfield.getGenericType()可以拿到带泛型参数的ParameterizedTypeJackson 能还原出ListSkuItem字段声明为Object那只能反序列化成LinkedHashMap所以我说 JSON 字段对应的 Java 属性类型宁可写具体一点也不要图省事写成 Object。泛型信息一旦丢失自动转实体类就失去了最核心的价值。4.3 autoResultMap 属性到底影响着什么TableName(autoResultMap true)不是随便加的它只影响 MyBatis-Plus 的 BaseMapper 内置方法如selectById、selectList生成的 ResultMap。如果你在 XML 里自定义了 select 语句那就要在 XML 的 resultMap 里手动配置 typeHandler这个注解对 XML 场景无效。它和TableField(typeHandler ...)两个条件同时满足时TableInfoHelper才会把 typeHandler 写进 ResultMap 的字段映射。一个高频踩坑组合是只加了TableField(typeHandler ...)没加autoResultMap true查出来的 JSON 字段全是 null。原因就是 ResultMap 里压根没注册这个处理器的信息MyBatis 只能按默认 String 处理。5. 实战问答常见问题与排查实录5.1 JSON 字段查询出来是 null这是我被问得最多的问题十次里面八次是同一个原因实体类上少了autoResultMap true。排查步骤我基本固定下来看实体类有没有TableName(autoResultMap true)看字段注解的 typeHandler 类名是否完整类路径有没有写错打开 SQL 日志看生成的 ResultMap 里对应字段有没有 typeHandler确认数据库列名和 Java 属性名能映射上尤其是开启驼峰转换后ext_info和extInfo是否对应一个常见的连带问题如果同一个 JSON 字段出现在多张表里实体类复制粘贴后忘了改TableName也会出现看似映射失效的情况实际上 handler 被旧实体类的 TableInfo 覆盖了。5.2 反序列化失败类型不匹配Jackson 在反序列化时遇到未知字段默认直接抛UnrecognizedPropertyException。我之前在 extInfo 里加了一个新的子属性数据库里还躺着老版本结构的数据查询接口就炸了。解决方式有两种在全局ObjectMapper上配置FAIL_ON_UNKNOWN_PROPERTIESfalse在具体实体类上标注JsonIgnoreProperties(ignoreUnknown true)另一个坑是日期类型。Jackson 默认把LocalDateTime序列化成yyyy-MM-ddTHH:mm:ss.SSS读回来后格式不对。我建议统一配置 ObjectMapperObjectMapper objectMapper new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.setDateFormat(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);注意日期格式要和你业务里其他接口的序列化风格保持一致否则会出现“接口返回一个格式数据库读出来另一个格式”的割裂感。5.3 与分页插件一起使用时 JSON 字段处理网上经常说的“mybatisplus 分页失效”有一部分其实发生在 JSON 字段参与复杂查询时。分页插件PaginationInnerInterceptor会改写 SQL 生成 COUNT 语句此时如果 SELECT 列表里有 JSON 函数表达式或者 JSON 字段在 WHERE 条件里用了JSON_CONTAINS改写时可能会出现列名解析错位导致 COUNT 结果不对甚至直接报 SQL 异常。我的实践建议是SELECT 列表不要对 JSON 字段做不兼容的表达式计算比如json_extract(ext_info, $.tag)直接映射到普通字段如果需要对 JSON 内部字段做筛选比如JSON_CONTAINS(ext_info, 热卖, $.tags)单独写 XML 或用Select注解不要依赖 BaseMapper 的 QueryWrapper 自动拼接分页插件的optimizeCountSql如果开启遇到复杂 JSON 查询可能把 COUNT SQL 改错必要时手动关闭核心原则是JSON 字段正常映射和 JSON 字段作为查询条件是两条技术路线。前者靠 typeHandler后者靠数据库函数。两条路线可以并存但别混在一条 SQL 里写。5.4 数据量大时的写入性能和字段冗余优化JSON 字段最大的隐形杀手是它在数据库里是不可拆分的一旦业务要按 JSON 内部字段排序、分组、过滤性能就崩了。我的建议高频查询字段从 JSON 里抽出来做成普通列JSON 只保留低频读取的整块内容不要频繁 update 整个 JSON 字段只改其中一个 key 时用JSON_SET做增量更新再配合 typeHandler 做查询映射单条记录超过几十 KB 的超大 JSON建议拆分存储甚至放到对象存储数据库里只留引用这些是我做过一次深刻复盘的经验。当时 ext_info 里存了几 MB 的扩展数据导出报表时直接把堆撑爆了后来把高频字段拆出去JSON 只留快照数据性能才真正稳下来。6. 扩展玩法实体类与 JSON 字段的更多操作6.1 根据 Java 实体类生成建表 SQL既然实体类已经把字段类型、JSON 映射、甚至注释都定义清楚了完全可以根据实体类自动生成建表 SQL。社区里有成熟的mybatis-plus-generator也可以自己写一个反射工具核心就是遍历字段读取TableName、TableField注解拼出 CREATE TABLE 语句。我写过一个简化版本核心逻辑类似StringBuilder sql new StringBuilder(); sql.append(CREATE TABLE ).append(tableName).append( (\n); for (Field field : fields) { TableField tableField field.getAnnotation(TableField.class); // 跳过 TableId 字段拼主键定义 // 根据字段类型映射到数据库类型 }这对初始化新模块、快速搭建测试环境很有用。要注意生成的 SQL 需要人工 review 一遍尤其是字段精度、索引、默认值这些细节不能全靠自动生成。6.2 JSON 字段的索引与条件查询MySQL 5.7 可以用生成列对 JSON 提取的内容建索引比如ALTER TABLE product ADD COLUMN ext_tag VARCHAR(32) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(ext_info, $.tag))) STORED;这样查询时直接走 ext_tag 索引不需要全表扫描 JSON。在 ORM 层面这个生成列对应的 Java 字段可以直接映射为普通字段不需要 typeHandler。等于既享受了 JSON 的灵活性又不牺牲查询性能。对高频筛选字段我会优先考虑这种方案而不是在 Service 里做内存过滤。6.3 把 JSON 映射经验复用到接口协议层TypeHandler 这套思路不仅可以用在数据库同样可以迁移到 HTTP 接口的反序列化、消息队列的 payload 解析。理解了“字段类型和存储格式分离”这一点之后你会少写很多无意义的转换代码。比如 Dubbo 接口的 attachment、MQ 消息的扩展字段都可以用类似方式定义强类型 DTO由框架完成序列化和反序列化业务代码永远面对的是类型安全的对象。这套 JSON 自动转实体类的方案在我维护的几个老项目里已经稳定跑了一年多。最大的感受不是少写了几百行工具方法而是实体类变成了整个业务模型的事实标准凡是能定义成强类型的地方一律不在代码里出现裸 JSON String。如果你正在排查一个“JSON 字段映射不出来”的 bug别急着加各种手写 parse先把autoResultMap和typeHandler两个配置对上九成的问题都出在这里。剩下的一成疑难杂症按上面问题排查的一步步走基本都能定位到根因。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。