MyBatis 类型处理器:TypeHandler 原理、自定义类型处理器与枚举映射(105)

MyBatis、JPA与ORM约 11 分钟阅读5,850 字符
知识库文档 106 / 251

引言:为什么 字段能自动映射为

MyBatis 从 ResultSet 中读取 create_time 列的值时,调用的是 ResultSet.getTimestamp() 还是 ResultSet.getObject()?答案是:取决于 TypeHandler。TypeHandler 是 MyBatis 中连接"JDBC 类型"和"Java 类型"的桥梁——它负责从 ResultSet 中读取数据并转换为 Java 对象(getResult),以及将 Java 对象设置到 PreparedStatement 中(setParameter)。这篇文章从 TypeHandler 的接口设计到 MyBatis 内置的 30+ 个类型处理器,再到自定义类型处理器和枚举映射的四种方式,完整拆解 MyBatis 的类型处理机制。

一、TypeHandler 接口与工作原理

文章配图
文章配图

文章配图
文章配图

核心接口

java
public interface TypeHandler<T> {
    // 设置 PreparedStatement 参数
    void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;

    // 从 ResultSet 按列名获取结果
    T getResult(ResultSet rs, String columnName) throws SQLException;

    // 从 ResultSet 按列索引获取结果
    T getResult(ResultSet rs, int columnIndex) throws SQLException;

    // 从存储过程 CallableStatement 获取结果
    T getResult(CallableStatement cs, int columnIndex) throws SQLException;
}

BaseTypeHandler 抽象类

MyBatis 提供了 BaseTypeHandler<T> 抽象类,它实现了 TypeHandler<T> 接口,并处理了 null 值的判断逻辑。自定义 TypeHandler 通常继承 BaseTypeHandler 并重写四个抽象方法:

java
public abstract class BaseTypeHandler<T> extends TypeReference<T> implements TypeHandler<T> {
    @Override
    public void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
        if (parameter == null) {
            if (jdbcType == null) {
                ps.setNull(i, JdbcType.NULL.TYPE_CODE);  // 没有指定 jdbcType 时用 NULL
            } else {
                ps.setNull(i, jdbcType.TYPE_CODE);       // 指定了 jdbcType 时用对应的类型
            }
        } else {
            setNonNullParameter(ps, i, parameter, jdbcType);  // 非 null 时委托给子类
        }
    }
    // getResult 类似,null 检查后委托给子类
}

类型处理的完整链路

  1. 参数设置:ParameterHandler.setParameters() → 遍历参数列表 → 查找对应的 TypeHandler → 调用 setParameter() → PreparedStatement.setXxx()
  2. 结果读取:ResultSetHandler.handleResultSets() → 遍历 ResultSet 列 → 查找对应的 TypeHandler → 调用 getResult() → ResultSet.getXxx() → 转换为 Java 类型

TypeHandler 的注册与查找

MyBatis 内置了 30+ 个 TypeHandler,注册在 Configuration.typeHandlerRegistry 中。查找逻辑:先按 javaType + jdbcType 精确匹配,找不到则按 javaType 匹配,再找不到则尝试用"父类型"匹配(如 LocalDateTime 找不到时尝试 Object)。

二、MyBatis 内置的 TypeHandler 分类

文章配图
文章配图

类别 Java 类型 JDBC 类型 内置 TypeHandler
基本类型 String VARCHAR/CHAR StringTypeHandler
基本类型 Integer/int INTEGER IntegerTypeHandler
基本类型 Long/long BIGINT LongTypeHandler
基本类型 Boolean/boolean BOOLEAN BooleanTypeHandler
基本类型 BigDecimal DECIMAL BigDecimalTypeHandler
日期时间 java.util.Date TIMESTAMP DateTypeHandler
日期时间 java.sql.Date DATE DateOnlyTypeHandler
日期时间 java.sql.Time TIME TimeOnlyTypeHandler
日期时间 LocalDateTime TIMESTAMP LocalDateTimeTypeHandler
日期时间 LocalDate DATE LocalDateTypeHandler

| 二进制 | byte[ ] | BLOB/BINARY | BlobTypeHandler |

| 枚举 | Enum | VARCHAR/INTEGER | EnumTypeHandler/EnumOrdinalTypeHandler |

枚举类型的两种内置处理方式

EnumTypeHandler:将枚举的 name()(如 ACTIVE)存入数据库。优点:可读性好。缺点:枚举重命名后数据库值不匹配。

EnumOrdinalTypeHandler:将枚举的 ordinal()(如 0)存入数据库。优点:存储紧凑。缺点:枚举顺序改变后映射错乱,不推荐。

三、自定义 TypeHandler

文章配图
文章配图

文章配图
文章配图

场景:将 JSON 字段映射为 Java 对象

java
public class JsonTypeHandler<T> extends BaseTypeHandler<T> {
    private final Class<T> type;

    public JsonTypeHandler(Class<T> type) {
        this.type = type;
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) 
            throws SQLException {
        ps.setString(i, JsonUtil.toJson(parameter));
    }

