MyBatis TypeHandler 泛型踩坑指南

背景

为了支持数据库字段与复杂 Java 类型之间的转换,最近我所参与的项目中使用到了 MyBatis TypeHandler,由于 MyBatis 设计问题,如果为同一个泛型类的不同参数类型创建多个 TypeHandler,后面注册的 TypeHandler 会将前面注册的 TypeHandler 覆盖,从而引发错误,因此这里做一篇总结,并提供给其他小伙伴一些解决思路。

TypeHandler 基础知识

TypeHandler 引入

Java 领域的持久层框架中,由于 Hibernate 不够灵活,目前使用最多的是 MyBatis 或 Spring-JDBC,这两个框架都可以编写 SQL ,配置数据库表字段和 Java 类字段之间的映射关系。

处理映射关系时,除了考虑字段名称之间的映射,还需要考虑数据库表字段类型与 Java 字段类型之间的转换关系。

MyBatis 中,数据库类型和 Java 类型之间的转换由 TypeHandler 来处理。TypeHandler 可以以合适的方式向 PreparedStatement 中设置参数,或从 ResultSet 中将数据库字段值转换为合适的 Java 类型值。

MyBatis 已经内置了一些常用的类型之间的转换关系,而自定义的 Java 类与数据库类型之间的转换则需要用户向 MyBatis 中注册自定义的 TypeHandler。

TypeHandler 注册

向 MyBatis 注册 TypeHandler 时需要提供一个实现了 TypeHandler 的类。

先看 TypeHandler 接口的定义。

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;
    T getResult(ResultSet rs, int columnIndex) throws SQLException;
    T getResult(CallableStatement cs, int columnIndex) throws SQLException;
}

TypeHandler 中提供了两种类型的方法,一类是向 PreparedStatement 中设置参数,一类是从 ResultSet 中获取值。以 MyBatis 内置的 StringTypeHandler 实现为例进行分析。

public class StringTypeHandler extends BaseTypeHandler<String> {

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

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

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex)
        throws SQLException {
        return rs.getString(columnIndex);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex)
        throws SQLException {
        return cs.getString(columnIndex);
    }
}

StringTypeHandler 实现了 BaseTypeHandler 类,BaseTypeHandler 类是一个 TypeHandler 的基类,它对 TypeHandler 做了简单的封装,我们自定义的 TypeHandler 实现 BaseTypeHandler 类即可。

自定义 TypeHandler

假设我们数据库表使用 VARCHAR 形式保存了 properties 形式的配置,为了在 VARCHAR 和 Properties 之间进行转换,我们可以自定义如下的 TypeHandler。

public class PropertiesTypeHandler extends BaseTypeHandler<Properties> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Properties parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, this.prop2Str(parameter));
    }

    @Override
    public Properties getNullableResult(ResultSet rs, String columnName) throws SQLException {
        return this.str2prop(rs.getString(columnName));
    }

    @Override
    public Properties getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        return this.str2prop(rs.getString(columnIndex));
    }

    @Override
    public Properties getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        return this.str2prop(cs.getString(columnIndex));
    }

    private String prop2Str(Properties properties) {
    	// 省略 Properties 转 String 代码
        return null;
    }

    private Properties str2prop(String str) {
    	// 省略 String 转 Properties 代码
        return null;
    }
}

注册自定义 TypeHandler

非 SpringBoot 环境下,我们需要在 xml 配置文件中注册 TypeHandler,具体示例如下。

<typeHandlers>
    <!--配置方式一:指定 TypeHandler 及处理的 Java 类型、JDBC 类型-->
    <typeHandler handler="com.zzuhkp.blog.typehandler.PropertiesTypeHandler" javaType="java.lang.String" jdbcType="VARCHAR"/>
    <!--配置方式二:指定 TypeHandler 所在的包名-->
    <package name="com.zzuhkp.blog.typehandler"/>
</typeHandlers>

通过 xml 配置 TypeHandler 有两种方式,第一种方式可以指定具体的 TypeHandler,第二种方式指定 TypeHandler 所在的包名即可。

那么不免会有一些疑问,仅提供包名 MyBatis 如何知道这个包下的 TypeHandler 可以处理哪些 Java 类型与 JDBC 类型呢?

事实上 MyBatis 提供了两个注解 @MappedJdbcTypes、@MappedTypes 分别用来指定 TypeHandler 处理的 JDBC 类型与 Java 类型,将这两个注解添加到自定义的 TypeHandler 类上即可。

xml 中配置的 javaType/jdbcType 优先级高于注解,由于 MyBatis 可以获取泛型中的实际类型,因此在 TypeHandler 只使用 @MappedJdbcTypes 也是没有问题的。 因此,如果通过提供包名的方式注册 TypeHandler,可以修改我们自定义的 TypeHandler 如下。

