手写 MyBatis 通用枚举 TypeHandler

手写 MyBatis 通用枚举 TypeHandler

手写 MyBatis 通用枚举 TypeHandler 开发中的痛点 在我们开发中,枚举在数据库字段是 TINYINT类型,而在Java中是 Enum类型。如果不书写转换器或者手动转换,Mybatis就没办法正常将两者映射。 而在我们日常开发中经常会采用以下: 手动 if-else/switch,来

手写 MyBatis 通用枚举 TypeHandler

开发中的痛点

在我们开发中,枚举在数据库字段是 TINYINT类型,而在Java中是 Enum类型。如果不书写转换器或者手动转换,Mybatis就没办法正常将两者映射。

而在我们日常开发中经常会采用以下:

  • 手动 if-else/switch,来强行转换。

    • 缺点: 麻烦,代码冗余,不易维护
  • 为每个枚举类书写一个专门转换器

    • 缺点: 系统中枚举类一旦很多,代码将会及其冗余。

BaseTypeHandler

实际上,Mybatis提供了 BaseTypeHandler抽象类,它实现了 TypeHandler接口,是一种模板方法设计模式,专门用于自定义JDBC类型与Java类型之间的转换。

  • TypeHandler:规定了所有类型处理器必须有的 4 个方法
  • BaseTypeHandler:实现了 TypeHandler接口。实现了null空值下的通用逻辑,同时将非null非空值状态下的核心具体转换逻辑,定义为4个抽象方法,并对外公开。

BaseTypeHandler包含了以下抽象方法:

public abstract void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;

public abstract T getNullableResult(ResultSet rs, String columnName) throws SQLException;

public abstract T getNullableResult(ResultSet rs, int columnIndex) throws SQLException;

public abstract T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException;

仔细看,你会发现很多常见的API(如果你学过JDBC,而不是只学习了Mybatis)

PreparedStatement、ResultSet、CallableStatement等,都是JDBC核心的API。

setNonNullParameter

  • 功能:将 Java 对象的非空属性值设置到 PreparedStatement 中对应的 ? 占位符上
    • 学过JDBC都知道,PreparedStatement执行 预编译的 SQL 语句,并允许使用 ?占位符,运行时再动态绑定参数值
  • setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType)
    • 参数解析
      • int i:当前参数在SQL语句中的占位符位置(从1开始)
      • T parameter:Java对象中非空的属性值(类型为泛型T)
      • JdbcType jdbcType:可选的JDBC类型,用于精确控制数据库类型
      • PreparedStatement ps:已经预编译的PreparedStatement,SQL语句含 ?占位符。

getNullableResult

  • 功能:从数据库返回的结果集(ResultSet或CallableStatement)中,根据列名(或列索引)取出非空的值,并将其转换为对应的 Java 类型。
    • ResultSet是数据库查询结果集,支持按列名和索引来获取数据
    • CallableStatement用于调用数据库中的存储过程,支持处理存储过程的输入参数、输出参数以及返回结果
getNullableResult(ResultSet rs, String columnName)
getNullableResult(ResultSet rs, int columnIndex)
getNullableResult(CallableStatement cs, int columnIndex)
  • 参数解析
    • ResultSet rs:数据库查询结果集
    • String columnName:列名
    • int columnIndex:列索引
    • CallableStatement cs:存储过程调用

手撕枚举类通用转换器

了解了 BaseTypeHandler接口,我们就可以开始书写枚举类通用转换器。

自定义专用接口

为了区分需要与数据库交互的枚举类和正常的枚举类,且让Mybatis更精确地识别到是需要与数据库交互的枚举类,我们要定义一个接口:

/**
 * @author zhuxi
 * @apiNote 枚举接口
 * <p>
 *     需要持久化到数据库的枚举类需要实现此接口
 * </p>
 */
public interface BaseEnum {
    int getCode();
}

让需要与数据库交互的枚举类实现该接口

/**
 * @author zhuxi
 * @apiNote 风险等级枚举类
 */
@Getter
@AllArgsConstructor
public enum RiskLevel implements BaseEnum {
    LOW(0,"安全"),
    MIDDLE(1,"低风险"),
    HIGH(2,"高风险");

    private final int code;
    private final String value;

    //根据code获取枚举值 (自行实现)
    public static RiskLevel fromCode(int code) {}
   
}

枚举类通用转换器

因为是针对枚举类的通用转换器,所以我们必须确认对应的Java类型必须是 Enum且实现了 BaseEnum接口

public class UniversalEnumHandler<E extends Enum<E> & BaseEnum> extends BaseTypeHandler<E> {}

如上述代码,我们既保证了是枚举类(Enum),又要求其必须实现 BaseEnum接口。这样要处理的Java类型就精确到那些需要与数据库交互的枚举类了。

TypeReference

接下里,我们需要介绍一下 TypeReference抽象类。接下来会讲到一些源码的东西,这是为了让你们明白后续代码为什么这么写,当然看不懂也无所谓,后续代码基本也算是固定模板了。

实际上,BaseTypeHandler 也继承了 TypeReference 抽象类。而 TypeReference核心作用获取到 BaseTypeHandler<Class>中的Class类型,并将其与转换器建立映射存在map中 方便后续使用。TypeReference源码这里不做赘述,感兴趣可自行查阅。

我们都知道 Java 的泛型在运行时会被“擦除”。但在 MyBatis 的类型转换中,框架必须精准地知道 Java 的具体类型,才能选择正确的策略去转换。而当我们书写通用转换器时,一定用的是泛型,那 TypeReference是获取不到泛型类型的。

