我接手过不少半路项目,最头疼的就是数据库字段和Java类型对不上。数据库里明明存了个VARCHAR,Java代码里却想直接拿一个Map来接;用户表里手机号是密文,查询的时候却得先解密再比对;枚举存进库里的到底是数字还是字符串,每个项目规则还不一样——这些问题,MyBatis和MyBatis-Plus默认的类型转换规则根本管不过来,真正能兜底的方案就是自定义TypeHandler。
这篇文章就围绕TypeHandler这条主线:先讲清楚它到底在MyBatis执行链路里干了什么活,再给出一套可以直接抄的JSON字段Handler和AES加密字段Handler实现,最后把MyBatis和MyBatis-Plus两种场景下的注册、踩坑、排查路径全过一遍。不管你现在是刚接触框架定制,还是已经在生产环境里被字段映射问题折腾过,这里面的内容都能让你少走弯路。
1. 什么时候你才真正需要自定义TypeHandler
1.1 那些"映射不上"的字段类型
先说个最直接的问题:为什么不直接用@TableField加上默认映射,非要折腾Handler?
因为MyBatis自带的类型转换只覆盖了最常规的Java类型和JDBC类型对,比如String对应VARCHAR、Integer对应INTEGER、LocalDateTime对应TIMESTAMP。一旦你的字段带上业务语义,默认规则就失效了。我归纳了一下,实际项目中触发自定义TypeHandler需求的基本是下面这几类场景:
- 数据库存的是JSON字符串,Java实体里却声明成
Map<String, Object>、List<Long>或者某个自定义DTO对象。默认情况下MyBatis拿到字符串根本不知道往Map里塞,直接抛TypeException。 - 敏感字段需要加密存储,数据库里是密文VARCHAR,Java对象里是明文String。你既不想在Service层每个方法里都手动加密解密,又不想让加密逻辑污染业务代码。
- 枚举类型。Java枚举在库里存的是
code数字或value字符串,但Java属性是枚举对象。MyBatis默认的EnumTypeHandler只处理枚举name,一旦枚举值改过名字,历史数据就全部错位。 - 一些特殊JDBC类型,比如PostgreSQL的
jsonb、cidr,MySQL的BIT存布尔数组,这些用默认处理器也无法正确处理。 - 自定义值对象,比如金额类
Money、身份证号类IdCard,这类领域对象往往需要一套统一的序列化规则,不适合在每个Mapper里重复写转换逻辑。
这五类场景的共同点是什么?**类型转换的规则不是框架能猜出来的,而是业务方自己定义的。**框架猜不出来,又不愿意把转换代码散落到业务层到处复制,那就得让MyBatis在结果映射和参数赋值两个环节之间,插入一段我们自己的转换逻辑——这段逻辑的载体就是TypeHandler。
1.2 一个比喻快速理解Handler的定位
很多人看官方文档会把TypeHandler理解成"一个自定义转换器",但这个说法太模糊。我更喜欢用物流分拣来打比方:
MyBatis执行一条SQL,本质上就是"Java对象 -> SQL参数 -> 数据库结果 -> Java对象"的双向运输过程。PreparedStatement是装货的卡车,ResultSet是卸货的传送带。默认情况下,MyBatis的运输队会根据货物标签(Java类型 + JdbcType)自动选一辆默认车来搬运。但如果你的货是"易碎品"(自定义类型),或者标签贴错了(类型不匹配),默认车队就不知道怎么搬了。
TypeHandler就是一组"特制的打包箱"。你在装货的时候告诉MyBatis:"这个Java对象进数据库之前,请先套上我给的箱子(转成特定JDBC类型或字符串格式)。"在卸货的时候也一样:"数据库给我这个值,请先按我给的规则拆箱,再塞进Java属性。"
理解了这层定位,再去看官方文档里那四个方法就知道它们分别对应运输的哪个环节了。
2. TypeHandler运行时到底做了什么:四个方法的真相
2.1 从PreparedStatement到ResultSet的双向转换
TypeHandler这个接口的核心方法只有4个,加上一个setParameter默认方法和两个泛型继承方法,一共也就6个方法需要关注。但实际开发中,你只需要实现BaseTypeHandler<T>抽象类,覆盖下面两个写入方法和两个读取方法:
| 方法名 | 调用时机 | 做什么 |
|---|---|---|
setNonNullParameter | SQL执行前,给PreparedStatement绑定参数时 | 把Java对象的属性转成JDBC能识别的类型,调用ps.setXxx |
setNonnullParameter(串行版) | 罕见场景,可不重写 | 与setNonNullParameter二选一,一般用前者 |
getNullableResult(ResultSet, String) | 查询结果按列名映射时 | 从ResultSet按列名取值,转成Java类型返回 |
getNullableResult(ResultSet, int) | 查询结果按下标映射时 | 同上的下标版本,一般在resultMap列映射或游标查询时触发 |
getNullableResult(CallableStatement, int) | 存储过程出参 | 处理存储过程返回值场景 |
setParameter | @Param注解等场景 | 默认走setNonNullParameter,很少直接重写 |
2.2 为什么setNonNullParameter和getNullableResult要各写一遍
我见过不少新手只实现setNonNullParameter,然后发现查询结果还是解析不了——因为忘了读取方向。TypeHandler最大的特点就是它是双向的:
- 写入方向:Java对象属性 ->
setNonNullParameter-> PreparedStatement -> 数据库 - 读取方向:数据库列值 ->
getNullableResult-> Java对象属性
这两条链路在MyBatis中分别由PreparedStatementHandler和ResultSetHandler去调用。如果你只处理了写入,查询出来的字段依然会被默认规则处理,该报错的还是报错,该读错的还是读错。
2.3 最小可用的代码骨架
按照上面四个方法写一个最小骨架,大概长这样:
public class MapTypeHandler extends BaseTypeHandler<Map<String, Object>> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Object> parameter, JdbcType jdbcType) throws SQLException { // 写入方向:Java Map -> JSON字符串 ps.setString(i, MAPPER.writeValueAsString(parameter)); } @Override public Map<String, Object> getNullableResult(ResultSet rs, String columnName) throws SQLException { // 读取方向:数据库字符串 -> Java Map return parseToMap(rs.getString(columnName)); } @Override public Map<String, Object> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parseToMap(rs.getString(columnIndex)); } @Override public Map<String, Object> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parseToMap(cs.getString(columnIndex)); } private Map<String, Object> parseToMap(String json) { try { return MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {}); } catch (Exception e) { throw new RuntimeException("JSON解析失败: " + json, e); } } }这段骨架别看简单,其实已经覆盖了99%自定义Handler的模板结构。后面所有更复杂的Handler——加密的、枚举的、JSON数组的——都是在这个结构上填充自己的转换规则而已。
3. 手写一个完整自定义TypeHandler:以JSON字段为例
3.1 先把需求和选型想清楚
JSON字段是目前自定义Handler最常见的入口。比如商品表里有一列ext_info,存的是活动标签、价格区间、推荐理由这类不定长扩展信息。你要是把这列在实体类里声明成String,每次读取出来都还得手动JSON.parseObject;声明成Map,又没法直接用MyBatis默认映射。
我的建议是:业务频繁读写的JSON扩展字段,用自定义Handler转成具体泛型类型;只是偶尔读一下的,可以在Mapper层用@Result配合现有工具类转换。两个方案没有绝对优劣,但前者代码更整洁,后者少写Handler类。既然文章主题是Handler,下面按前者的完整方案讲。
序列化工具我用的Jackson,也是Spring Boot默认的JSON库。这里有个小细节:如果你的项目里已经配置了ObjectMapper的全局序列化规则(比如日期格式、Null值处理、Long转String),Handler里的ObjectMapper最好和Spring容器里的是同一个,避免两边序列化规则不一致带来诡异问题。推荐通过Spring注入而不是自己new:
@Component public class JacksonMapTypeHandler extends BaseTypeHandler<Map<String, Object>> { private final ObjectMapper objectMapper; public JacksonMapTypeHandler(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } // 后续实现同上 }3.2 实现带泛型推断的JSON Handler
如果是固定类型,直接像2.3节那样写就行。但实际项目中,JSON字段的类型往往不止Map一种,可能是List<Sku>,可能是某个ActivityConfig对象。这时候我们有两种选择:
选择一:为每个类型都写一个Handler。代码重复但简单直接。 选择二:写一个泛型Handler,利用Jackson的JavaType做反序列化。
我推荐选择二,因为可维护性好很多。实现思路是通过构造函数接收目标类型,然后在读取时用objectMapper.readValue(json, javaType):
public class JacksonTypeHandler<T> extends BaseTypeHandler<T> { private final ObjectMapper objectMapper; private final JavaType javaType; public JacksonTypeHandler(Class<T> type) { this(new ObjectMapper(), type); } public JacksonTypeHandler(ObjectMapper objectMapper, Class<T> type) { this.objectMapper = objectMapper; this.javaType = objectMapper.getTypeFactory().constructType(type); } @Override public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, objectMapper.writeValueAsString(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 T parse(String json) { try { return objectMapper.readValue(json, javaType); } catch (IOException e) { throw new RuntimeException("JSON转换失败", e); } } }这里有个容易踩的坑:泛型Handler在MyBatis-Plus中做注册时,框架会通过反射推断JavaType。如果构造函数里只有Class<T>一个参数,MyBatis-Plus能识别;如果用了多个参数或者没有默认构造,部分版本可能注册失败。后面第4章会专门说这个问题。
3.3 在单独使用MyBatis时如何注册
如果你不是在Spring Boot环境,而是纯MyBatis项目,注册Handler有三种方式,按优先级排列:
- XML的
resultMap里通过typeHandler属性指定 - 全局配置文件
mybatis-config.xml里的<typeHandlers>标签 - SQL语句里的
#{param, typeHandler=xxx}
XML场景最典型的是mapper文件里这样写:
<resultMap id="productMap" type="com.example.entity.Product"> <id property="id" column="id"/> <result property="extInfo" column="ext_info" typeHandler="com.example.handler.JacksonTypeHandler"/> </resultMap>如果项目里所有Map<String,Object>属性的实体都要用同一个Handler处理,更省事的方式是全局注册:
<typeHandlers> <typeHandler handler="com.example.handler.JacksonTypeHandler" javaType="java.util.Map"/> </typeHandlers>这样只要Java属性类型是Map,MyBatis就会自动找这个Handler来处理,不用每个resultMap都写一遍。
还有一种是SQL参数级指定,适用于零散的单个字段:
<insert id="insertProduct"> INSERT INTO product (id, ext_info) VALUES (#{id}, #{extInfo, typeHandler=com.example.handler.JacksonTypeHandler}) </insert>三种方式的使用场景不同:resultMap精确控制每个字段,适合同一个字段在不同查询里有不同转换需求的场景;全局注册省事,适合全项目统一规则的场景;SQL参数级指定最灵活,但侵入性强,写多了SQL很难维护。我的习惯是:统一的项目规范用全局注册,特殊情况用resultMap覆盖,SQL参数级这种能不用就不用。
3.4 在Spring Boot + MyBatis-Plus中的注册方式
MyBatis-Plus继承了MyBatis的TypeHandler体系,所以前面讲的所有机制依然成立。但因为MyBatis-Plus自动化程度更高,注册方式又多了一条路——注解。
最常见的写法是直接在实体字段上加@TableField注解,指定typeHandler:
@TableName("product") public class Product { @TableId(type = IdType.AUTO) private Long id; @TableField(typeHandler = JacksonTypeHandler.class) private Map<String, Object> extInfo; }这里有个细节值得一提:MyBatis-Plus官方文档里,JSON字段的Handler还被要求开启@TableName(autoResultMap = true)。原因在于,MP的BaseMapper内置方法(如selectById、selectList)生成的SQL映射默认不读取实体上的typeHandler注解。只有开启autoResultMap = true,MP才会扫描注解并生成对应的resultMap。如果你不开启,insert写参数时能正常用Handler,select查出来extInfo却是null,这个坑我见人踩过好多次。
@TableName(value = "product", autoResultMap = true) public class Product { // ... }如果没有加autoResultMap = true,自定义Handler在自定义SQL里挺正常,一旦用了MP内置CRUD方法,查询结果就是null。排查链路后面还会讲,这里先记住这个关键字。
4. MyBatis-Plus场景下的注册细节:@TableField与自动填充的配合
4.1 typeHandler属性与自动填充的坑
MyBatis-Plus里很多项目会配一个元对象处理器(MetaObjectHandler)做插入/更新时自动填充创建时间、更新时间、操作人等字段。自动填充和TypeHandler配合的时候,有一个非常隐蔽的坑:
自动填充发生在真正执行SQL之前,是在实体对象层面操作字段值。如果你的自动填充字段本身也需要TypeHandler转换(比如填一个JSON对象),那填充器里set的值必须是你Handler能处理的Java类型。换句话说,Handler作用于PreparedStatement参数赋值阶段,而自动填充作用于更早的实体属性赋值阶段。两个机制是串行而非并行,很多人在填充器里set了一个字符串,以为Handler会再转一次JSON,结果Handler拿到"成功转成了JSON字符串的字符串",再序列化一次就变成双引号嵌套的脏数据。
解决办法也简单:自动填充的时候,直接set最终想要的那个Java类型(比如对象或Map),让Handler只负责序列化这一次,不要重复转换。
4.2 条件构造器中的类型转换陷阱
再来说一个MP特有的场景:QueryWrapper里用eq、in这些条件去查一个带有自定义TypeHandler的字段。
MP的QueryWrapper在构建条件时,是通过反射判断字段值类型,然后交给MyBatis参数处理器去绑定参数。绝大多数情况下,Wrapper里的值会作为普通参数传给PreparedStatement,但它不会自动套用实体字段上的typeHandler注解。什么意思呢?比如你加密存储了手机号,实体上有字段级TypeHandler做加密,你写queryWrapper.eq("phone", plainText),MP并不会先加密再比较,最终SQL比较的是明文字符串和数据库里的密文,查了个寂寞。
遇到这种需求,常规做法有两种:
- 在条件里手动调用加密方法:
queryWrapper.eq("phone", encryptUtil.encrypt(plainText)),简单粗暴但依赖业务层记得调用。 - 在Mapper自定义SQL,使用
#{phone, typeHandler=...}来指定加密Handler。这样条件值也会走Handler转换,SQL层更统一。
我的建议是第二种,因为第一种方案一旦有一个调用点漏了加密,线上就会出现数据匹配不上的问题,排查成本非常高。
4.3 自定义TypeHandler时的自动注册次序
Spring Boot环境下,MyBatis-Plus会自动扫描配置的type-handlers-package下的Handler类,并注册到MybatisConfiguration中。这里要注意一个次序问题:
Handler注册为Bean时,如果你在Handler构造器里依赖了Spring的ObjectMapper(通过构造注入),那么Handler的注册时机必须在ObjectMapper Bean创建之后。Spring Boot自动配置会处理大部分情况,但如果你自己写了一个@Configuration类提前new了Handler,可能会拿到一个未配置序列化规则的原生ObjectMapper。
排查这类问题最直接的办法是:在Handler注册的日志里打出ObjectMapper的类名和配置状态,或者干脆把Handler也交给Spring管理(标@Component),由Spring负责依赖装配,再让MP注册这些Spring Bean。这样ObjectMapper初始化顺序由Spring容器保证,问题基本不会再出现。
5. 实战案例升级:带AES加密的敏感字段Handler
5.1 需求场景和设计思路
JSON Handler只是热身,真正体现TypeHandler价值的是敏感字段加密。我接手过一个跨境商城的项目,用户的手机号、邮箱、身份证都必须加密落库,但业务层希望CRUD时直接操作明文,加密解密逻辑全部下沉到ORM层。这样Service代码不用关心加密细节,也能保证所有访问敏感字段的路径都经过加密(前提是字段只通过Mapper访问)。
设计思路是这样的:
- 数据库字段类型:VARCHAR,存AES加密后的Base64字符串
- Java实体属性类型:String,业务代码里是明文
- 写入时:Handler对明文做AES加密 -> Base64编码 -> ps.setString
- 读取时:rs.getString拿到密文 -> Base64解码 -> AES解密 -> 返回明文
- 密钥来源:从配置中心读取,Handler初始化时注入,避免硬编码在代码里
5.2 完整实现代码
@Component public class AesEncryptTypeHandler extends BaseTypeHandler<String> { private final AesUtil aesUtil; public AesEncryptTypeHandler(AesUtil aesUtil) { this.aesUtil = aesUtil; } @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 写入:明文 -> 密文 ps.setString(i, aesUtil.encrypt(parameter)); } @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return decrypt(rs.getString(columnName)); } @Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return decrypt(rs.getString(columnIndex)); } @Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return decrypt(cs.getString(columnIndex)); } private String decrypt(String cipherText) { if (cipherText == null || cipherText.isEmpty()) { return cipherText; } return aesUtil.decrypt(cipherText); } }注意几个实现细节:
- 空值判断放在解密方法里,Base64解码空字符串会直接数组越界。读取方向上,
setNonNullParameter只处理非null,但读取方向getNullableResult却可能拿到null,所以在这里主动判断。 - 密钥和向量不要写在Handler类里,通过配置类注入,方便后续轮换密钥。我在生产环境就吃过硬编码密钥的亏——换密钥要改代码重新发布,而用配置中心的方案直接改配置再刷新就行。
- 解密异常要区分业务异常和脏数据。如果数据库里混入了一条非加密的明文历史数据,解密必然失败。我建议在
decrypt方法里捕获异常后返回原文并打WARN日志,而不是直接抛异常让查询崩溃。具体策略看团队要求,但一定要有处理预案。
5.3 加密字段在查询条件中的处理
加了Handler的加密字段,读取和写入都没有问题了,但条件查询又是另一个故事——数据库里存的是密文,你拿明文去WHERE phone = ?,肯定匹配不上。
简单方案是把加密也应用到查询条件参数上。但这里有个更复杂的场景:模糊查询。AES加密后的密文没有可搜索性,你没法对密文做LIKE '%keyword%'。如果业务确实有模糊搜索敏感字段的需求,常规做法有:
- 增加一个明文搜索辅助字段(如
sms_phone_plain),专门用来做模糊查询,加密字段只做精确匹配。这是目前业务系统用得最多的方案,代价是冗余存储。 - 用AES的确定性加密模式(如AES-GCM-SIV)保持同一明文加密结果一致,可以支持精确查询,但仍然不支持模糊查询。
- 引入专门的可搜索加密方案,复杂度较高,一般项目没必要。
我强烈建议第一套方案,简单务实。模糊查询功能在合规和安全之间本来就是一道权衡题,先用辅助字段支撑业务才是性价比最高的路线。
6. 踩坑实录与完整排查链路
6.1 最常见的四类TypeHandler问题
前面每章都提了一些坑,这里汇总一下我遇到过的最高频问题,按出现频率排序:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
插入/更新失效,报No typehandler found for property xxx | 类型注册不完整,全局没有扫描到Handler | 确认type-handlers-package配置,或@TableField(typeHandler=...)显式指定 |
| 查询返回null,insert却是好的 | MP内置方法没有读取字段注解 | 实体类@TableName加autoResultMap = true |
查询报错,Can't set value或ClassCastException | Handler返回类型与实体属性类型不一致 | 检查Handler泛型类型与实体字段申明是否一致 |
报错JSON parse error: Illegal character | 数据库里有脏数据或历史明文数据 | 清洗数据,或在Handler里做兼容处理 |
6.2 一条完整的排查链路:从insert生效到select为null
这里把6.1表格里第二个问题展开,写一条真实的排查链路,方便大家以后按图索骥。
场景:Spring Boot + MyBatis-Plus项目,给Product.extInfo字段配了JacksonTypeHandler。调用productMapper.insert(product),数据库里JSON正常写入;调用productMapper.selectById(1L),返回的实体extInfo字段是null。
排查步骤:
确认查询走的是MP内置方法还是自定义SQL。如果是内置方法,先怀疑
autoResultMap。我在代码里搜@TableName,果然只写了@TableName("product"),没有写autoResultMap = true。加上之后重新启动,问题消失。如果加了
autoResultMap还是null,接下来查Handler是否被Spring容器管理。在Handler构造函数里打个日志或断点,看getNullableResult有没有被调用。没被调用说明映射根本没走到Handler。确认resultMap是否生成且正确引用handler。可以开启MyBatis SQL日志(MP配置
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl),看执行的SQL和返回映射是否符合预期。确认Handler泛型类型与字段类型一致。比如实体里
extInfo是Map<String, Object>,而Handler实现的是List<Long>,框架在映射时会因为类型不一致直接跳过或报错。
这条链路走完,大部分MP自定义Handler查询为null的问题都能定位到根因。
6.3 规范建议:Handler类命名、包结构与单元测试
最后说点工程规范层面的经验。
Handler类的命名我建议统一加TypeHandler或Handler后缀,让人一眼能看出类型。比如JsonMapTypeHandler、AesEncryptTypeHandler,不要起CustomJsonHandler这种模棱两可的名字。包结构上,放在com.xxx.common.handler或者com.xxx.config.mybatis.handler下面,集中管理。
单元测试一定要写,因为这个组件是基础设施,出了问题影响面非常大。最少要覆盖:
- 写入SQL后PreparedStatement里被赋的值是否符合预期(可以用Mockito或MyBatis的
SqlSession做集成测试) - 从ResultSet读取转换后类型是否正确
- 空字符串、null、null值、非法JSON字符串四种边界情况
我习惯把Handler的转换逻辑抽出一个纯静态方法或者独立的工具类,这样测试不需要启动Spring容器,直接对方法做断言,又快又稳。这种设计也方便其他业务类直接复用同一个转换逻辑,保持全项目加密/JSON规则一致。
TypeHandler虽然只是MyBatis里一个小小的接口,但真正用好了,能让实体模型干净很多、业务层省掉大量重复代码。从我这些年接手的项目来看,凡是后期在字段映射上频繁出问题的,基本都是前期没有在Handler层统一设计,把转换逻辑散落在Service和SQL片段里。花一下午把项目里JSON字段、加密字段、枚举字段的Handler一次性梳理清楚,后续能省下好几天的排查时间,这笔账怎么算都值。