做Java后端的兄弟,十有八九都遇到过这种需求:MyBatis查出来的某个列是JSON字符串,实体类里对应的却是List<XxxDTO>或者Map<String, Object>。第一次遇到时,我都是查出来一个String,再在业务层手动调用ObjectMapper去读,写进去再序列化一次。一个实体里有两个这种字段就开始复制粘贴,三个字段就想骂人了。后来我认真研究了一下MyBatis的TypeHandler机制,发现要做到"自动映射对象为JSON"其实有一整套非常顺手的玩法,但网上很多文章把最关键的类型信息、注册方式、边界条件都讲漏了。这篇就用我自己在项目里踩过坑之后沉淀下来的写法,把整套方案拆开讲清楚。适合正在被JSON字段折磨、以及想彻底搞懂TypeHandler怎么用的读者。
1. 为什么越来越多的表开始直接存JSON字段
1.1 从一次"拆表还是留JSON"的争论说起
有一年我做一个偏电商的项目,订单表里需要保存渠道方返回的原始报文,里面二十多个字段,而且渠道方每隔半年就要加几个新字段。如果把所有字段都拆成数据库列,等于每半年要改一次表结构、改一次Mapper、改一次DTO,光是联调成本就够喝一壶的。当时团队里有人提议用JSON列来存这些"随时可能变化"的信息,我第一反应是抵触:关系型数据库存JSON,听起来就像用错误的工具干正确的事。后来MySQL从5.7开始原生支持JSON类型,加上业务方强烈要求"新增字段不能发版",我们最终把这类"低频查询、高频存取、结构不稳定"的数据放进了JSON列。
实践下来,JSON列最适合的场景其实很明确:
- 扩展字段。比如商品的多语言描述、SKU的规格参数,不同品类的字段完全不一样,硬拆列会拆出一堆稀疏列。
- 外部系统报文。支付回调、第三方接口的原始数据,主要做存档和偶尔排查问题用,不需要参与关系运算。
- 前端自定义配置。用户自定义的报表条件、页面布局,后端不关心结构,只负责存取。
- 树形或嵌套结构。比如菜单的多级children,拆成表要递归查询,存JSON反而一次取完。
这些场景有个共同点:CRUD的重心在"存取"而不是"关系运算"。一旦JSON字段频繁出现在where条件、join、group by里,就别硬存了,老老实实拆表,或者干脆上文档数据库。这是我总结的第一条经验:JSON列是妥协方案,不是银弹,它只适合解决"结构不稳定数据"的存取问题,不适合参与关系计算。
1.2 MyBatis默认面对JSON列时的尴尬
场景定了,接下来的问题就落在和数据库打交道的这一层。MyBatis本身是一个"列名到属性"的映射框架,它在做映射时会检查Java类型和JdbcType是否匹配。数据库里的JSON列,在JDBC眼里就是一个String(MySQL的JSON类型会被驱动转换成String),如果实体字段也是String,那一切正常,查出来拿到的就是一段原始JSON字符串。
问题出在实体字段不是String的时候。比如我把扩展字段定义成Map<String, Object>,直接查询,MyBatis会尝试把String塞进Map,结果要么是TypeException,要么静默置null。有些同学图省事,把实体字段定义成String,然后在Controller里拿JsonNode去解析,这等于把自动映射的工作又扔回给业务层。整个项目里到处都是JSON.parseObject(xxx.getExtra())这种代码,排查问题的时候根本不知道哪里转换过、哪里没转换过。
所以核心矛盾就摆在这:数据库里是JSON字符串,Java这边希望是对象,而MyBatis默认不做这层转换。要解决它,就得动用MyBatis留给我们的一扇门——TypeHandler。这扇门并不是为JSON设计的,但用好了,它就是"自动映射对象为JSON"最顺手的入口。
2. 三种自动映射方案,我为什么最后选了TypeHandler
2.1 方案一:Service层手动序列化,最省事但也最容易被复制粘贴
很多项目的现状就是这样:实体里保留一个String字段,再在Service层写一个私有方法做转换,每次使用调一下。我最早也是这么干的,还专门封装过一个JsonUtil工具类:
// 读取 OrderExtra extra = JsonUtil.parseObject(order.getExtra(), OrderExtra.class); // 写入 order.setExtra(JsonUtil.toJson(extra));看起来挺干净对吧?但实际项目里最大的问题不是代码量,而是转换时机不统一。有的同事在Controller转,有的在Service转,还有的直接把extra字符串返回给前端,让前端自己去解析。一旦字段结构调整,你根本不知道哪些地方漏改了。其次,类型转换没有兜底,parse出错时只能在业务代码里try-catch,业务逻辑被异常处理淹没,可读性直线下降。这种方案对两三个字段还行,字段一多就失控。
2.2 方案二:给每个类型写一个TypeHandler,半自动但类爆炸
后来我跟MyBatis的TypeHandler杠上了,发现可以让MyBatis在映射时自动调用我们定义的处理器。思路很简单:针对每个目标类型写一个TypeHandler,在resultMap里给它指过去。这个方案确实解决了转换时机统一的问题,但用了一阵子就不对劲——一个系统里JSON字段可能有十几种类型:List<OrderDetail>、Map<String, Object>、Set<Long>、某个DTO嵌套另一个DTO,每一种都要单开一个类。如果只写一个Handler然后内部做类型判断,代码又丑得没法看。总之是能跑,但维护成本相当于把复制粘贴从业务层搬到了配置层。
2.3 方案三:抽象基类加TypeReference子类,唯一让我觉得接近"自动"的做法
我最终采用的方案,是写一个AbstractJsonTypeHandler<T>抽象基类,子类用一行构造器指定目标类型,配合全局注册或者字段级配置。整体思路可以概括成:把"JSON字符串与Java对象互转"的逻辑收敛到一个抽象类里,具体类型信息由子类通过Jackson的TypeReference带进去。这样既能保留泛型信息,又不至于每个Handler都复制一遍读写代码。三个方案放在一起对比,差异就很明显:
| 方案 | 代码量 | 类型安全 | 转换时机 | 维护成本 |
|---|---|---|---|---|
| Service层手动序列化 | 少 | 弱,容易漏 | 不统一 | 高,散落各处 |
| 每个类型一个Handler | 中 | 中 | 统一,但配置分散 | 中,类太多 |
| 抽象基类加子类 | 低 | 强,TypeReference保留泛型 | 统一 | 低,新增类型只加一行子类 |
方案三也不是银弹,它没法解决"一个字段在某些场景要读成对象,另一些场景只要原始字符串"的需求。真遇到这种需求,就别硬套TypeHandler了,直接用String字段反而更快,查询SQL里临时转换也更清晰。
3. 实操:一个通用JsonTypeHandler的完整落地
3.1 底层选Jackson而不选Gson/Fastjson的原因
写TypeHandler之前,先要定JSON库。我承认Gson的API确实简洁,Fastjson的序列化速度在某些老版本测试里也很亮眼,但我在几个项目里用过之后,最终固定用Jackson,理由只有一个:它和Spring Boot、MyBatis-Plus这些框架的集成最顺,生态里的坑基本都被填平了。尤其当你需要自定义序列化规则、处理多态、搭配TypeReference反序列化泛型的时候,Jackson的ObjectMapper设计得最完整。还有一个现实原因:Spring MVC默认的HttpMessageConverter就是Jackson,项目里反正已经引入了,没必要再加一个依赖。
用Jackson要注意ObjectMapper的配置,很多人直接new ObjectMapper()就开始用,等到序列化LocalDateTime时发现输出是个数组,接前端传回来的时间字符串又解析不了,才意识到要注册JavaTimeModule。正确姿势是把ObjectMapper配置成全局单例,它的实现是线程安全的,初始化一次就够了,不要每次转换都new一个。
3.2 核心代码:从BaseTypeHandler到带TypeReference的抽象基类
下面这段代码是我现在的标配。它继承MyBatis的BaseTypeHandler<T>,重写四个方法,内部用Jackson的TypeReference来保留泛型信息:
package com.example.mybatis.handler; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import java.io.IOException; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; import java.util.Map; public abstract class AbstractJsonTypeHandler<T> extends BaseTypeHandler<T> { protected static final ObjectMapper MAPPER = new ObjectMapper() .registerModule(new JavaTimeModule()) .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); private final TypeReference<T> typeReference; protected AbstractJsonTypeHandler(TypeReference<T> 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 (JsonProcessingException e) { throw new SQLException("对象序列化为JSON失败, index=" + i, e); } } @Override public T getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } @Override public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private T parse(String json) throws SQLException { if (json == null || json.isEmpty()) { return null; } try { return MAPPER.readValue(json, typeReference); } catch (IOException e) { throw new SQLException("JSON反序列化失败: " + json, e); } } }注意两个细节。第一,我重写了BaseTypeHandler里的三个getNullableResult重载,分别覆盖按列名读取、按列索引读取、按存储过程参数读取三种情况,缺哪个,用到哪个场景就会报抽象方法错误。第二,写入时调用ps.setString(i, json),因为JDBC层面JSON列就是以字符串形式交互的,数据库驱动会再把它转换成数据库自己的JSON格式。
最关键的还是MAPPER.readValue(json, typeReference)。很多人写TypeHandler时习惯用readValue(json, SomeClass.class),但如果你要反序列化的是List<SkuAttrVO>,传Class是没办法表达泛型的,Jackson只能得到List<LinkedHashMap>。用TypeReference把泛型信息完整传进去,反序列化出来直接就是带类型的List,这就是"真自动映射"和"伪自动映射"的分水岭。
只要目标类型不是太怪,子类写起来就是一个空壳:
public class SkuAttrListTypeHandler extends AbstractJsonTypeHandler<List<SkuAttrVO>> { public SkuAttrListTypeHandler() { super(new TypeReference<List<SkuAttrVO>>() {}); } }如果JSON字段是Map<String, Object>,也照葫芦画瓢:
public class ExtraMapTypeHandler extends AbstractJsonTypeHandler<Map<String, Object>> { public ExtraMapTypeHandler() { super(new TypeReference<Map<String, Object>>() {}); } }整个项目的JSON类型再多,无非就是多几个这样的壳子类,每个类只有构造器那一行,维护成本几乎可以忽略。
3.3 三种接入方式:XML、注解、全局注册
代码写好后,接下来就是把TypeHandler塞进MyBatis的映射过程。入口有三个,按项目情况选就行。
第一种,在XML的resultMap里显式指定,这是最直观、也是我主力使用的方式:
<resultMap id="skuResultMap" type="com.example.domain.SkuInfo"> <id column="id" property="id"/> <result column="attrs" property="attrs" typeHandler="com.example.mybatis.handler.SkuAttrListTypeHandler"/> </resultMap>插入时也要告诉MyBatis用哪个Handler处理参数:
<insert id="insertSku"> insert into sku_info (id, sku_name, attrs) values (#{id}, #{skuName}, #{attrs, typeHandler=com.example.mybatis.handler.SkuAttrListTypeHandler, jdbcType=VARCHAR}) </insert>注意这里我写了jdbcType=VARCHAR。如果你不写,字段为null的时候MyBatis会抛出TypeException,原因后面踩坑部分细说。
第二种,纯注解模式。如果用注解开发,可以在Mapper接口方法上用@Results和@Result指定:
@Select("select id, sku_name, attrs from sku_info where id = #{id}") @Results(id = "skuResultMap", value = { @Result(column = "id", property = "id"), @Result(column = "attrs", property = "attrs", typeHandler = SkuAttrListTypeHandler.class) }) SkuInfo selectById(Long id);第三种,全局注册。把Handler注册到MyBatis配置里,之后凡是目标类型匹配的字段都自动走这个Handler。好处是写SQL时不用重复声明typeHandler,坏处是注册范围太宽容易误伤。比如你注册了一个ExtraMapTypeHandler给java.util.Map,那实体里其他类型为Map、但数据库列只是普通文本的字段也会被它接管,如果没有按JSON格式存储,解析就会失败。所以全局注册适合"团队统一约定所有Map/List字段都存JSON"的项目,否则宁愿用字段级配置,多写几行也不留隐性地雷。
全局注册的写法有两种。传统mybatis-config.xml:
<typeHandlers> <typeHandler handler="com.example.mybatis.handler.ExtraMapTypeHandler" javaType="java.util.Map"/> </typeHandlers>Spring Boot + mybatis-spring-boot-starter环境,可以在application.yml里配置包扫描路径,包下的Handler会自动注册:
mybatis: type-handlers-package: com.example.mybatis.handler4. 真实踩坑记录:类型擦除、空覆盖、日期格式这五个问题
4.1 读出来全是LinkedHashMap,泛型信息是怎么丢的
这是我自己最早踩过、也是网上问题最多的一个坑。如果只用new JsonTypeHandler extends BaseTypeHandler<Object>,然后readValue(json, Object.class),从数据库读出来的List<Long>会变成ArrayList<LinkedHashMap>,赋值给实体字段时直接ClassCastException。原因很简单:Object.class在Jackson眼里就是一个无结构的类型,它拿到[1,2,3]这种数组时,只能老老实实生成ArrayList,元素是Integer还好,一旦数组里是对象,它没能力猜出你想要的SkuAttrVO结构,只能用LinkedHashMap兜底。这个问题的根源是Java泛型擦除,运行时拿到的T其实是Object,不把类型信息显式传进去,任何框架都救不了你。
所以我的AbstractJsonTypeHandler强制要求子类传TypeReference。如果你团队里有人图省事写Object.class通用Handler,趁早拦住,这种代码一旦上线,就是隐藏在业务深处的不定时炸弹。
4.2 insert时字段为null直接报TypeException
第二个坑和null有关。把Handler接好后的第一个晚上,我写了个insert测试,发现只要attrs传null,MyBatis直接抛异常:JDBC requires that the JdbcType must be specified for all nullable parameters。原因在BaseTypeHandler源码里写得很清楚:参数为null时,它不知道这一列在数据库里的类型,必须由你告诉它jdbcType是什么。解决办法就是在#{}里明确指定,像我3.3节里的例子那样,写jdbcType=VARCHAR。
实际项目里,我更建议写成动态SQL,attrs为空就不更新这列,这样既能避免TypeException,也能避免把业务上没有意义的空值写进数据库。下面这个update示例会一并说明。
4.3 局部更新把整个JSON列清空
这其实是动态SQL的问题,但和JSON字段放一起时特别容易出事。我踩过的情况是:编辑某个商品,前端只改了商品名称,没动attrs,结果整个attrs列被更新成了NULL。原因是Mapper里写的是全量更新:
<update id="updateSku"> update sku_info set sku_name = #{skuName}, attrs = #{attrs, typeHandler=com.example.mybatis.handler.SkuAttrListTypeHandler} where id = #{id} </update>前端没传attrs时,这个字段就是null,null被当成普通参数塞进set子句,数据库列就被清空了。后来我改成动态拼接:
<update id="updateSku"> update sku_info <set> <if test="skuName != null">sku_name = #{skuName},</if> <if test="attrs != null"> attrs = #{attrs, typeHandler=com.example.mybatis.handler.SkuAttrListTypeHandler}, </if> </set> where id = #{id} </update>所以要记住:TypeHandler解决的是JSON和对象的转换,不能替你解决"NULL要不要更新"这种业务语义。特别是在接口PATCH语义很常见的前后端分离项目中,全量更新是最容易出事故的写法。
4.4 LocalDateTime被序列化成了一串数组
如果TypeHandler里的ObjectMapper没配置JavaTimeModule,LocalDateTime默认会被序列化成[2024, 1, 1, 12, 0, 0]这种数组形式,反序列化倒是能成功,但接口返回给前端、或者存进数据库里,两边看到的内容都很奇怪。Java Web项目里,时间格式几乎都是yyyy-MM-dd HH:mm:ss,所以我在初始化MAPPER时就注册了JavaTimeModule,并且关闭了WRITE_DATES_AS_TIMESTAMPS。
如果你不想全局改,也可以给实体时间字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。但要小心:TypeHandler里的MAPPER和Spring MVC的ObjectMapper是两个实例,注解在两边都会生效,这种隐式耦合有时候会把新人绕晕。我的习惯是全局统一在ObjectMapper配置里处理时间格式,实体上尽量不加JSON格式注解。
4.5 大JSON字段的查询性能与序列化开销
最后一个坑不是报错,是性能。JSON列方便是真方便,但如果你在表里放几MB的报文,然后select *一把梭,每一次查询都在把整段字符串从数据库拖到应用层,再经过TypeHandler反序列化成对象,CPU和IO都在燃烧。针对大字段,我的建议有三个:
第一,SQL里只select需要的列。第二,历史报文用单独的表或单独的列存,业务高频查询的表里不要放大包袱。第三,MySQL 5.7以上可以用json_extract在数据库端先把需要的子字段提取出来,应用层连TypeHandler都不用接:
select id, sku_name, json_unquote(json_extract(attrs, '$.price')) as price, json_unquote(json_extract(attrs, '$.stock')) as stock from sku_info where id = #{id}这种方式适合"只需要JSON里某一两个子字段"的轻量查询,避免把整棵JSON树反序列化出来,代码还少写一个Handler。只有当接口确实需要返回完整JSON结构时,才让TypeHandler把整个对象加载出来。
5. 让映射更聪明的几个进阶玩法
5.1 MyBatis-Plus 自带的JacksonTypeHandler为什么能少写类
如果你用的是MyBatis-Plus,其实框架里已经内置了一个JacksonTypeHandler,用法非常简单:
@TableName(value = "sku_info", autoResultMap = true) public class SkuInfo { @TableField(typeHandler = JacksonTypeHandler.class) private List<SkuAttrVO> attrs; }它的原理比我手写的抽象基类更进一步:解析实体字段的泛型参数,自动构建Jackson所需的JavaType,所以List<SkuAttrVO>也能还原成正确类型。听起来很完美,但实际用起来要注意两点。第一,必须设置@TableName(autoResultMap = true),否则查询时@TableField里的typeHandler不会自动生效,你会遇到"insert成功、select出来全是null"的诡异现象。第二,当字段声明类型是接口或者抽象实现类时,泛型解析会退化,反序列化结果可能变成LinkedHashMap,遇到这种多态场景还是要换自定义TypeHandler。
我现在的习惯是:简单的List、Map字段直接用内置JacksonTypeHandler,复杂嵌套DTO、或者需要自定义序列化规则(比如字段脱敏、加密存储)的时候,才用自己的抽象基类。框架的东西能少写代码就少写,但要知道它的边界在哪。
5.2 在resultMap里拆JSON列,或让数据库先投影
除了字段级映射,MyBatis还允许在resultMap层面做更灵活的处理。比如attrs JSON列里的price、stock是商品列表页高频展示的字段,实体里不必放一个List<SkuAttrVO>,可以直接拆成两个普通字段:
<resultMap id="skuListMap" type="com.example.dto.SkuListDTO"> <id column="id" property="id"/> <result column="price" property="price"/> <result column="stock" property="stock"/> </resultMap>对应的SQL就是4.5节里带json_extract的那条。这种做法既绕开了TypeHandler的反序列化开销,又贴合列表页的实际需求,是我比较推荐的一类"聪明映射"。它本质上是在说:不是所有JSON列都要整体反序列化,能用数据库函数投影解决的,就别让Java对象承担不必要的转换成本。
5.3 接口返回时避免JSON串被二次转义的小技巧
最后一个进阶玩法,是我在前端联调时被逼出来的。如果实体的某个字段是String,里面存的是标准JSON字符串,用Spring MVC默认的Jackson返回给前端时,会被当作普通字符串转义,前端拿到的是"{\"price\":10}",还得再parse一次。如果确实想让它原样输出,比如接口要直接透传第三方报文,可以给字段加@JsonRawValue:
public class SkuVO { private String skuName; @JsonRawValue private String attrs; // 前端直接拿到 {"price":10},不需要二次解析 }这个注解会让Jackson把该字段的值原封不动写进响应JSON。代价是,如果attrs本身不是合法JSON,整个响应就会坏掉。所以这个技巧只适合你已经通过TypeHandler或者数据库约束保证了字段内容一定是合法JSON的场景,别滥用。
最后分享一个我现在的习惯:新表设计时,凡是允许空的JSON列,都给一个默认值'{}'或'[]',防止老接口漏传字段时把NULL写进库;与此同时,业务代码里凡是涉及JSON字段更新,一律走专门的DTO加TypeHandler,不直接用Map散装传参。这两条规矩立下来之后,JSON字段相关的线上事故少了很多。希望这篇能把TypeHandler这条路上的坑帮你提前避开。