2217 字
约 7 分钟
15
S08-X07 · 类型处理器与枚举映射:从 TypeHandler 到自定义

数据库里的 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);
    }
  }
}

三个要点:

  1. 序列化异常必须转成 SQLException 抛出,不能返回 null——返回 null 会让"字段丢失"伪装成"数据本来为空"。
  2. @MappedTypes(List.class) 会匹配所有 List 字段。要么用包装类型(JsonList<String>)避免误伤,要么只在该字段上用 <result typeHandler>。
  3. 存 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. 自测

  1. BaseTypeHandler 的四个抽象方法分别对应什么场景?漏实现一个会怎样?
  2. EnumOrdinalTypeHandler 在枚举中间插入新值时,历史数据为什么全部错位且没有报错?
  3. @MappedJdbcTypes(includeNullJdbcType = true) 解决的是什么匹配问题?
  4. 全局注册的 handler 与 <result typeHandler> 冲突时谁生效?
  5. 参数为 null 时,setNonNullParameter 会被调用吗?为什么?
  6. Spring 的 Converter 为什么无法替代 TypeHandler?两者各管哪一段?
S08-X07 · 类型处理器与枚举映射:从 TypeHandler 到自定义
https://www.clxhxhhr.top/posts/5827/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。