@MappedJdbcTypes(JdbcType.VARCHAR)
public class PropertiesTypeHandler extends BaseTypeHandler<Properties> {
	// 省略部分代码
}

在 SpringBoot 环境下,我们可以引入 mybatis-spring-boot-starter 依赖,此时直接在 Spring 的 application.proerties 配置文件中进行如下配置:
mybatis.type-handlers-package=com.zzuhkp.blog.typehandler

问题引出

通过前面的内容,我们知道,如果数据库字段对应的是一个我们定义的复杂类型,我们就需要向 MyBatis 中注册 TypeHandler。

假定数据库有一个用户表,如下。

CREATE TABLE `user` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `username` varchar(100) '用户名',
  `password` varchar(100) COMMENT '密码',
  `role_ids` varchar(255) COMMENT '角色ID',
  `resource_codes` varchar(255) COMMENT '资源编号',
  `create_time` datetime COMMENT '创建时间',
  PRIMARY KEY (`id`)
)

为了控制权限,我们将资源和角色信息以 json 数组的形式分别保存到 resource_codes、role_ids 字段中。当前数据库记录如下。
在这里插入图片描述
用户表对应的 Java 类型如下。

@Data
public class UserPO {
    private Integer id;
    private String username;
    private String password;
    private List<Integer> roleIds;
    private List<String> resourceCodes;
}

由于用户类的 roleIds 和 resourceCodes 字段为复杂类型,为了将 List<Integer>List<String> 与数据库 VARCHAR 类型之间转换,我们定义两个 TypeHandler 类,并将其注册到 MyBatis 中。

VARCHARList<Integer> 之间转换的 TypeHandler 如下。

@MappedJdbcTypes(JdbcType.VARCHAR)
public class IntegerListTypeHandler extends BaseTypeHandler<List<Integer>> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, List<Integer> parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, JSONObject.toJSONString(parameter));
    }

    @Override
    public List<Integer> getNullableResult(ResultSet rs, String columnName) throws SQLException {
        return this.str2List(rs.getString(columnName));
    }

    @Override
    public List<Integer> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        return this.str2List(rs.getString(columnIndex));
    }

    @Override
    public List<Integer> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        return this.str2List(cs.getString(columnIndex));
    }

    private List<Integer> str2List(String str) {
        if (StrUtil.isBlank(str)) {
            return null;
        }
        return JSONObject.parseArray(str, Integer.class);
    }
}

VARCHARList<String> 之间转换的 TypeHandler 如下。

@MappedJdbcTypes(JdbcType.VARCHAR)
public class StringListTypeHandler extends BaseTypeHandler<List<String>> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, JSONObject.toJSONString(parameter));
    }

    @Override
    public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException {
        return this.str2List(rs.getString(columnName));
    }

    @Override
    public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        return this.str2List(rs.getString(columnIndex));
    }

    @Override
    public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        return this.str2List(cs.getString(columnIndex));
    }

    private List<String> str2List(String str) {
        if (StrUtil.isBlank(str)) {
            return null;
        }
        return JSONObject.parseArray(str, String.class);
    }
}

将用户相关的数据库操作,抽象到 UserMapper 类,代码如下。

public interface UserMapper {
    UserPO selectById(@Param("id") Integer id);
}

UserMapper 对应的 xml 文件如下。

<mapper namespace="com.zzuhkp.blog.mybatis.mapper.UserMapper">
    <select id="selectById" resultType="com.zzuhkp.blog.mybatis.entity.UserPO">
        select * from user where id = #{id}
    </select>
</mapper>

我们可能会有根据ID查询用户的需求,测试代码如下。

public class App {
    public static void main(String[] args) throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
        UserPO userPO = userMapper.selectById(1);
        System.out.println(JSONObject.toJSONString(userPO));
        System.out.printf("roleId type %s \n", userPO.getRoleIds().get(0).getClass());
        System.out.printf("resourceCode type %s \n", userPO.getResourceCodes().get(0).getClass());
    }
}

控制台打印代码如下。

{"id":1,"password":"123456","resourceCodes":["resource1","resource2"],"roleIds":["1","2"],"username":"hkp"}
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
	at com.zzuhkp.blog.mybatis.App.main(App.java:29)

问题出现了,用户类中的我们定义的角色ID列表存储的类型为整型,通过打印内容我们发现变成了字符串类型,并且我们试图获取角色ID列表存储的角色ID时抛出了ClassCastException异常,也就是说我们希望使用 IntegerListTypeHandler 处理 VARCHARList<Integer> 之间的转换,而MyBatis 错误的选择了StringListTypeHandler

问题分析

为了解决问题,下一步我们就需要分析 MyBatis 为何选择了错误的 TypeHandler ? MyBatis 到底是如何选择 TypeHandler 的呢?是未正常注册还是注册后选择错误?

