手写 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 语句,并允许使用?占位符,运行时再动态绑定参数值
- 学过JDBC都知道,
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时,就会报错。
- 单 Class 参数构造器
注意上述到现在,我们所讲都并未涉及到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,然后会调用TypeHandlerRegistry的register(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=张**)