    @Override
    public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String json = rs.getString(columnName);
        return json == null ? null : JsonUtil.fromJson(json, type);
    }

    @Override
    public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String json = rs.getString(columnIndex);
        return json == null ? null : JsonUtil.fromJson(json, type);
    }

    @Override
    public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String json = cs.getString(columnIndex);
        return json == null ? null : JsonUtil.fromJson(json, type);
    }
}

注册自定义 TypeHandler

方式一:在 mybatis-config.xml 中注册:

xml
<typeHandlers>
    <typeHandler handler="com.example.JsonTypeHandler" javaType="com.example.Address"/>
</typeHandlers>

方式二:在 resultMap 中指定:

xml
<result column="address" property="address" 
    javaType="com.example.Address"
    typeHandler="com.example.JsonTypeHandler"/>

方式三:Spring Boot 中通过 mybatis.type-handlers-package 自动扫描:

yaml
mybatis:
  type-handlers-package: com.example.typehandler

四、枚举映射的四种方式

文章配图
文章配图

文章配图
文章配图

方式一:EnumTypeHandler(默认)

java
public enum Status { ACTIVE, INACTIVE }
// 存入: "ACTIVE", 读取: Status.ACTIVE

方式二:EnumOrdinalTypeHandler

java
public enum Status { ACTIVE, INACTIVE }  // ordinal: 0, 1
// 存入: 0, 读取: Status.ACTIVE
// 危险: 如果在 ACTIVE 前面插入 PENDING, PENDING 变成 0, ACTIVE 变成 1, 数据错乱

方式三:自定义枚举属性映射

java
public enum Status {
    ACTIVE(1, "激活"),
    INACTIVE(0, "未激活");

    private final int code;
    private final String desc;

    Status(int code, String desc) { this.code = code; this.desc = desc; }
    public int getCode() { return code; }

    public static Status fromCode(int code) {
        for (Status s : values()) {
            if (s.code == code) return s;
        }
        throw new IllegalArgumentException("Unknown code: " + code);
    }
}

// 自定义 TypeHandler
public class StatusTypeHandler extends BaseTypeHandler<Status> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Status p, JdbcType jt) 
            throws SQLException {
        ps.setInt(i, p.getCode());
    }

    @Override
    public Status getNullableResult(ResultSet rs, String columnName) throws SQLException {
        int code = rs.getInt(columnName);
        return Status.fromCode(code);
    }
    // ... 其他 getNullableResult 方法类似
}

方式四:MyBatis-Plus 的 @EnumValue 注解

java
public enum Status {
    ACTIVE(1), INACTIVE(0);
    @EnumValue
    private final int code;
    Status(int code) { this.code = code; }
    public int getCode() { return code; }
}

MyBatis-Plus 自动扫描 @EnumValue 注解,注册对应的 TypeHandler,无需手动配置。这是最简洁的方式,但需要引入 MyBatis-Plus 依赖。

五、常见陷阱

文章配图
文章配图

陷阱一:未指定 jdbcType 导致 null 值报错

