MyBatis TypeHandler 泛型踩坑指南

简介: 背景为了支持数据库字段与复杂 Java 类型之间的转换,最近我所参与的项目中使用到了 MyBatis TypeHandler,由于 MyBatis 设计问题,如果为同一个泛型类的不同参数类型创建多个 TypeHandler,后面注册的 TypeHandler 会将前面注册的 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 字段中。当前数据库记录如下。


image.png


用户表对应的 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、List 与数据库 VARCHAR 类型之间转换,我们定义两个 TypeHandler 类,并将其注册到 MyBatis 中。


VARCHAR 与 List 之间转换的 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 之间转换的 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);
    }
}


VARCHARList 之间转换的 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 处理 VARCHAR 与 List 之间的转换,而MyBatis 错误的选择了StringListTypeHandler。


问题分析


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


在 StringListTypeHandler 打断点后可以看到调用栈如下。


image.png


由于我们在 xml mapper 文件中配置的是 resultType,因此 MyBatis 只会选用自动映射的方式处理数据库字段与 Java 字段之间的映射。跟踪调用栈中的自动映射方法

DefaultResultSetHandler#applyAutomaticMappings 如下。


image.png


TypeHandler 由DefaultResultSetHandler#createAutomaticMappings 方法返回的 UnMappedColumnAutoMapping 列表指定。跟踪此方法如下。


2.png


至此,我们可以发现 TypeHandler 是根据 Class 和 JdbcType 从注册中心获取,而 Class 是一个原始类型,并不包含自身的泛型参数的具体类型。可以推测,MyBatis 在注册 TypeHandler 时也是使用原始类型 Class 和 JdbcType 进行注册,因此后注册的 StringListTypeHandler 覆盖了先注册的 IntegerListTypeHandler,从而导致 MyBatis 错误的选择了 TypeHandler。


问题解决


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


image.png


这个 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 不支持泛型类型的问题进行处理。


目录
相关文章
|
10天前
|
Java 数据库连接 数据库
【微服务】mybatis typehandler使用详解
自定义 `TypeHandler` 的能力使得 MyBatis 在处理特定的数据类型转换时更加灵活和强大,为在微服务架构中构建与数据库交互逻辑提供了极大的便利。它允许我们灵活处理多样化的数据格式,满足业务不断变化的需求。
20 2
|
2月前
|
存储 Java 数据库连接
mybatis精讲(三)--标签及TypeHandler使用
mybatis精讲(三)--标签及TypeHandler使用
|
2月前
|
Java 数据库连接 数据库
MyBatis TypeHandler详解:原理与自定义实践
MyBatis TypeHandler详解:原理与自定义实践
|
3月前
|
SQL Java 数据库连接
Mybatis如何通过泛型来动态调整返回参数
Mybatis如何通过泛型来动态调整返回参数
277 0
|
3月前
|
存储 Java 数据库连接
MyBatis的类型处理器TypeHandler与自定义实现
MyBatis的类型处理器TypeHandler与自定义实现
304 0
|
XML Java 关系型数据库
【SpringBoot + Mybatis系列】自定义类型转换 TypeHandler| 8月更文挑战
在使用 mybatis 进行 db 操作的时候,我们经常会干的一件事情就是将 db 中字段映射到 java bean,通常我们使用ResultMap来实现映射,通过这个标签可以指定两者的绑定关系,那么如果 java bean 中的字段类型与 db 中的不一样,应该怎么处理呢?
820 0
【SpringBoot + Mybatis系列】自定义类型转换 TypeHandler| 8月更文挑战
|
XML 前端开发 Java
面试官问:Mybatis中的TypeHandler你用过吗?
面试官问:Mybatis中的TypeHandler你用过吗?
|
SQL Java 数据库连接
MyBatis + Spring 基于SqlSessionDaoSupport的泛型基类
引官网一段话: SqlSessionDaoSupport 是 一 个 抽象 的支 持 类, 用来 为你 提供 SqlSession 。
1265 0
|
SQL 缓存 Java
深挖 Mybatis 源码:TypeHandler,反射工具包!
关于 Mybatis 源码系列,前面已经介绍了缓存模块,这篇文章里,我们来看看 Mybatis 反射工具箱和 TypeHandler 系列
162 0
|
XML 设计模式 缓存
MyBatis源码解析之基础模块—TypeHandler
MyBatis源码解析之基础模块—TypeHandler
1902 0