ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatis Plus JSON字段映射实体类:TypeHandler配置与避坑

MyBatis Plus JSON字段映射实体类:TypeHandler配置与避坑 最近不止一个做后端的朋友来问我同一个问题数据库里某列存的是 JSON 字符串比如extra_info里存了{tags:[vip],score:88}实体类里想直接定义一个对象来接收像private ExtraInfo extraInfo;这样。结果插入的时候没什么问题一查出来要么是 null要么直接抛类型转换异常折腾半天不知道怎么处理。这篇文章就把 mybatis plus JSON 自动转实体类的原理、标准配置、常见坑和进阶玩法一次讲透。适合正在用 MyBatis Plus 做业务开发、需要在数据库字段和 Java 对象之间塞 JSON 数据的同学也特别适合那些遇到“查出来的 List 变成了 LinkedHashMap”之后一头雾水的人。我会从底层机制讲起再把代码直接给到能抄走最后补上几个只有踩过坑才会知道的细节。1. 先别急着加注解搞懂 TypeHandler 和 autoResultMap这坑一半出在这里1.1 数据库读写的“搬运工”机制TypeHandler 到底干了什么MyBatis 读写字段靠的是一套叫做 TypeHandler 的机制。简单说每次往数据库写参数MyBatis 会找一个 TypeHandler 来执行setNonNullParameter把 Java 对象变成 JDBC 能接受的类型每次从结果集读数据又会用同一个 TypeHandler 的getNullableResult把 JDBC 列值变回 Java 对象。这就像一个仓库搬运工入库时把“对象”装箱成“字符串”出库时把“字符串”拆包回“对象”。问题在于系统默认只给 String、Integer、Long 这些常见类型配好了搬运工。如果你的实体字段声明成了ExtraInfo这种自定义对象却没有告诉 MyBatis 用哪个 TypeHandler系统就只能挑一个兜底的来干活。兜底的情况一般有两种表现插入时走了 ObjectTypeHandler调用了对象的toString()最终落库的是一串ExtraInfo1a2b3c这种对象地址根本不是合法的 JSON。查询时拿到的是普通字符串MyBatis 自动映射又试图把 String 塞进ExtraInfo字段这下就出现ClassCastException或者在部分严格模式下直接给这个字段置 null。所以你遇到的“JSON 不能自动转实体类”本质上不是 MyBatis Plus 的 bug而是你还没告诉 MyBatis这个字段的搬运工是 Jackson而不是默认的字符串处理。1.2 插入和查询是两套链路为什么加了 typeHandler 还不够按网上的教程你大概会先加上这样的注解TableField(typeHandler JacksonTypeHandler.class) private ExtraInfo extraInfo;加上之后你会发现插入确实正常了但查询还是不出结果。原因在于 MyBatis Plus 生成 SQL 的方式不同插入、更新实体时SQL 是 MyBatis Plus 根据实体类生成的它能识别到TableField里的 typeHandler所以写操作会正确调用这个处理器把对象序列化成 JSON 字符串。查询默认走的是 MyBatis 自身的自动映射自动映射只关心“列名”对应“属性名”根本不看字段注解里的 typeHandler。也就是说读数据这条链路根本不知道要对这一列调用 Jackson 去解析。要让查询也知道这一列该用 JacksonTypeHandler就必须让 MyBatis 在执行时拿到一个包含 typeHandler 配置的 resultMap。于是就有了TableName(autoResultMap true)TableName(value user_extra, autoResultMap true) public class UserExtra { // ... }这个开关的作用是让 MyBatis Plus 在应用启动时扫描实体类字段上的注解自动生成一个完整的 resultMap把typeHandler信息也带进去。加了它查询链路才能和插入链路一样正确调用 Jackson 把 JSON 字符串反序列化回ExtraInfo对象。注意这个开关只对 MyBatis Plus 自动生成的 resultMap 有效。如果你用的是 XML 里的自定义 resultMap那就得自己在result标签上手动写typeHandler属性。2. 标准配置长这样两行注解搞定 90% 的场景2.1 实体类的完整写法含建表语句先看表结构我用的列类型是 VARCHAR(2000)这个选择背后的原因后面会专门讲CREATE TABLE user_extra ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, extra_info VARCHAR(2000) NULL COMMENT 扩展信息JSON格式 );实体类Data TableName(value user_extra, autoResultMap true) public class UserExtra { TableId(type IdType.AUTO) private Long id; private Long userId; TableField(value extra_info, typeHandler JacksonTypeHandler.class) private ExtraInfo extraInfo; }对应的ExtraInfo就是一个普通 POJOData public class ExtraInfo { private ListString tags; private Integer score; }这里有两个容易忽略的点第一TableField里的value一定要写对列名。如果实体属性名和列名能通过驼峰转下划线自动对应上可以省略但我建议显式写出来尤其是在不同模块有不同命名规范的项目里。第二确保项目里有 Jackson 依赖。如果你用的是 Spring Boot 的spring-boot-starter-web里面已经带了jackson-databind可以直接用com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler。如果是非 Spring 项目记得手动加依赖。2.2 用一套自测用例验证四条链路配置完之后别急着上线先用一组操作把链路全部验证一遍// 1. 插入 UserExtra record new UserExtra(); record.setUserId(1001L); ExtraInfo info new ExtraInfo(); info.setTags(List.of(vip, new_user)); info.setScore(88); record.setExtraInfo(info); userExtraMapper.insert(record); // 2. 按 ID 查询 UserExtra dbRecord userExtraMapper.selectById(record.getId()); System.out.println(dbRecord.getExtraInfo().getScore()); // 期望输出 88 // 3. 列表查询 ListUserExtra list userExtraMapper.selectList( Wrappers.UserExtralambdaQuery().eq(UserExtra::getUserId, 1001L)); System.out.println(list.get(0).getExtraInfo().getTags()); // 期望输出 [vip, new_user] // 4. 更新重点 UserExtra update new UserExtra(); update.setId(record.getId()); ExtraInfo newInfo new ExtraInfo(); newInfo.setTags(List.of(vip)); newInfo.setScore(99); update.setExtraInfo(newInfo); userExtraMapper.updateById(update);updateById这里有个很实用的默认行为MyBatis Plus 默认的字段更新策略是 NOT_NULL也就是只有非 null 字段才会拼接进 UPDATE 语句。所以上面这个update对象只设置 id 和 extraInfo就不会把 userId 之类的字段误更新掉正好适合单独更新 JSON 列。我建议把这四条链路都跑一遍再继续因为有不少人配置完插入和查询都正常一用updateById就发现 JSON 列没更新或者是更新成了 null。如果出现后者先检查字段是否被额外加上了updateStrategy FieldStrategy.IGNORED再检查实体对象里该字段是否为 null。2.3 为什么这里优先推荐 JacksonTypeHandler内置的 JSON 处理器其实有好几个JacksonTypeHandler、FastjsonTypeHandler、GsonTypeHandler。我在新项目里统一用 Jackson原因很简单Spring Boot 生态默认就是 Jackson零额外依赖。Jackson 的ObjectMapper配置统一实体类里已有的JsonFormat、JsonIgnoreProperties这些注解都能直接生效。Fastjson 虽然 API 简单但历史上出过好几次反序列化相关的安全问题公司安全团队大概率也会限制使用。如果项目里已经深度依赖了其他 JSON 库用对应的内置 handler 也不是不行只是你需要额外注意它不会读取 Jackson 的注解配置两套序列化结果可能不一致。3. 高频翻车点查出来的 List 变成了 LinkedHashMap3.1 完整复现与排查链路标准配置跑通之后很多人会在“JSON 列里是一个对象数组”的场景翻车。比如实体字段这样写Data TableName(value product, autoResultMap true) public class Product { TableId(type IdType.AUTO) private Long id; TableField(typeHandler JacksonTypeHandler.class) private ListExtAttr extAttrs; }ExtAttr长这样Data public class ExtAttr { private String attrName; private String attrValue; }插入没问题但查询出来之后代码一执行到这里就炸Product product productMapper.selectById(1L); String name product.getExtAttrs().get(0).getAttrName(); // 这里异常异常信息通常是java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.example.ExtAttr排错的时候先别怀疑配置用一行代码确认反序列化出来的真实类型System.out.println(product.getExtAttrs().get(0).getClass()); // 输出class java.util.LinkedHashMap看到LinkedHashMap基本就能断定是 Jackson 在反序列化时没有拿到元素的真实类型只能退化成通用的 Map 结构。3.2 根因泛型擦除 Jackson 需要具体类型Jackson 在做readValue的时候必须知道目标类型才能正确构造对象。对于ListExtAttr这种声明如果它拿到的只是一个“原始 Clist”没有ExtAttr这个泛型参数Jackson 就会按照默认规则把 JSON 对象反序列化成LinkedHashMap。MyBatis Plus 内置的AbstractJsonTypeHandler做了不少兼容处理。在较新的版本里它会尝试读取实体字段的Field元信息拿到getGenericType()从而保留ListExtAttr的泛型类型这时是能正常工作的。真正容易翻车的场景集中在下面几类实体字段声明在泛型父类里子类继承时泛型参数没有固化Field里拿到的是TypeVariable而不是具体Class。字段类型写成了原始的List没有任何泛型参数。项目使用的 MyBatis Plus 版本偏老Field信息没有正确传给 handler。某些场景下字段类型写成了Object或MapJackson 只会产出LinkedHashMap。这解释了为什么同样的配置有的人跑得好好的有的人一查就报错。不是配置方法不同而是字段类型和版本环境不同。3.3 两个解决方案包装类 or 自定义 TypeHandler遇到LinkedHashMap我一般按优先级试两个方案。第一个方案是改字段类型用“包装类”代替裸的 List。比如Data public class ExtAttrListWrapper { private ListExtAttr items; }实体里改成TableField(typeHandler JacksonTypeHandler.class) private ExtAttrListWrapper extAttrs;这样 Jackson 拿到的就是一个具体的ExtAttrListWrapper类型内部items的泛型也能保留反序列化会非常稳定。缺点是业务代码里要写product.getExtAttrs().getItems()多了一层。第二个方案是写一个自定义 TypeHandler用TypeReference精确指定类型一劳永逸public class ExtAttrListTypeHandler extends BaseTypeHandlerListExtAttr { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ListExtAttr parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new SQLException(ExtAttr 列表序列化失败, e); } } Override public ListExtAttr getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } Override public ListExtAttr getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } Override public ListExtAttr getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private ListExtAttr parse(String json) throws SQLException { if (json null || json.isEmpty()) { return Collections.emptyList(); } try { return MAPPER.readValue(json, new TypeReferenceListExtAttr() {}); } catch (IOException e) { throw new SQLException(ExtAttr 列表反序列化失败, e); } } }实体里用它替换内置 handlerTableField(typeHandler ExtAttrListTypeHandler.class) private ListExtAttr extAttrs;TypeReference这个写法看起来有点魔法其实就是 Jackson 提供的“绕过泛型擦除”的标准工具。new TypeReferenceListExtAttr() {}在构造时会把泛型信息以匿名内部类的形式固化下来Jackson 读取时就能拿到ListExtAttr而不是裸的List。我个人的习惯是实体里只有一个 JSON 字段时用内置 handler如果项目里 JSON 字段很多、泛型结构又复杂直接抽一个通用的自定义 base handler 包一下后面所有人都不用来回踩坑。4. 进阶实操Wrapper 更新、列类型选择、JSON 内字段查询4.1 UpdateWrapper 更新 JSON 字段会静默埋雷很多人配置完实体后会顺手用 LambdaUpdateWrapper 去更新 JSON 字段LambdaUpdateWrapperUserExtra wrapper Wrappers.lambdaUpdate(); wrapper.eq(UserExtra::getId, 1L) .set(UserExtra::getExtraInfo, newInfo); // 注意这里直接传了对象 userExtraMapper.update(null, wrapper);这个写法是最容易“静默出错”的。set方法接收的是ObjectMyBatis 在执行时按照参数的实际类型去找 TypeHandler。newInfo是一个ExtraInfo对象没有对应的 handler最终会走 ObjectTypeHandler 的toString()你回头看数据库会发现里面躺着一串ExtraInfo5a2f3b而且全程没有异常。出现这个问题时最省心的做法是改用updateById它会走实体字段的 typeHandler前面已经验证过。如果你确实需要带条件更新就先手动序列化ObjectMapper mapper new ObjectMapper(); LambdaUpdateWrapperUserExtra wrapper Wrappers.lambdaUpdate(); wrapper.eq(UserExtra::getId, 1L) .set(UserExtra::getExtraInfo, mapper.writeValueAsString(newInfo)); userExtraMapper.update(null, wrapper);后来版本的 MyBatis Plus 在set方法上增加了带 TypeHandler 参数的重载但我还是建议手动序列化更直白不依赖具体版本。4.2 数据库字段类型VARCHAR、TEXT 还是 JSON很多人在列类型上纠结我直接给一个对比列类型优点缺点典型场景VARCHAR(2000)兼容所有数据库、可设默认值、可建普通索引长度受限、无格式校验大多数业务扩展字段TEXT容量大能存很长的 JSON无格式校验、排序/分组受限大段配置、长文本 JSONJSONMySQL 5.7有格式校验、支持 JSON 函数旧版本不能设默认值、替换成本高确定用 MySQL、需要校验格式jsonbPostgreSQL二进制存储、查询性能好、支持 GIN 索引键顺序会变、依赖 PGPostgreSQL 专用如果你只是想在实体里少写一个 String 字段、图个方便那就用 VARCHAR(2000)。多数业务 JSON 字段不会超过这个长度而且以后如果要给这个字段建索引或者迁移数据库VARCHAR 都更省事。如果你明确有“格式必须合法”的诉求比如对接外部系统存储回调数据那就用 MySQL 的 JSON 类型它会在写入时校验 JSON 合法性。4.3 按 JSON 里的某个字段做查询条件实体字段搞定了又会出现新需求想直接根据 JSON 内部的某个值来过滤比如“查出所有 score 等于 88 的记录”。MyBatis Plus 的 QueryWrapper 原生语法不支持 JSON 取值但你可以在apply里写原生 SQLQueryWrapperUserExtra wrapper new QueryWrapper(); wrapper.apply(json_extract(extra_info, $.score) {0}, 88); ListUserExtra list userExtraMapper.selectList(wrapper);这段 SQL 依赖 MySQL 的json_extract函数。PostgreSQL 得用extra_info - scoreSQLite 又是另一套语法。也就是说一旦在查询条件里用了 JSON 内部字段你就和具体数据库绑定了跨库会变得很麻烦。更关键的是性能直接在 JSON 列上做函数运算普通索引是不起作用的。如果这个查询是大数据量高频路径最佳实践是在 MySQL 里加生成列把extra_info-$.score提炼成独立列并建索引然后用普通条件去查而不是每次都走apply。4.4 别忽略的两个安全细节第一不要用Object或MapString, Object去承接 JSON 字段。虽然能通但反序列化出来的结构不可控代码里到处是类型判断和强转稍微一变结构就崩。第二小心“多态反序列化”风险。如果 JSON 里包含class之类的类型标识字段某些 JSON 库在自动绑定类型时可能被攻击者利用构造恶意数据。固定结构的 JSON 就用固定 DTO 接收绝不要把不可信的外部 JSON 直接喂给宽松类型配置的序列化器。5. 四个方案的横向对比与我的最终建议5.1 方案对比表聊了这么多你会发现解决方案其实有四条路方案配置成本可控性额外依赖适合场景内置 JacksonTypeHandler低两行注解中Jackson简单 JSON 对象、包装类自定义 TypeHandler中要写一个类高无泛型集合、加密脱敏、特殊字段Service 层手动转换低低无只有一两个字段、不想动实体XML resultMap中高无项目本来就在写 XML SQLService 层手动转换听起来土但在某些场景下反而是最优解比如某个 JSON 字段只是偶尔用一下业务代码里根本没有连续访问它的地方实体里保留 StringService 里调一次工具类转对象逻辑非常清晰。我见过不少项目为了“优雅”把几十个字段全配上 JSON 自动转换结果一半的字段一年都没人读。5.2 我现在的固定套路踩过几轮坑之后我在自己项目里的用法已经稳定了字段是单个对象结构固定直接内置 JacksonTypeHandler配合TableName(autoResultMap true)。字段是泛型集合或者出现过 LinkedHashMap优先用包装类不行就写自定义 TypeHandler。字段会被前端任意传实体只留 StringService 里做校验和转换避免脏数据直接进库。涉及 wrapper 更新那一类场景一律updateById传实体对象绝不再把对象直接塞进set。最后说一个排查利器在本地把 SQL 日志打开查询出问题的时候直接拿 SQL 去数据库客户端执行再把返回的列值原样复制出来调 Jackson 的readValue。很多时候你会惊讶地发现数据库里存的 JSON 本身就不是合法 JSON——可能是报错前的半截数据、可能是对象地址、也可能是 XML。JSON 自动转实体类只是最后一公里数据源头干不干净往往才是真正要花时间解决的问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进