做后端时间久了,一定会遇到这种需求:数据库表里有一列JSON,Java实体类里对应一个对象或者List,查询出来的时候想直接拿到组装好的对象,插入的时候又想自动把对象序列化成JSON串写进去。MyBatis自动映射对象为json这件事,看起来很小,处理不好却特别闹心。
我之前维护过一个电商后台系统,订单表里有个extra_info字段,存的是JSON串,里面是买家备注、发票信息、优惠券明细这类结构不固定的数据。表结构是这么设计的,但Java这边每次查询出来都是一坨String,业务代码里到处是JSON.parseObject(...),写着写着就想骂人。后来我花了点时间把TypeHandler这块吃透了,才彻底告别这种手工活。
这篇文章围绕这个需求展开:典型场景、四种实现方式的利弊、TypeHandler手写全流程、完整链路复现,以及我踩过的坑。适合正在用MyBatis做前后端分离项目、又被JSON字段反复折磨的同学,也适合想深入了解MyBatis扩展机制的开发者。
1. 需求拆解:JSON字段为什么成为MyBatis开发中的老大难
1.1 我遇到的三个典型场景
JSON字段在业务系统里出现的频率,比很多人想象的高得多。我归纳一下,基本逃不出下面三类:
第一类是“用户扩展属性”。比如用户表除了固定的昵称、手机号、头像之外,还想记录用户自定义的偏好设置——通知开关、消息推送频率、界面主题。今天加一个偏好,明天加一个设置,如果每个字段都建一列,数据库表结构会被改得七零八落。最终几乎都会收敛成一张表加一个JSON列,比如attributes、extra_info、config这种命名。
第二类是“明细型数据”。典型代表是商品规格参数。一个SPU可能有多个规格,每个规格包含名称、价格差值、库存影响。规格数量不固定,没法用固定字段表达,存成JSON数组是最自然的。查询商品详情时,直接把这个数组映射成List<SpecItem>,业务层用起来就像操作普通Java集合一样。
第三类是“审计与快照”。比如订单操作日志里记录请求前后的数据差异,或者下单时把购物车商品信息快照一份存进去,防止后续商品改价影响历史订单。这类数据的特征是“结构可能变”,但“历史数据不能动”,JSON天然适合。
这几类场景有个共同点:数据库层面不想拆表,Java层面不想每次手工转换。于是“自动映射”就成了刚需。
1.2 不写TypeHandler,代码会烂成什么样
很多人一开始偷懒,数据库字段直接映射成String,要用的时候再自己转换。短期看确实快,时间一长问题就全冒出来了。
最直观的问题就是重复代码。查询用户的时候要转一次,查询订单的时候要转一次,写操作的时候还要把Java对象手动JSON.toJSONString(...)再塞回去。全项目的变化点到处都是,哪天Jackson换成了Gson,或者字段名改动,牵一发动全身。
然后是异常处理的灾难。手工反序列化就必须处理JSONException,每个调用点要么try-catch要么往上抛。我在一个项目里见过二十多处try { JSON.parseObject(...) } catch (Exception e) { log.error(...) },这些代码本质上都在做同一件事。
更隐蔽的问题是类型安全。手工解析时很容易写出JSON.parseObject(str, Map.class),拿到一个Map,然后强行get("xxx"),取出来的对象又得二次强转。嵌套结构一深,代码根本没有可读性,而且运行时才可能暴露类型错误。
所以,让MyBatis在映射阶段就完成对象与JSON文本之间的转换,把脏活累活拦截在持久层,是非常值得做的事。
2. 方案选型:四种JSON映射路线的利弊分析
2.1 业务层手动序列化:最直接但污染最重
这种方案写起来最简单,查出来是String,业务层调用JSONUtil.toJson或者JSON.parseObject转换一下就行。对于只有一两个字段、调用点很少的小项目,它完全OK。
问题是随着项目膨胀,维护成本会指数上升。每个使用方都要知道这个字段原来是JSON、该怎么转、转错类型怎么办。这本该由持久层封装掉的细节,被暴露到了整个业务层。一旦字段的JSON结构调整,所有解析代码都要跟着改。
另外,手动序列化对写入特别不友好。一不小心把Java对象直接传给Mapper,MyBatis默认会调用该对象的toString(),存进去的就是com.example.User@1a2b3c这种垃圾数据。我见过不止一次线上数据被这么写坏的。
2.2 SQL函数拼JSON:只读场景限定版
MySQL 5.7之后提供了JSON_OBJECT、JSON_ARRAY、JSON_EXTRACT这些函数,可以在SQL里直接把关系型结果拼成JSON。PostgreSQL也有类似的json_build_object、json_agg等函数。
这种做法的好处是查询结果天然是JSON字符串,Mapper接口返回String甚至List<Map<String, Object>>都行,契合一些对外的只读报表接口。但坏处也很明显:嵌套关系复杂的时候,SQL会变成一坨铁打的字符串拼接地狱,可读性极差;而且可用的JSON函数依赖具体数据库厂商,换数据库等于重写。
我个人的建议是:只在一两个查询很固定、结果集要求很特殊的场景用一下,比如数据报表、对外API直出结果,不要把整个持久层架构押在SQL拼JSON上。
2.3 自定义TypeHandler:一次配置,收益长远
TypeHandler是MyBatis原生提供的一种类型转换机制,专门负责JDBC类型和Java对象类型之间的互转。我们可以自定义一个JSON类型的Handler,让MyBatis在读取结果集时自动把JSON文本反序列化为Java对象,在写入参数时自动把对象序列化为JSON文本。
这是一条正经的“对象与JSON自动映射”路线。业务层拿到的始终是对象,存进去的也始终是对象,底层的序列化和反序列化完全由持久层接管。一次编写、全局复用,后续新增一个JSON字段只需要把它注册到对应实体的字段上就行。
这也是本篇文章重点讲的方案,后面会给出可落地的完整代码。
2.4 内置处理器与框架方案:省事但有条件
如果你的项目用的是MyBatis-Plus,它自带了一个JacksonTypeHandler,可以直接通过注解挂在字段上,非常方便。但它有个前提:实体类必须标注@TableName(autoResultMap = true),否则MyBatis-Plus生成的ResultMap不会把typeHandler带进去,查询时JSON字段永远返回null。这个坑太经典,后面会专门讲。
另外,像Hutool这类工具库也提供了通用的JSON类型处理器,底层可以切换Jackson、Gson、Fastjson。如果项目已经重度使用Hutool,用它封装好的Handler能少写不少代码。
2.5 怎么选:一张表说清楚
我把四种方案放在一起做了个对比:
| 方案 | 实现成本 | 代码侵入性 | 灵活性 | 适用情况 |
|---|---|---|---|---|
| 业务层手动序列化 | 低 | 高,到处都有转换代码 | 中 | 只有一两个JSON字段的小项目 |
| SQL函数拼JSON | 偏低 | 只限于SQL层 | 低,嵌套复杂就失控 | 固定只读接口、报表查询 |
| 自定义TypeHandler | 中,写一个类 | 低,配置一次后业务层无感 | 高,可以控制序列化细节 | 多字段、多实体都要映射的项目 |
| MyBatis-Plus内置Handler | 很低 | 低 | 受限于框架版本 | 已经使用MyBatis-Plus的项目 |
我自己的默认选择是:有MP就用内置JacksonTypeHandler,原生MyBatis就自定义一个TypeHandler。这条路线最稳,后面要加的坑也是可控的。
3. 核心实现:手写通用JSON TypeHandler的完整代码
3.1 TypeHandler的原理先搞明白
在动手写代码之前,得先知道TypeHandler在MyBatis里到底扮演什么角色。MyBatis执行一条查询时,内部会经过ResultSetHandler来处理结果集,而具体把ResultSet里的某一列转换成Java对象的,就是TypeHandler。
TypeHandler接口定义了四个核心方法:
setParameter:给PreparedStatement设置参数时调用,负责把Java对象转换成JDBC可以接受的类型,对应写操作。getResult(ResultSet, String):按列名从结果集取值,对应查询操作。getResult(ResultSet, int):按列索引从结果集取值。getResult(CallableStatement, int):处理存储过程调用时的返回值。
我们通常不直接实现接口,而是继承BaseTypeHandler<T>抽象类,只需要重写setNonNullParameter和三个getNullableResult方法。MyBatis已经处理好了“参数为null”的分支逻辑,我们只需要关注非null情况下的转换。
理解了这一点,JSON映射的TypeHandler思路就很清晰:写的时候把对象序列化成JSON串,通过JDBC的setString写入数据库;读的时候把数据库返回的JSON字符串反序列化成目标对象返回给业务层。
3.2 基于Jackson的通用TypeHandler实现
接下来是核心代码。我个人习惯用Jackson做默认的JSON处理库,因为Spring Boot默认就带Jackson,不需要额外引入依赖,而且它的ObjectMapper功能足够丰富。
启动项目前需要确认依赖都有,如果是Spring Boot项目,下面这个已经有了,不用再加:
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>如果要在JSON对象里包含LocalDateTime这类Java 8时间类型,需要额外引入JSR310模块,并把JavaTimeModule注册进ObjectMapper。这是很多人在实际使用中会栽的跟头,我放在后面的排查章节里详细说。
先给一个基础版本的通用JSON TypeHandler:
package com.example.handler; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class JsonTypeHandler<T> extends BaseTypeHandler<T> { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final Class<T> clazz; public JsonTypeHandler(Class<T> clazz) { this.clazz = clazz; } @Override public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, toJson(parameter)); } @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 String toJson(T object) { try { return OBJECT_MAPPER.writeValueAsString(object); } catch (JsonProcessingException e) { throw new RuntimeException("对象序列化为JSON失败", e); } } private T parse(String json) { if (json == null || json.isEmpty()) { return null; } try { return OBJECT_MAPPER.readValue(json, clazz); } catch (JsonProcessingException e) { throw new RuntimeException("JSON反序列化失败: " + json, e); } } }这个类是通用的,任何实体对象都能用。构造器传入目标类clazz,就是告诉Jackson反序列化时转成什么类型。
不过这里有个绕不开的问题:Java的泛型会在运行时被擦除,如果直接new JsonTypeHandler(),根本不知道clazz是什么。因此不能指望MyBatis在XML里写一个typeHandler="...JsonTypeHandler"的字符串就能自动完成所有类型映射,我们必须给它指明具体的转换目标类型。
3.3 泛型擦除的实战解法:子类绑定与TypeReference
解决泛型擦除有两种主流做法,我在项目里都验证过。
第一种方法,也是最直观的:为每个需要转换的目标类型写一个子类,在子类的构造器里把具体的Class传进去。比如订单的OrderExtra对象需要一个Handler,就可以这样写:
package com.example.handler; import com.example.entity.OrderExtra; public class OrderExtraTypeHandler extends JsonTypeHandler<OrderExtra> { public OrderExtraTypeHandler() { super(OrderExtra.class); } }这样在XML或者注解里注册typeHandler="com.example.handler.OrderExtraTypeHandler"时,MyBatis能通过无参构造把它new出来,并且Handler内部知道要把JSON转成OrderExtra。这相当于用子类把泛型信息固化下来,代码量不大,一个类型一行子类声明,我最常用这个办法。
第二种方法是借用MyBatis-Plus的AbstractJsonTypeHandler思路,通过TypeReference在反序列化时保留泛型类型。MP里处理List<String>、List<Map<String, Object>>这些泛型容器特别有用,因为无法为每个泛型容器都写一个子类。
MP的JacksonTypeHandler核心实现大致长这样,内部利用TypeReference解决反序列化泛型问题:
package com.example.handler; import com.fasterxml.jackson.core.JsonProcessingException; 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.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public abstract class AbstractJsonTypeHandler<T> extends BaseTypeHandler<T> { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 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 { ps.setString(i, toJson(parameter)); } @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 String toJson(T object) { try { return OBJECT_MAPPER.writeValueAsString(object); } catch (JsonProcessingException e) { throw new RuntimeException("对象序列化为JSON失败", e); } } private T parse(String json) { if (json == null || json.isEmpty()) { return null; } try { return OBJECT_MAPPER.readValue(json, typeReference); } catch (JsonProcessingException e) { throw new RuntimeException("JSON反序列化失败: " + json, e); } } }使用时就构造匿名内部类传入具体的TypeReference:
public class StringListTypeHandler extends AbstractJsonTypeHandler<List<String>> { public StringListTypeHandler() { super(new TypeReference<List<String>>() {}); } }这种方式写起来稍微绕一点,但对List<SpecItem>、Map<String, Object>这类泛型容器是唯一优雅的解。我在实际项目里两种方法混合用:实体对象用J子类绑定,泛型容器用TypeReference方案。
3.4 三种注册方式:XML、注解、包扫描
Handler写好了,还得让MyBatis知道在哪个字段上用它。主流有三种注册方式。
第一种是XML的ResultMap显式配置。这是最通用的方式,在Mapper的XML文件里给对应字段绑定Handler:
<resultMap id="OrderResultMap" type="com.example.entity.Order"> <id column="id" property="id"/> <result column="extra_info" property="extraInfo" typeHandler="com.example.handler.OrderExtraTypeHandler"/> </resultMap>这里有个容易被忽略的点:MyBatis的自动映射行为默认是PARTIAL,也就是说,没在ResultMap里显式声明的字段,只要列名能和属性名匹配上,依然会自动映射。所以上面的ResultMap只写了extra_info这一列,其他字段不受影响,不需要把所有列都罗列出来。这也是很多人不敢用ResultMap的原因,其实完全没必要担心。
第二种是MyBatis-Plus的注解方式:
@TableName(value = "t_order", autoResultMap = true) public class Order { @TableId private Long id; @TableField(typeHandler = JacksonTypeHandler.class) private OrderExtra extraInfo; }注意autoResultMap = true这个属性。MyBatis-Plus默认生成的ResultMap不会带上字段的typeHandler配置,如果不把autoResultMap打开,查询时extraInfo就会一直是null。这个坑踩的人太多了,我每次写都要默念一遍。
第三种是包扫描自动注册。在全局配置里指定Handler所在的包,MyBatis启动时会自动扫描并注册所有Handler类。这种方式虽然省去了逐字段绑定的麻烦,但不推荐直接用于JSON映射,因为它无法告诉MyBatis某个Handler到底对应哪个实体字段。它更适合那些“不管什么字段,一律按统一规则转换”的场景,而JSON转换恰恰需要针对不同类型定制目标Class。
3.5 全局配置与JDBC类型的坑
不管用哪种注册方式,有一个全局配置建议加上,那就是下划线转驼峰:
mybatis: configuration: map-underscore-to-camel-case: true这样数据库的user_name列才能自动映射到Java的userName属性。否则用resultType自动映射时,JSON字段就算Handler配对了,其他列名对不上也会全部是null。
再讲一个JDBC类型层面的坑。JSON字段在MySQL里常用json类型,在PostgreSQL里常用jsonb类型。MySQL的JDBC驱动会把json列按字符串返回,用setString和getString都没问题。
但PostgreSQL的jsonb不一样,驱动返回的是一个PGobject,不是纯字符串。如果你在PostgreSQL上用了上面的Handler,读取时直接rs.getString(columnName)在某些版本的驱动下会抛异常,或者返回空串。解决办法是在getNullableResult里对类型做一次判断,遇到PGobject就取它的getValue():
Object obj = rs.getObject(columnName); if (obj instanceof PGobject) { JSONObject pgObject = (PGobject) obj; return parse(pgObject.getValue()); } return parse(obj == null ? null : obj.toString());这块属于迁移数据库才会遇到的知识点,遇到时知道怎么处理就行。
4. 实操复现:从建表到Controller输出JSON的完整链路
4.1 建表、实体、TypeHandler准备
理论讲得再多,不如跑一个最小可用的例子。我用一个非常典型的需求来演示:用户表存一个JSON类型的attributes字段,里面是用户偏好设置对象,查询用户信息时希望attributes直接映射成Java对象。
建表语句:
CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(64) NOT NULL, attributes JSON NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );实体类:
package com.example.entity; import java.time.LocalDateTime; public class User { private Long id; private String userName; private UserAttributes attributes; private LocalDateTime createdAt; // 省略 getter / setter }UserAttributes是内嵌的一个对象,比如记录用户等级、渠道来源、消息偏好:
package com.example.entity; public class UserAttributes { private Integer level; private String channel; private Boolean enableSms; private Boolean enableEmail; // 省略 getter / setter }对应的TypeHandler子类:
package com.example.handler; import com.example.entity.UserAttributes; public class UserAttributesTypeHandler extends JsonTypeHandler<UserAttributes> { public UserAttributesTypeHandler() { super(UserAttributes.class); } }4.2 Mapper接口与XML配置
Mapper接口很简单:
package com.example.mapper; import com.example.entity.User; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper { User selectById(@Param("id") Long id); List<User> selectList(); int insert(User user); }XML文件:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.UserMapper"> <resultMap id="UserResultMap" type="com.example.entity.User"> <id column="id" property="id"/> <result column="user_name" property="userName"/> <result column="attributes" property="attributes" typeHandler="com.example.handler.UserAttributesTypeHandler"/> <result column="created_at" property="createdAt"/> </resultMap> <select id="selectById" resultMap="UserResultMap"> SELECT id, user_name, attributes, created_at FROM t_user WHERE id = #{id} </select> <select id="selectList" resultMap="UserResultMap"> SELECT id, user_name, attributes, created_at FROM t_user </select> <insert id="insert" parameterType="com.example.entity.User"> INSERT INTO t_user (user_name, attributes, created_at) VALUES ( #{userName}, #{attributes, typeHandler=com.example.handler.UserAttributesTypeHandler}, #{createdAt} ) </insert> </mapper>注意插入语句里#{attributes, typeHandler=...}这种写法,指定参数写的时候用哪个Handler。如果不指定,MyBatis会把UserAttributes当成普通对象处理,调用它的toString(),写入数据库的就是对象内存地址字符串,等于把数据写坏。
4.3 查询接口直接返回对象
Controller层没有任何JSON转换代码,直接拿实体:
package com.example.controller; import com.example.entity.User; import com.example.mapper.UserMapper; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; @RestController public class UserController { private final UserMapper userMapper; public UserController(UserMapper userMapper) { this.userMapper = userMapper; } @GetMapping("/user/{id}") public User getUser(@PathVariable("id") Long id) { return userMapper.selectById(id); } }请求/user/1时,Spring MVC用Jackson序列化User对象。因为attributes在MyBatis层已经反序列化成了UserAttributes对象,所以序列化结果是嵌套结构。数据库里存的是一段JSON文本,返回给前端的却是规整的对象JSON,全程没有任何手工转换:
{ "id": 1, "userName": "张小北", "attributes": { "level": 3, "channel": "APP", "enableSms": true, "enableEmail": false }, "createdAt": "2024-06-01 10:30:00" }4.4 写入场景:对象自动存成JSON串
写入操作同样自动。Service层只需要构建对象,完全不需要关心数据库列是文本、是JSON还是别的:
User user = new User(); user.setUserName("李一一"); UserAttributes attributes = new UserAttributes(); attributes.setLevel(5); attributes.setChannel("WEB"); attributes.setEnableSms(true); attributes.setEnableEmail(true); user.setAttributes(attributes); userMapper.insert(user);日志或者数据库客户端里能看到attributes字段被自动写成了{"level":5,"channel":"WEB","enableSms":true,"enableEmail":true}。整个过程序列化发生在TypeHandler内部,业务层只碰对象。
4.5 查询结果直接输出JSON串的替代姿势
有一种特殊情况:如果某个接口只想要原始JSON串,不想要对象,比如有段代码在下游又要自己处理,那么可以不配Handler,Mapper直接返回String:
@Select("SELECT attributes FROM t_user WHERE id = #{id}") String selectAttributesJson(@Param("id") Long id);这种情况适合“纯透传”场景,不关心JSON结构,数据库是什么就原样返回什么。还有一种是查多行、每行只取一个JSON串,返回值可以设计成List<String>。
这两个姿势不冲突。对象映射用于业务逻辑处理,字符串透传用于无脑转发。我自己遇到“只给前端回显、不需要二次加工”的接口时,会默认使用String透传,省一层反序列化开销。
5. 常见问题排查与性能优化实录
这块是文章最有价值的部分,都是我实际踩过的坑,整理成速查表方便对号入座。
5.1 查出来是null?三个原因按顺序排查
JSON字段查询出来为null,是最常见的问题,通常逃不出下面三个原因。
第一个,ResultMap没配或者没配对。XML方式里没写typeHandler,或者写了但类路径错了;MP方式里忘了加autoResultMap = true。检查方法很简单:看一眼执行的SQL日志和ResultMap配置,对照一下字段名。
第二个,列名和属性名对不上。比如数据库列叫attributes,实体属性叫attribute(少个s),或者下划线转驼峰全局开关没开。这种情况不仅JSON字段为null,普通的user_name也会出问题。
第三个,字段本身在数据库里就是NULL。很多Handler实现里json == null || json.isEmpty()都会返回null,这是正常行为,不是Bug。排查的时候先查数据库原始值,再考虑配置问题。
我见过一个项目,排查了半天Handler没问题,最后发现是数据库初始化脚本里attributes列没有给默认值,老数据全是NULL。所以说先查数据,再查配置。
5.2 反序列化失败的几种典型情况
反序列化失败最常见的报错是JsonProcessingException: Unrecognized field之类的。这个异常意味着数据库里的JSON结构和目标Java对象的字段对不上。
举个例子:实体里UserAttributes只有level、channel、enableSms、enableEmail四个字段,但数据库的JSON里存了一个mobile字段。Jackson默认遇到未知字段会直接报错。如果业务上允许旧数据有多余字段,可以在ObjectMapper上加上:
OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);如果反序列化目标是List<SpecItem>,但数据库里存的是单个对象结构{...}而不是数组[{...}],也会转换失败。这种一般是历史数据格式迁移不完整造成的,最直接的办法是写一个数据订正脚本,把脏数据处理干净。
还有个隐蔽问题:双重序列化。我看过有人写插入逻辑时先手动转了一次JSON字符串,存进对象里的字段本身就是String类型,结果TypeHandler又序列化了一次,数据库里就成了带转义的字符串。检查方法就是看库里的值有没有很多反斜杠。
5.3 日期类型和空字段的处理
如果JSON对象里包含LocalDateTime类型,直接用默认ObjectMapper反序列化会报错:Java 8 date/time type not supported。需要在ObjectMapper里注册JSR310模块,并且按项目规范配置日期格式:
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")) .setSerializationInclusion(JsonInclude.Include.NON_NULL);WRITE_DATES_AS_TIMESTAMPS关闭后,LocalDateTime默认会序列化成ISO字符串,可读性好很多。setSerializationInclusion(JsonInclude.Include.NON_NULL)可以让null字段不输出,减少无效传输数据。
这一块我之前踩过,不加配置时查出来是一个数组格式的时间戳,前端解析直接报错,后来统一在Handler里设置好了才稳定。
5.4 日志中看到转义反斜杠,别慌
很多时候,日志里打印出来的JSON字段值长这样:
"attributes": "{\"level\":3,\"channel\":\"APP\"}"这是在日志输出时,JSON字符串里的引号被转义了,属于正常现象。如果此时解析出来的是String而不是对象,说明Handler没生效;但如果解析出来是对象,仅仅是日志层面看起来像转义,完全不用管。
之所以特别强调,是因为我在项目里见过有人看到这种日志,以为是数据写坏了,跑去数据库里人工改数据,结果把原本正常的值改成了真·脏数据。正确的判断方式是:用数据库客户端直接查一下原始值,原始值没有反斜杠就是正常的。
5.5 性能观察与优化建议
JSON序列化和反序列化是有CPU开销的。一个List<User>查询,每行都要把JSON文本解析成对象,数量一多还是挺明显的。
我有几个优化习惯。第一,查询时只查必要的字段,别偷懒写SELECT *,尤其不要把超大JSON字段带到一个根本不需要它的列表接口里。第二,对于纯展示接口,如果确认下游不需要对象结构,直接用String透传,省掉反序列化开销。第三,大批量导入导出场景,尽量在SQL层面完成数据裁剪,减少进入ORM的数据量。
另外,MySQL对JSON列支持函数索引,比如JSON_EXTRACT(attributes, '$.level')。如果有按JSON内部字段查询的需求,可以给对应表达式建索引,避免全表扫描。这个平时用不上,但一旦压测遇到瓶颈,是一条实用的后路。
我在一个导出接口里,原本是查出5万条对象再逐条转JSON,CPU飙得很高。后来改成SQL层直接输出JSON文本,Mapper返回List<String>,接口耗时直接砍了一半。所以Handler虽好,也不要什么场景都硬上。
最后说点我自己的体会。TypeHandler这个东西看着不起眼,其实它是MyBatis扩展性最好的一环。把这块搞明白,不光JSON,很多东西都能挂进去做透明转换。我以前在项目里用TypeHandler做过敏感字段加解密,查询时自动解密、写入时自动加密,业务层完全无感知。这类“持久层自动帮业务层干活”的设计思路,一旦用顺了,整个代码的整洁度会上一个台阶。希望这篇文章能帮你在JSON自动映射这条路上少踩几个坑。