xml
<insert id="insert">
  INSERT INTO user (name, create_time) VALUES (#{name}, #{createTime})
</insert>

如果 createTime 为 null,MyBatis 不知道应该调用 ps.setNull(i, Types.TIMESTAMP) 还是 ps.setNull(i, Types.NULL)。如果未指定 jdbcType,MyBatis 默认用 Types.NULL,某些数据库(如 Oracle)不支持 Types.NULL,会报错。解决方案:#{createTime, jdbcType=TIMESTAMP}。

陷阱二:自定义 TypeHandler 未注册导致不生效

自定义 TypeHandler 必须显式注册(XML 配置、resultMap 中指定、或 Spring Boot 自动扫描),否则 MyBatis 不会使用它。最常见的排查盲区:在 resultMap 中指定了 javaType 但未指定 typeHandler,MyBatis 会用内置的 TypeHandler 而不是你自定义的。

陷阱三:LocalDateTime 和 Date 的 TypeHandler 混淆

MyBatis 3.4.5+ 内置了 LocalDateTimeTypeHandler,但需要 mybatis-typehandlers-jsr310 依赖(或 MyBatis 3.5+ 内置)。如果 classpath 上没有这个依赖,LocalDateTime 会回退到 ObjectTypeHandler,导致类型转换错误。排查方法:检查 Configuration.typeHandlerRegistry 中是否注册了 LocalDateTimeTypeHandler。

陷阱四:枚举反序列化失败时静默返回 null

EnumTypeHandler 在数据库中遇到不匹配的枚举名时(如 UNKNOWN),Enum.valueOf() 会抛出 IllegalArgumentException。MyBatis 的默认行为是记录警告日志并返回 null——而不是抛异常。这意味着数据库中插入了一个非法枚举值后,应用不会报错,但查询结果中该字段为 null。如果业务逻辑依赖枚举非 null,这会导致隐蔽的 NPE。

小结:TypeHandler 是 JDBC 和 Java 之间的翻译官

TypeHandler 本质上封装了"JDBC 类型的 get/set"和"Java 类型的转换"两个操作。理解 TypeHandler 后,你就能解释:为什么 LocalDateTime 能自动映射(LocalDateTimeTypeHandler)、为什么 JSON 字段可以直接映射为 Java 对象(自定义 JsonTypeHandler)、为什么枚举映射有四种方式(各有取舍)。TypeHandler 是 MyBatis 中"最容易被忽视但最实用的扩展点"——自定义 TypeHandler 可以解决字段加密、JSON 序列化、敏感信息脱敏等一系列实际问题,而不需要在上层业务代码中做转换。

(以上正文阅读时长约 40-50 分钟,以下问答部分不计入正文阅读时长)

文末答疑:面试官会怎么问

问 1:MyBatis 的**TypeHandler**和 JDBC 的**setObject()/getObject()**有什么区别?为什么 MyBatis 不直接用 JDBC 的自动类型映射?

答:JDBC 的 setObject()/getObject() 依赖 JDBC 驱动的默认类型映射,这个映射是"驱动层面"的,不同数据库驱动行为可能不一致。MyBatis 的 TypeHandler 是"框架层面"的,提供了统一的类型映射规则,不受驱动差异影响。更重要的是,TypeHandler 支持自定义——你可以把 VARCHAR 列映射为 Address 对象(通过 JsonTypeHandler),这是 JDBC 的 setObject() 做不到的。TypeHandler 在"JDBC 类型"和"Java 类型"之间插入了自定义转换逻辑,而 JDBC 的自动映射是"直连"的,没有转换空间。

问 2:你如何设计一个"加密 TypeHandler"——让数据库中的敏感字段加密存储,但应用层看到的始终是明文?

答:继承 BaseTypeHandler<String>,在 setNonNullParameter 中调用加密函数将明文转为密文存入数据库,在 getNullableResult 中读取密文并解密为明文返回。关键点:加密密钥不应该硬编码在 TypeHandler 中,应该通过配置注入(如 @Value 或环境变量)。但 TypeHandler 不是 Spring Bean,不能用 @Autowired 注入——需要通过 mybatis-config.xml 的 <typeHandler> 配置或在 Configuration 初始化时手动设置。另外,加密字段不能用于数据库的 WHERE 条件(因为数据库中的值是密文,WHERE name = '张三' 查不到),这是设计上的重要限制。

问 3:**EnumTypeHandler**和**EnumOrdinalTypeHandler**有什么本质区别?为什么说**EnumOrdinalTypeHandler**是危险的?

答:EnumTypeHandler 存储枚举的 name()(字符串),EnumOrdinalTypeHandler 存储枚举的 ordinal()(整数,从 0 开始)。EnumOrdinalTypeHandler 危险的原因是:ordinal() 的值取决于枚举的声明顺序。如果你在 ACTIVE 前面插入一个新枚举值 PENDING,ACTIVE 的 ordinal 从 0 变成 1,数据库中所有之前存储的 0 现在映射为 PENDING 而不是 ACTIVE——数据错乱且没有报错。EnumTypeHandler 用 name() 存储,枚举重命名也会导致问题,但至少"插入新枚举"不会改变已有枚举的 name。工程上建议用自定义的 code 属性(如 int code)来映射,而不是依赖 ordinal() 或 name()。

问 4:如果你的项目中同时使用了 MyBatis 和 MyBatis-Plus,MyBatis-Plus 的**@EnumValue**注解和 MyBatis 原生的**EnumTypeHandler**会冲突吗?

答:不会冲突,但 MyBatis-Plus 会覆盖 MyBatis 的默认行为。MyBatis-Plus 在启动时通过 MybatisSqlSessionFactoryBean 扫描所有带 @EnumValue 注解的枚举类,自动注册对应的 TypeHandler。这个注册的优先级高于 MyBatis 原生的 EnumTypeHandler。如果某个枚举类有 @EnumValue,MyBatis-Plus 的 TypeHandler 生效;如果没有,回退到 MyBatis 原生的 EnumTypeHandler。两者共存的典型场景:项目中大部分枚举用 @EnumValue 映射 code,少数简单枚举(不需要 code 映射)用默认的 EnumTypeHandler。

问 5:**TypeHandler**在**setParameter**时,如果**jdbcType**为 null 且参数值也为 null,MyBatis 怎么处理?这会有什么问题?

答:BaseTypeHandler.setParameter() 中,当 jdbcType == null 且 parameter == null 时,调用 ps.setNull(i, JdbcType.NULL.TYPE_CODE)。JdbcType.NULL.TYPE_CODE 是 java.sql.Types.NULL,值为 0。MySQL 的 JDBC 驱动可以接受 Types.NULL,但 Oracle 的 JDBC 驱动会报错 java.sql.SQLException: Invalid column type。解决方案:在 SQL 中显式指定 jdbcType,如 #{createTime, jdbcType=TIMESTAMP}。MyBatis 3.4+ 支持在全局配置 jdbcTypeForNull 来指定默认的 null 类型,如 <setting name="jdbcTypeForNull" value="NULL"/>(MySQL 适用)或 value="VARCHAR"(Oracle 适用)。