既然泛型被擦除了,MyBatis 是怎么锁定具体类型的?

  • 这就引出了我们平时写的专用转换器和通用转换器之间的核心区别。

  • 专用类型转换器

public class UniversalEnumHandler extends BaseTypeHandler<RiskLevel> {}
  • TypeReference是可以正常获取到类型的,紧接着将该类型与转换器建立映射存入Map中,方便后续使用。

通用类型转换器

public class UniversalEnumHandler<E extends Enum<E> & BaseEnum> extends BaseTypeHandler<E> {}
  • TypeReference无法获取到类型(泛型被擦除)。
  • 所以主要依赖 TypeHandlerRegistry来进行手动注册。TypeHandlerRegistry的源码中有个 getInstance方法。基于此方法可以得出 TypeHandlerRegistry回去调用通用类型转换器的:
    • 单 Class 参数构造器
      • 通过该构造器获取到Class类型。
    • 无参构造器
      • 如果没有顺利使用单 Class 参数构造器,则会使用无参构造器。但也基本意味着该转换器已经等同虚设,后续执行到具体Sql时,就会报错。

注意上述到现在,我们所讲都并未涉及到Spring!!! 不要错误解读。

因为通用类型转换器天然不适合交给Spring管理(Spring的Bean默认单例),所以这里并没有多说。

通用枚举处理器的核心实现

public class UniversalEnumHandler<E extends Enum<E> & BaseEnum> extends BaseTypeHandler<E> {

    private final Class<E> type;

    public UniversalEnumHandler(Class<E> type) {
        if (type == null){
            throw new IllegalArgumentException("Type argument cannot be null");
        }
        this.type = type;
    }
  
    // 将int整型与枚举类E的变量进行匹配并转换为枚举类
    private E convert(int code) {
        for (E each : type.getEnumConstants()) {
            if (each.getCode() == code) {
                return each;
            }
        }

        throw new IllegalArgumentException("Cannot convert " + code + " to " + type.getSimpleName() + " by ordinal value.");
    }
}

了解过刚才讲过的一些关于源码的东西后,上述代码就很容易理解了。

实现4个抽象方法

@Override
    public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {
        ps.setInt(i, parameter.getCode());
    }

    @Override
    public E getNullableResult(ResultSet rs, String columnName) throws SQLException {
        return convert(rs.getInt(columnName));
    }

    @Override
    public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        return convert(rs.getInt(columnIndex));
    }

    @Override
    public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        return convert(cs.getInt(columnIndex));
    }

这四个方法没什么好讲的。

设置为默认枚举类转换器

这里配置文件设置,我们统一用的SpringBoot。因为在springBoot的项目里,基本都使用SpringBoot的配置文件。

mybatis:
  configuration:
    default-enum-type-handler: com.dayang.constant.UniversalEnumHandler

注意,这里强烈推荐上述这种设置方法!

如果是下述这种注册方法(不建议)

mybatis:
  type-handlers-package: com.dayang.constant

那你就需要注意可能会出现以下问题。

  • SpringBoot启动后,Mybatis会根据该路径去扫描该包。

  • 进而发现 UniversalEnumHandler,然后会调用 TypeHandlerRegistryregister(Class<?> typeHandlerClass)方法去注册 UniversalEnumHandler

  • 要注册它肯定要先实例化它,而register又不知道其具体类名。

    • 所以它就调用了内部的 getInstance(Class? javaTypeClass,Class<?> typeHandlerClass)方法
    • 但是由于此时,Java对象都没实例化,且也不知道具体类型。
    • 所以javaTypeClass会被填充为null,进入getInstance方法后就触发了以下分值
      • try {
                    c = typeHandlerClass.getConstructor();
                    return (TypeHandler)c.newInstance();
                } catch (Exception var4) {
                    e = var4;
                    throw new TypeException("Unable to find a usable constructor for " + typeHandlerClass, e);
                }
        
    • 可以看出这里调用的是无参构造器。而我们本来就是想通过单 Class 参数构造器来构造的,所以根本就没有书写无参构造器。
    • 最终直接SpringBoot启动失败。

测试

@Test
    void contextLoads() {
        UserDTO userInfo = userInnerApi.getUserInfo(1700000000000000001L);
        System.out.println(userInfo);
    }

输出:

==>  Preparing: SELECT u.id AS userId, u.nickName, u.avatar_url, u.risk_level, u.is_enabled, u.is_distributor, ua.auth_status AS isRealNamed, ua.real_name FROM t_user_base u LEFT JOIN t_user_auth ua ON u.id = ua.user_id WHERE u.id = ?
==> Parameters: 1700000000000000001(Long)
<==    Columns: userId, nickName, avatar_url, risk_level, is_enabled, is_distributor, isRealNamed, real_name
<==        Row: 1700000000000000001, 系统总工, https://oss.dayang.com/avatar/admin.jpg, 0, 1, 0, 1, 张总工
<==      Total: 1
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@375ff309]
UserDTO(userId=1700000000000000001, nickName=系统总工, avatarUrl=https://oss.dayang.com/avatar/admin.jpg, riskLevel=LOW, isEnabled=true, isDistributor=false, isRealNamed=AUTHENTICATED, realName=张**)
Notion-笔记、文档、项目管理协同一体化平台 2026-03-27
Jakarta Bean Validation 自定义校验,实现 "自解释" 的角色编码校验 2026-07-10

评论区