数据库里的 varchar 'PAID' 与 Java 里的 OrderStatus.PAID 之间隔着 TypeHandler。这层转换平时透明,出错时只有两种形态:写进去和读出来不一致,或同一类型在别的字段上被顺带改了语义。枚举映射尤其如此——三种存法的差别在"改一次代码"之后才暴露。
1. 职责与三条注册路径
TypeHandler 干一件事:Java 类型 ↔ JDBC 类型的双向转换。写方向把对象塞进 PreparedStatement,读方向从 ResultSet 取值并还原成对象。
| 注册方式 | 写法 | 生效范围 |
|---|---|---|
| 包扫描 | mybatis.type-handlers-package=com.demo.handler |
该包下所有带 @MappedTypes 的 handler,全局 |
| 注解 | @MappedTypes(X.class) + @MappedJdbcTypes(JdbcType.VARCHAR) |
同上,靠注解声明匹配范围 |
| XML 显式 | <result ... typeHandler="com.demo.handler.XxxHandler"/> |
只作用于该字段 |
| 参数内联 | #{status, typeHandler=com.demo.handler.XxxHandler} |
只作用于该参数 |
mybatis:
type-handlers-package: com.demo.handler
configuration:
default-enum-type-handler: org.apache.ibatis.type.EnumTypeHandler
包扫描最省事也最危险:它给该 Java 类型的所有字段套上同一个 handler,包括你不想改的那些。
2. 枚举的三种存法
| 存法 | 配置 | 库里长什么样 | 风险 |
|---|---|---|---|
存 name() |
EnumTypeHandler(3.4.5 起默认) |
'PAID' |
改枚举常量名 → 历史数据读不出 |
存 ordinal() |
EnumOrdinalTypeHandler |
0,1,2 |
枚举顺序调整或中间插值 → 全部错乱 |
| 存自定义 code | 自定义 TypeHandler |
'10' 或 'PAID' 等业务码 |
需要自己维护 code 映射表 |
<!-- 存序号:注册后对该类型全局生效 -->
<typeHandlers>
<typeHandler handler="org.apache.ibatis.type.EnumOrdinalTypeHandler"
javaType="com.demo.enums.OrderStatus"/>
</typeHandlers>
EnumOrdinalTypeHandler 的风险不是理论问题:只要有人在枚举中间插一个新值,后面常量的 ordinal 全部后移,历史数据立刻指向错误枚举且没有报错。它只适合"枚举永不扩展"的场景。
EnumTypeHandler 的问题相反:库里存字符串,重命名常量后旧值找不到对应枚举,读取抛 IllegalArgumentException(Enum.valueOf 抛出后被 MyBatis 包装)。两种都疼,所以多数团队选第三种:显式 code。
3. 自定义 TypeHandler:四个方法
@MappedTypes(OrderStatus.class)
@MappedJdbcTypes(value = JdbcType.VARCHAR, includeNullJdbcType = true)
public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, OrderStatus p, JdbcType jt)
throws SQLException {
ps.setString(i, p.getCode()); // 写方向:只处理非 null
}
@Override
public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
return OrderStatus.of(rs.getString(columnName)); // 读方向:列名
}
@Override
public OrderStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return OrderStatus.of(rs.getString(columnIndex)); // 读方向:下标
}
@Override
public OrderStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return OrderStatus.of(cs.getString(columnIndex)); // 存储过程出参
}
}
BaseTypeHandler 已把 null 判断包好了:setParameter 遇到 null 走 ps.setNull(i, jdbcType),非 null 才调 setNonNullParameter。三个 getNullableResult 分别对应按列名取值、按下标取值、从 CallableStatement 取值——四个都要实现,漏一个就在对应场景返回 null。
OrderStatus.of(code) 要处理"库里是脏值":返回 null 还是抛异常是业务决定。抛异常能立刻暴露问题,但一条脏数据会让整个查询失败;返回 null 更宽容,但脏值会伪装成字段缺失。
4. includeNullJdbcType=true 为什么必要
@MappedJdbcTypes 声明"只处理 VARCHAR"。但查询时 MyBatis 不一定拿得到列的 JDBC 类型:ResultSetMetaData 在部分视图、联合查询或包装过的 ResultSet 上会返回 null。类型未知时默认规则下你的 handler 不被选中,回退到默认处理。
includeNullJdbcType = true 表示"类型未知也算匹配"。缺了它,症状是"同一字段有的查询走了 handler、有的没走"——极难查,因为它取决于具体 SQL 与驱动。
<resultMap id="UserMap" type="com.demo.entity.User">
<result column="order_status" property="orderStatus"
typeHandler="com.demo.handler.OrderStatusTypeHandler"/>
</resultMap>
5. 优先级:显式声明 > 全局注册
| 声明位置 | 优先级 | 说明 |
|---|---|---|
#{} 里的 typeHandler= |
最高 | 只影响该参数 |
<result> / <id> 的 typeHandler |
高 | 只影响该字段,覆盖全局 |
@MappedTypes 全局注册 |
中 | 按 Java 类型 + JDBC 类型匹配 |
内置 handler(如 StringTypeHandler) |
最低 | 兜底 |
这解释了一个常见困惑:全局注册了 handler,某个字段还是老行为——那个字段的 resultMap 里显式写了 typeHandler;或者你想覆盖却写在了错误的 <result> 上。
建议:全局注册只管"整个项目都该这么转"的类型(枚举、JSON),个别字段的特殊转换用 <result typeHandler> 就地声明。
6. JSON 字段映射
MySQL 8.0 有 json 列类型,但用 varchar 存 JSON 字符串更常见。两种都可以用自定义 handler 收口:
@MappedTypes(List.class)
@MappedJdbcTypes(value = JdbcType.VARCHAR, includeNullJdbcType = true)
public class StringListTypeHandler extends BaseTypeHandler<List<String>> {
private static final ObjectMapper M = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<String> p, JdbcType jt)
throws SQLException {
try {
ps.setString(i, M.writeValueAsString(p));
} catch (JsonProcessingException e) {
throw new SQLException("JSON 序列化失败", e); // 别吞
}
}
@Override
public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String raw = rs.getString(columnName);
if (raw == null) return null;
try {
return M.readValue(raw, new TypeReference<List<String>>() {});
} catch (IOException e) {
throw new SQLException("JSON 反序列化失败: " + raw, e);
}
}
}
三个要点:
- 序列化异常必须转成
SQLException抛出,不能返回 null——返回 null 会让"字段丢失"伪装成"数据本来为空"。 @MappedTypes(List.class)会匹配所有List字段。要么用包装类型(JsonList<String>)避免误伤,要么只在该字段上用<result typeHandler>。- 存
json列时 MySQL 会规范化键顺序与空白,比对原始字符串会不一致——要比对就用varchar。
7. 空值与 jdbcType
setNull 必须带 JDBC 类型,MyBatis 默认 jdbcTypeForNull=OTHER:
| 场景 | 默认行为 | 需要显式指定吗 |
|---|---|---|
| MySQL,参数为 null | 发 setNull(i, OTHER),驱动能接受 |
一般不需要 |
| Oracle,参数为 null | OTHER 报 ORA-17004: 无效的列类型 |
必须写 #{name,jdbcType=NULL} |
| 自定义 handler 写 null | BaseTypeHandler 走 setNull,用配置的类型 |
同上 |
mybatis:
configuration:
jdbc-type-for-null: 'NULL' # 全局把默认值改成 NULL
<!-- 或按参数单独指定 -->
<insert id="insert">
insert into t_user (name, remark) values (#{name}, #{remark,jdbcType=VARCHAR})
</insert>
jdbcType 与 TypeHandler 是两件事:前者决定 null 用什么类型发过去,后者决定非 null 值怎么转换。自定义 handler 的 setNonNullParameter 看不到 null——null 在父类就被拦掉了,所以"handler 没处理 null"不是 bug。
8. 与 Spring 的 Converter 不要混
| 维度 | MyBatis TypeHandler |
Spring Converter |
|---|---|---|
| 作用位置 | JDBC 参数绑定与结果映射 | MVC 参数绑定、ConversionService |
| 触发时机 | 每次 SQL 执行 | 每次 HTTP 参数/路径变量解析 |
| 影响数据库值 | 是 | 否 |
| 典型用途 | 枚举 ↔ code、JSON ↔ 对象 | String → 枚举/DTO 字段 |
常见误解:注册了 StringToOrderStatusConverter 就以为 mapper 能把 'PAID' 转成枚举。不能——Converter 只管 HTTP 入口,管不到 JDBC 出口。
两者并存:Controller 靠 Converter 把请求参数转成枚举,MyBatis 靠 TypeHandler 把枚举存成 code。缺一个就会出现"接口能收、查出来是 null"。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
查询报 IllegalArgumentException: No enum constant |
库里存的是旧枚举名/脏值 | 用 code 存法,或做兼容映射 |
写进去 0/1/2,读出来枚举错位 |
用了 EnumOrdinalTypeHandler 且枚举顺序变过 |
改成 code 或 name |
| 同一字段有时走 handler、有时不走 | 缺 includeNullJdbcType=true |
补注解属性 |
ORA-17004: 无效的列类型 |
null 参数用了 jdbcTypeForNull=OTHER |
配 jdbc-type-for-null: NULL 或参数内联 jdbcType |
| 字段静默变 null | handler 里 catch 异常后返回了 null | 转成 SQLException 抛出 |
| handler 完全不生效 | 包扫描路径写错,或没加 @MappedTypes |
检查 type-handlers-package 与注解 |
10. 边界
| 需求 | 更合适的选择 |
|---|---|
| 枚举存业务码 | 自定义 TypeHandler + code 字段 |
| 枚举只在本服务用、永不改名 | EnumTypeHandler(存 name),成本最低 |
| 单个字段特殊转换 | <result typeHandler> 就地声明,不动全局 |
JSON 存进 json 列 |
自定义 handler + ps.setString,MySQL 自动校验 |
一句话:TypeHandler 是"值怎么进数据库",Converter 是"请求怎么进对象"。搞混这两层,问题会以"数据对不上"的形式出现,而不是以报错的形式出现。
11. 自测
BaseTypeHandler的四个抽象方法分别对应什么场景?漏实现一个会怎样?EnumOrdinalTypeHandler在枚举中间插入新值时,历史数据为什么全部错位且没有报错?@MappedJdbcTypes(includeNullJdbcType = true)解决的是什么匹配问题?- 全局注册的 handler 与
<result typeHandler>冲突时谁生效? - 参数为 null 时,
setNonNullParameter会被调用吗?为什么? - Spring 的
Converter为什么无法替代TypeHandler?两者各管哪一段?