在 StringListTypeHandler 打断点后可以看到调用栈如下。
在这里插入图片描述由于我们在 xml mapper 文件中配置的是 resultType,因此 MyBatis 只会选用自动映射的方式处理数据库字段与 Java 字段之间的映射。跟踪调用栈中的自动映射方法DefaultResultSetHandler#applyAutomaticMappings 如下。
在这里插入图片描述TypeHandler 由DefaultResultSetHandler#createAutomaticMappings 方法返回的 UnMappedColumnAutoMapping 列表指定。跟踪此方法如下。
在这里插入图片描述至此,我们可以发现 TypeHandler 是根据 ClassJdbcType 从注册中心获取,而 Class 是一个原始类型,并不包含自身的泛型参数的具体类型。可以推测,MyBatis 在注册 TypeHandler 时也是使用原始类型 ClassJdbcType 进行注册,因此后注册的 StringListTypeHandler 覆盖了先注册的 IntegerListTypeHandler,从而导致 MyBatis 错误的选择了 TypeHandler。

问题解决

由于自动映射处理时从 TypeHandlerRegistry 获取 TypeHandler 丢失了泛型信息,因此无法正常找到正确的 TypeHandler。MyBatis 作为一个成熟的开源框架用户量应该比较大,因此第一反应是从百度查询是否有其他人遇到过相同问题,然而百度也未给出答案。这时候我把目光转向了 github 上 mybatis 的 issue。通过查询 issue 发现其他人确实遇到过相同问题。issue 部分内容截图如下。

在这里插入图片描述这个 issue 在21年2月25提交,MyBatis 项目的成员之一 harawata 在3月11回复表示这是一个已知的缺陷,然而最近没有时间修改。截止到发文时间,这个 issue 仍然处于 open 状态。

修改 MyBatis 源码必然可以解决问题,然而为了使用 TypeHandler 使用修改过的 MyBatis 源码则显得小题大做。到底还有没有其他的解决方案呢?

只要使用自动映射,那么 MyBatis 必然无法正确选择 TypeHandler 。我们知道,MyBatis 提供了手动映射的方式,只要我们在 mapper xml 文件中配置 resultMap 即可,而 resultMap 中是可以指定使用的 TypeHandler。

修改我们测试使用的 mapper xml 文件如下。

<mapper namespace="com.zzuhkp.blog.mybatis.mapper.UserMapper">
    <resultMap id="BaseResultMap" type="com.zzuhkp.blog.mybatis.entity.UserPO">
        <result column="role_ids" property="roleIds" typeHandler="com.zzuhkp.blog.mybatis.typehandler.IntegerListTypeHandler"/>
        <result column="resource_codes" property="resourceCodes" typeHandler="com.zzuhkp.blog.mybatis.typehandler.StringListTypeHandler"/>
    </resultMap>

    <select id="selectById" resultMap="BaseResultMap">
        select * from user where id = #{id}
    </select>
</mapper>

再次执行我们的测试方法,打印结果如下。

{"id":1,"password":"123456","resourceCodes":["resource1","resource2"],"roleIds":[1,2],"username":"hkp"}
roleId type class java.lang.Integer 
resourceCode type class java.lang.String 

此时,MyBatis 使用了手动指定的 TypeHandler,问题得到解决。

那么此时 MyBatis 为什么又能找到正确的 TypeHandler 呢?分析 MyBatis 解析 mapper xml 文件的源码,发现 MyBatis 调用了如下的方法来获取 TypeHandler。

public abstract class BaseBuilder {
  protected TypeHandler<?> resolveTypeHandler(Class<?> javaType, Class<? extends TypeHandler<?>> typeHandlerType) {
    if (typeHandlerType == null) {
      return null;
    }
    // javaType ignored for injected handlers see issue #746 for full detail
    TypeHandler<?> handler = typeHandlerRegistry.getMappingTypeHandler(typeHandlerType);
    if (handler == null) {
      // not in registry, create a new one
      handler = typeHandlerRegistry.getInstance(javaType, typeHandlerType);
    }
    return handler;
  }
}

这时是根据我们指定的 TypeHandler 的具体类型获取,TypeHandlerRegistry 会将所有注册过的 TypeHandler 以 TypeHandler 对应的 Class 作为 key,TypeHandler 实例作为 value 缓存到类型为 Map 的字段 allTypeHandlersMap 中。如果已注册则从注册的 TypeHandler 直接获取,否则则通过反射实例化获取 TypeHandler 实例。

总结

如果我们为同一个泛型类型注册了不同的 TypeHandler,那么在使用自动映射时后注册的 TypeHandler 会将先注册的 TypeHandler 覆盖。此时我们可以手动在 resultMap 中指定 typeHandler ,并使用 resultMap 替代 resultType 来临时解决,相信在未来的版本中 MyBatis 内部将会对 TypeHandler 不支持泛型类型的问题进行处理。

  • 7
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大鹏cool

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值