十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

MyBatis自定义TypeHandler查询JSON字段返回null的排查与解决方案

MyBatis自定义TypeHandler查询JSON字段返回null的排查与解决方案 如果你在 MyBatis 里自定义了一个 TypeHandler 来处理 MySQL 的 JSON 字段结果查询返回 null先别急着怀疑人生。这个问题我在项目里至少见过十几次每次排查到最后原因都很简单但没踩过坑的人就是看不出来。所谓“自定义 TypeHandler 返回 null”最典型的表现就是数据库里 JSON 字段明明有值插入的时候用 TypeHandler 也正常唯独查询的时候映射到 Java 对象里的属性始终是 null。更折磨人的是有时候本地全换一遍代码还是 null同事电脑上却好的。这篇文章把背后的原理、最容易翻车的几个点、以及一份可以直接抄走的完整代码一次性讲清楚。如果你正在排查这个问题建议按顺序看完尤其是 3、4 两节两个都是高频大坑。1. 问题现场一个明明很简单却很灵异的 null1.1 先看你是不是这个姿势假设你有一张用户表里面有个字段extra_info类型是 MySQL 的json存的是一串 JSON 数组比如[admin,vip]。Java 实体里对应的属性是一个ListStringpublic class UserInfo { private Long id; private String name; private ListString extraInfo; // getter / setter }为了能把 JSON 字符串映射成ListString你写了一个自定义 TypeHandler先不管细节大概长这样MappedTypes(List.class) public class JsonListTypeHandler extends BaseTypeHandlerListString { // 具体实现先省略后面会详细说 }然后在 MyBatis 的 XML 或注解里指定了它resultMap iduserInfoMap typecom.example.entity.UserInfo id columnid propertyid/ result columnextra_info propertyextraInfo typeHandlercom.example.handler.JsonListTypeHandler/ /resultMap看起来没什么问题但一执行UserInfo userInfo userInfoMapper.selectById(1L); System.out.println(userInfo.getExtraInfo()); // 输出null不是空数组不是空字符串而是彻头彻尾的 null。如果你已经走到了这一步把代码翻来覆去看了几遍也没发现问题那重点排查两个方向一是查询时有没有真的走这个 resultMap二是 JDBC 拿到的 JSON 值到底是什么类型。1.2 返回 null 和报异常是两码事这里有个关键判断如果你的 TypeHandler 在解析 JSON 时抛了异常MyBatis 会把这个异常包装成PersistenceException抛出来你根本不会看到 null。所以“静默返回 null”和“抛异常”背后的原因是截然不同的。热搜词里有一条failed to deserialize the json body into the target type: input: missing fie这是 Jackson 在反序列化 JSON 时遇到字段缺失或类型不匹配时报的错。如果你在 TypeHandler 里用了 Jackson然后把异常吞掉了并返回 null那才是真正的灾难线上日志看不到任何异常数据却悄悄变成 null。我见过有人这样写try { return objectMapper.readValue(json, List.class); } catch (Exception e) { return null; // 千万别这么干 }这个坑属于“自己给自己埋雷”。一旦 JSON 数据里出现脏数据解析失败后直接返回 null问题会变得极难排查。后面讲具体实现的时候我会强调这一点的正确处理方式。2. 先搞懂 MyBatis TypeHandler 的工作原理2.1 TypeHandler 到底是干嘛的TypeHandler 在 MyBatis 中的作用本质上是解决“JDBC 类型”和“Java 类型”之间的双向转换。写库的时候Java 属性作为PreparedStatement的参数需要转换成 JDBC 能识别的类型查库的时候从ResultSet取出来的数据要变成 Java 对象的属性。如果你不写自定义 TypeHandlerMyBatis 会用内置的一堆默认处理器。比如StringTypeHandler处理字符串LongTypeHandler处理 Long这些基础类型覆盖了绝大多数场景。但 MySQL 的json字段不在 MyBatis 内置支持的范围内因为 MySQL 驱动返回给 JDBC 的值在不同版本、不同连接配置下可能不一样所以你需要自定义一个 TypeHandler 来告诉 MyBatis这个列请你用我的逻辑来转换。2.2 BaseTypeHandler 四个方法分别在什么时候被调用自定义 TypeHandler 最常用的方式是继承BaseTypeHandlerT它本身是一个泛型抽象类强制你实现四个方法方法作用被调用的时机setNonNullParameter给 PreparedStatement 赋值执行 insert / update且参数不为 null 时getNullableResult(ResultSet rs, String columnName)从结果集按列名取值查询返回MyBatis 按列名映射时getNullableResult(ResultSet rs, int columnIndex)从结果集按下标取值查询返回MyBatis 按下标映射时getNullableResult(CallableStatement cs, int columnIndex)从存储过程的出参取值调用存储过程时很多人只实现了前三个方法把CallableStatement那个漏掉了。平时用不到存储过程没事但如果别人在项目里加了存储过程调用这个 TypeHandler 就会在运行时因为缺少实现而直接报错报错场景还比较冷门。规范化实现还是四个都写完比较好。注意一个细节setParameter这个入口在BaseTypeHandler里已经帮你判断了参数是否为 null。参数为 null 时MyBatis 会调用ps.setNull(i, jdbcType.TYPE_CODE)根本不会走进你写的setNonNullParameter。这本身是正常行为但很容易引起误解——比如你 insert 时传了一个 null 的List然后数据库里 JSON 字段存成了 SQL 的 NULL查询出来自然也是 null。这个情况不算 TypeHandler 的 bug但确实是把“返回 null”问题复杂化的一个来源。3. 最常见的原因JSON 列在 JDBC 层根本不是 String3.1 MySQL Connector/J 的 byte[] 陷阱这是整个问题里最容易踩、也最隐蔽的一个坑MySQL 的 JSON 类型字段经过 Connector/J 取出来的时候不一定是你以为的String。MySQL 从 5.7 开始支持 JSON 类型到 8.0 之后InnoDB 存储引擎对 JSON 有自己的二进制存储格式而不是简单存一串文本。JDBC 驱动在读取这种二进制格式时不同版本的mysql-connector-java表现得不一样。实测下来在 8.0.x 系列的大部分版本里ResultSet#getObject返回的是byte[]ResultSet#getString在某些情况下会返回 null。于是你的 TypeHandler 里如果是这样写的Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String json rs.getString(columnName); // 这里可能拿到 null return json null ? null : objectMapper.readValue(json, List.class); }当驱动返回 byte[] 时rs.getString(columnName)的行为就变得不可控了。要么返回 null要么抛异常。我见过最诡异的情况是同一个 SQL在本地跑能查出来在测试环境跑却是 null一查驱动版本两个环境的mysql-connector-java版本不同行为就不一样。3.2 用一个 JDBC 测试快速验证碰到这种问题不要一上来就改 MyBatis 配置先写一个最原始的 JDBC 测试把问题定位在驱动层还是 MyBatis 层。代码很简单Test public void testJdbcJson() throws Exception { Class.forName(com.mysql.cj.jdbc.Driver); String url jdbc:mysql://localhost:3306/test?serverTimezoneAsia/Shanghai; try (Connection conn DriverManager.getConnection(url, root, 123456); PreparedStatement ps conn.prepareStatement(SELECT extra_info FROM user_info WHERE id ?)) { ps.setLong(1, 1L); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { Object obj rs.getObject(extra_info); System.out.println(getObject 类型: (obj null ? null : obj.getClass())); System.out.println(getString 值: rs.getString(extra_info)); System.out.println(getBytes 长度: (rs.getBytes(extra_info) null ? null : rs.getBytes(extra_info).length)); } } } }跑完你就知道答案了。如果getObject的类型是[Bbyte[] 的 class 标示或者getString确实返回 null而getBytes有长度那就实锤是驱动层的问题。此时 TypeHandler 里应该按字节数组来处理而不是字符串。3.3 TypeHandler 里该用 getString 还是 getBytes既然 JSON 列可能返回 byte[]那最稳妥的写法就是用getBytes拿到字节数组后再转成字符串做解析。你可能会担心如果查询 SQL 里直接把 JSON 列 CAST 成了 CHAR那 getBytes 也能正常工作因为 CHAR 类型驱动返回的字节数组就是字符串本身的编码。这相当于两种场景都能兼容。所以TypeHandler 的读取方法推荐这样写private ListString parse(byte[] bytes) throws SQLException { if (bytes null || bytes.length 0) { return null; } try { String json new String(bytes, StandardCharsets.UTF_8); return objectMapper.readValue(json, new TypeReferenceListString() {}); } catch (IOException e) { throw new SQLException(解析 JSON 失败, e); } }同时为了双保险查询 SQL 里也可以把 JSON 列显示地转成字符串SELECT id, name, CAST(extra_info AS CHAR) AS extra_info FROM user_info WHERE id #{id}CAST(extra_info AS CHAR)让 MySQL 在服务端就把 JSON 转成文本这样驱动返回的就是字符串而不是二进制。这条 SQL 再加上基于getBytes的 TypeHandler基本可以覆盖所有驱动版本。有人会担心 CAST 影响性能实际上对于绝大多数小表查询这个开销可以忽略。如果 JSON 字段很大、查询量又高那就更应该考虑把高频使用的字段拆出来做冗余列。4. resultMap、resultType 与 TypeHandler 的匹配机制4.1 resultType 自动映射为什么赋不上另外一个高频坑是你明明注册了 TypeHandler全局也扫描了但查询方法用的是resultType而不是resultMap。用resultTypecom.example.entity.UserInfo时MyBatis 会根据结果集的元数据和实体的属性做“自动映射”。自动映射时的 TypeHandler 查找逻辑跟 resultMap 显式指定完全不一样。它会根据数据库列的 JDBC 类型去找对应 handler再拿实体的 setter 类型去匹配。MySQL 的 JSON 列在 JDBC 层往往没有一个标准的JdbcType枚举值MyBatis 内置枚举里根本没有 JSON所以自动映射经常匹配不到你注册的那个处理器。结果就是MyBatis 可能用默认的ObjectTypeHandler处理这个列ObjectTypeHandler直接rs.getObject(col)拿到一个byte[]或者别的东西然后尝试 set 进实体的ListString属性。类型不匹配时直接赋 null也不报错这就是你看到“查询返回 null”的原因之一。所以重要原则是自定义 TypeHandler 想要 100% 生效查询必须走 resultMap并在 result 节点里显式指定 typeHandler。别指望全局注册了 typeHandler 之后写个resultType它就能完美自动映射。自动映射只适合 Java 属性和数据库列类型一一对应的情况自定义对象、复杂泛型这种场景老老实实写 resultMap。4.2 resultMap 显式指定 typeHandler 的正确姿势resultMap 里的写法有讲究很多人写错地方。正确的完整配置是这样的resultMap iduserInfoMap typecom.example.entity.UserInfo id columnid propertyid/ result columnextra_info propertyextraInfo javaTypejava.util.List jdbcTypeVARCHAR typeHandlercom.example.handler.JsonListTypeHandler/ /resultMap select idselectById resultMapuserInfoMap SELECT id, name, CAST(extra_info AS CHAR) AS extra_info FROM user_info WHERE id #{id} /select注意几个点column是数据库列名property是 Java 属性名别写反。typeHandler这里要写全限定类名。你也可以省略javaType和jdbcType只写typeHandlerMyBatis 会从 TypeHandler 类的泛型信息里推断 Java 类型。如果这个字段在你的实体里是ListString但 XML 里 typeHandler 的泛型是ListString二者能匹配上才会正确生效。还有一个容易被忽略的细节如果你的实体是UserInfo但 SQL 查询结果是u.id, u.name, u.extra_info这里的列名extra_info和 resultMap 里的columnextra_info必须一致。很多人用了表别名但 resultMap 里没改成别名映射半天映射不上也是一长串排查时间。4.3 注解方式和 MyBatis-Plus 的坑现在很多项目用注解代替 XML比如Select(SELECT id, name, CAST(extra_info AS CHAR) AS extra_info FROM user_info WHERE id #{id}) Results(id userInfoMap, value { Result(column id, property id), Result(column extra_info, property extraInfo, typeHandler JsonListTypeHandler.class) }) UserInfo selectById(Long id);同一套 SQL 如果多个方法复用Results里的id属性要配上其他地方用ResultMap(userInfoMap)引用避免每个方法都贴一大段注解。如果你用的是 MyBatis-Plus还有一个隐藏要求在实体类的字段上加了TableField(typeHandler JsonListTypeHandler.class)之后如果想让它对内置方法如selectById、selectList也生效光加注解不够还要在TableName上配置autoResultMap trueData TableName(value user_info, autoResultMap true) public class UserInfo { private Long id; private String name; TableField(typeHandler JsonListTypeHandler.class) private ListString extraInfo; }MyBatis-Plus 4.x 之后的版本对这个行为做了一些调整但依然建议显式声明。项目里如果同时有自定义 XML 和 MyBatis-Plus 的内置方法最好统一用Select或 XML 指定 resultMap不要过度依赖实体注解到内置 SQL 上的自动映射。5. 一个能直接抄的完整 TypeHandler 实现5.1 基于 Jackson 的泛型基类彻底解决“类型擦除”前面讲了很多“为什么”现在给一个能直接拿去用的方案。针对不同泛型不要每个都全部重写一套逻辑我习惯写一个泛型基类然后在子类里指定TypeReference。这样扩展性最好代码也干净。先写基类package com.example.handler; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public abstract class JsonTypeHandlerT extends BaseTypeHandlerT { private static final ObjectMapper MAPPER new ObjectMapper(); private final TypeReferenceT typeReference; protected JsonTypeHandler(TypeReferenceT typeReference) { this.typeReference typeReference; } Override public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (IOException e) { throw new SQLException(对象转 JSON 字符串失败, e); } } Override public T getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getBytes(columnName)); } Override public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getBytes(columnIndex)); } Override public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getBytes(columnIndex)); } private T parse(byte[] bytes) throws SQLException { if (bytes null || bytes.length 0) { return null; } try { String json new String(bytes, StandardCharsets.UTF_8); return MAPPER.readValue(json, typeReference); } catch (IOException e) { throw new SQLException(JSON 字符串解析失败: e.getMessage(), e); } } }然后需要什么类型写一个子类就行。比如处理ListStringpackage com.example.handler; import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; public class JsonStringListHandler extends JsonTypeHandlerListString { public JsonStringListHandler() { super(new TypeReferenceListString() {}); } }处理自定义对象ListUserAddress也一样public class JsonAddressListHandler extends JsonTypeHandlerListUserAddress { public JsonAddressListHandler() { super(new TypeReferenceListUserAddress() {}); } }处理MapString, Object的话public class JsonMapHandler extends JsonTypeHandlerMapString, Object { public JsonMapHandler() { super(new TypeReferenceMapString, Object() {}); } }这样做的核心逻辑是MyBatis 的 TypeHandler 最终是通过无参构造器创建实例的所以子类里一定要写一个无参构造器并在里面把TypeReference的具体泛型传进去。如果直接在基类上写死ListString那就没法复用了。泛型擦除的问题用这种方式绕过去。5.2 注册 TypeHandler 的四种方式搞定了实现类接下来是注册。有几种常见方式方式一XML 全局配置。在mybatis-config.xml里显式注册configuration typeHandlers typeHandler handlercom.example.handler.JsonStringListHandler javaTypejava.util.List jdbcTypeVARCHAR/ /typeHandlers /configuration方式二Spring Boot 的 application.yml 配置扫描包mybatis: type-handlers-package: com.example.handler这种方式的生效条件是TypeHandler 类上用MappedTypes和MappedJdbcTypes标好了对应的 Java 类型和 JDBC 类型。扫描包方式比较省事但注意并非所有场景都能按预期自动匹配到所以我依然建议在 resultMap 里显式指定。方式三resultMap 里显式指定这个前面已经写过result columnextra_info propertyextraInfo typeHandlercom.example.handler.JsonStringListHandler/方式四注解方式Result(column extra_info, property extraInfo, typeHandler JsonStringListHandler.class)个人建议全局注册可选但每个用到的地方尽量显式在 resultMap / Result 里指定 typeHandler。显式指定的优先级最高排错也最直观。5.3 写入时的注意事项别把 null 写进业务字段写入方向同样有坑。前面提过参数为 null 时 MyBatis 不会走setNonNullParameter而会直接 setNull。如果你的 JSON 列在数据库里没有默认值并且业务上必须存储[]或者{}那调用代码里要保证不会传 null 的 List 进去。比较保险的做法是在 service 层做兜底if (userInfo.getExtraInfo() null) { userInfo.setExtraInfo(new ArrayList()); }或者在 TypeHandler 的setNonNullParameter里把空集合统一序列化成[]避免数据库里出现 SQL NULL 和空 JSON 混用的情况。数据库里同一个字段NULL、空字符串、[]、{}都代表“空”但映射到 Java 侧的行为完全不同这个需要团队约定清楚。5.4 解析失败时千万不要吞异常我的建议是解析失败时一定要抛出 SQLException让错误暴露在日志里。虽然抛出异常会导致查询失败但这总比线上数据悄悄变成 null 好一百倍。你可以再加一个兜底策略对个别脏数据允许解析失败时返回 null但必须通过日志级别监控到然后再决定要不要清洗数据。这里正好对应前面那条热搜词里的failed to deserialize the json body into the target type。它虽然说的是 Spring MVC 接收请求体时的报错但 Jackson 解析 JSON 的报错逻辑是一模一样的。你在 TypeHandler 里用readValue如果 JSON 字段缺失、类型对不上就会抛出类似异常。正确处理是往上抛 SQLException而不是 catch 后 return null 装没看见。6. 排查路线图与避坑清单6.1 从日志到源码的完整排查步骤如果你正被这个问题折磨别慌按顺序走一遍第一步确认数据本身。先用 Navicat 或命令行查一下SELECT id, CAST(extra_info AS CHAR) FROM user_info WHERE id 1;确认数据库里确实有内容不是 SQL NULL。第二步开启 MyBatis SQL 日志。在 Spring Boot 的配置文件里加上mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者用logging.level.com.example.mapperdebug。重点看 PreparedStatement 的查询参数和 ResultSet 的返回值数量。如果日志显示 ResultSet 有返回但实体属性是 null那就是映射层问题。第三步写一个前面提到的 JDBC 最小测试确认 JSON 列在驱动层返回的是 String 还是 byte[]。第四步检查你的查询是否真的走了 resultMap。如果你用的是 XML确认select标签里写了resultMapuserInfoMap而不是resultType。如果你用的是注解确认Results确实生效并且这个查询方法没有被 MyBatis-Plus 的BaseMapper.selectById这种内置方法替代。第五步检查 resultMap 里的column属性是否和 SQL 输出的列名一致。用了别名就对别名没加前缀就对原列名。第六步检查 TypeHandler 类的泛型和方法实现。重点看getNullableResult三个重载是否都实现了内部解析有没有吞异常。6.2 高频坑位速查表现象可能原因解决方向查询返回 nullinsert 正常查询没走 resultMap / Results在 select 上显式指定 resultMapresultMap 指定了 handler 还是 nullJDBC 层 JSON 值是 byte[]getString 拿不到TypeHandler 改用 getBytesSQL 加 CAST全项目好几处用只有某些方法 null有的方法用了 resultType 自动映射统一改成 resultMap 或 Results同一个 SQL本地正常测试环境 nullmysql 驱动版本不一致统一驱动版本TypeHandler 兼容 byte[] 和 StringMyBatis-Plus 内置方法查出来 null实体上 autoResultMap 未开启TableName 加 autoResultMap true查询不报错但字段一直是 null列名或别名和 resultMap column 不匹配核对 SQL 输出列与 resultMap column缓存复用导致看起来没改生效一级/二级缓存存了旧结果排查时先清缓存或重启确认配置生效6.3 我最后想说的几个习惯这个其实算不上什么高深技术但真的把人折磨得够呛。我个人调试这类问题时有三个固定习惯分享出来供参考。第一凡是实体里出现自定义对象、泛型集合、Map 这类属性查询一律用 resultMap 显式映射不依赖自动映射。省那几个字符的代价可能是几小时的排查时间不划算。第二JSON 字段在实体里尽量用具体的业务对象而不是String加手动转换。与其在 Service 层反复写JSON.parseObject不如一开始就在 TypeHandler 里把转换做掉调用方拿到的就是标准对象。第三TypeHandler 里所有解析操作都走getBytes不要用getString。这个习惯在 MySQL 8.0 之后尤其重要因为 JSON 字段的返回类型在不同版本驱动下表现不一致。虽然现在不少新版驱动会把 JSON 直接当成 String 返回但保不齐哪天升级驱动就踩一脚。用getBytes不需要改变逻辑能兼容两种行为算是花最小的成本买了一份保险。如果你日常会大量用 MySQL JSON 字段建议把泛型基类沉淀成一个公共模块团队统一使用。另外如果你碰到过“第一次查询返回 null第二次查询又正常”的反向灵异现象那多半和 MyBatis 一级缓存有关系而不是 TypeHandler 的问题可以去检查一下 SqlSession 的生命周期。这个属于另一条排查路线了有机会再单独写一篇。
返回列表