MyBatis TypeHandler generic stepping guide

background

In order to support the conversion between database fields and complex Java types, MyBatis TypeHandler is used in the project I participated in recently. Due to MyBatis design issues, if you create multiple TypeHandlers for different parameter types of the same generic class, the TypeHandler registered later The previously registered TypeHandler will be overwritten, which will cause errors, so here is a summary and provide some solutions to other partners.

TypeHandler basics

TypeHandler introduced

Among the persistence layer frameworks in the Java field, because Hibernate is not flexible enough, MyBatis or Spring-JDBC are currently the most used. Both frameworks can write SQL and configure the mapping relationship between database table fields and Java class fields.

When dealing with the mapping relationship, in addition to considering the mapping between field names, you also need to consider the conversion relationship between database table field types and Java field types.

In MyBatis, the conversion between database type and Java type is handled by TypeHandler. TypeHandler can set parameters to PreparedStatement in an appropriate way, or convert database field values ​​from ResultSet to appropriate Java type values.

MyBatis has built-in conversion relationships between some commonly used types, and the conversion between custom Java classes and database types requires users to register a custom TypeHandler in MyBatis.

TypeHandler registration

When registering TypeHandler with MyBatis, you need to provide a class that implements TypeHandler.

First look at the definition of the TypeHandler interface.

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;
}

Two types of methods are provided in TypeHandler, one is to set parameters to PreparedStatement, and the other is to get values ​​from ResultSet. Take the built-in StringTypeHandler implementation of MyBatis as an example for analysis.

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 implements the BaseTypeHandler class. The BaseTypeHandler class is a base class of TypeHandler. It simply encapsulates the TypeHandler. Our custom TypeHandler implements the BaseTypeHandler class.

Custom TypeHandler

Assuming that our database table uses VARCHAR to save the configuration of properties, in order to convert between VARCHAR and Properties, we can customize the following 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;
    }
}

Register a custom TypeHandler

In a non-SpringBoot environment, we need to register TypeHandler in the xml configuration file. The specific example is as follows.

<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>

There are two ways to configure TypeHandler through xml. The first way can specify a specific TypeHandler, and the second way can specify the package name where the TypeHandler is located.

Then there are unavoidable questions. How do I only provide the package name MyBatis to know which Java types and JDBC types can be handled by the TypeHandler under this package?

In fact, MyBatis provides two annotations @MappedJdbcTypes and @MappedTypes to specify the JDBC type and Java type handled by the TypeHandler respectively. Just add these two annotations to the custom TypeHandler class.

The javaType/jdbcType configured in the xml has a higher priority than the annotations. Since MyBatis can obtain the actual types in the generics, it is no problem to only use @MappedJdbcTypes in the TypeHandler. Therefore, if you register TypeHandler by providing the package name, you can modify our custom TypeHandler as follows.

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

In SpringBoot environment, we can introduce mybatis-spring-boot-starter dependent, this time directly follows the Spring application.proerties profile:
mybatis.type-handlers-package=com.zzuhkp.blog.typehandler.

Question leads

Through the previous content, we know that if the database field corresponds to a complex type we defined, we need to register TypeHandler with MyBatis.

Assume that the database has a user table as follows.

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`)
)

In order to control permissions, we save the resource and role information in the form of a json array into the resource_codes and role_ids fields, respectively. The current database records are as follows.

Insert picture description here


The Java types corresponding to the user table are as follows.

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

Since the user type field resourceCodes roleIds and complex type, in order to List<Integer>, List<String>databases and VARCHARbetween type conversion, we define two classes TypeHandler, and register it in MyBatis.

VARCHARAnd List<Integer>conversion between the TypeHandler follows.

@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);
    }
}

VARCHARAnd List<String>conversion between the TypeHandler follows.

@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);
    }
}

Abstract user-related database operations to the UserMapper class, the code is as follows.

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

The xml file corresponding to UserMapper is as follows.

<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>

We may query users' needs based on ID. The test code is as follows.

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());
    }
}

The console printing code is as follows.

{"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)

The problem has arisen. The type of the role ID list stored in the user class defined by us is integer. By printing the content, we found that it has become a string type, and we threw an ClassCastExceptionexception when we tried to get the role ID stored in the role ID list . in other words we want to use the IntegerListTypeHandlerprocessing VARCHARand List<Integer>conversion between, and MyBatis wrong choice StringListTypeHandler.

problem analysis

In order to solve the problem, the next step we need to analyze why MyBatis chose the wrong TypeHandler? How did MyBatis choose the TypeHandler? Is it not registered normally or the wrong choice after registration?

After the StringListTypeHandler break point, you can see the call stack as follows.

Insert picture description here

Since we configure the resultType in the xml mapper file, MyBatis will only use the automatic mapping method to process the mapping between database fields and Java fields. The automatic mapping method in the trace call stack is DefaultResultSetHandler#applyAutomaticMappingsas follows. The TypeHandler is specified

Insert picture description here

by DefaultResultSetHandler#createAutomaticMappingsthe UnMappedColumnAutoMapping list returned by the method. Track this method as follows.

Insert picture description here

At this point, we can find TypeHandler based Classand JdbcTypeobtained from the registry, while Class is a primitive type, does not contain its own specific type of generic parameters. Presumably, MyBatis also use the original type when registering TypeHandler Classand JdbcTypeto register, so after registration StringListTypeHandler covers IntegerListTypeHandler to register, resulting in MyBatis wrong choice TypeHandler.

problem solved

As the TypeHandler obtained from the TypeHandlerRegistry lost the generic information during the automatic mapping process, the correct TypeHandler could not be found normally. As a mature open source framework, MyBatis should have a relatively large number of users. Therefore, the first reaction is to check from Baidu whether anyone else has encountered the same problem, but Baidu did not give an answer. At this time, I turned my attention to the issue of mybatis on github . By querying the issue, it was found that others had indeed encountered the same problem. The screenshot of the issue part is as follows.

Insert picture description here

This issue was submitted on February 25, 21. Harawata, one of the members of the MyBatis project, replied on March 11 that this is a known defect, but there is no time to modify it recently. As of the time of posting, this issue is still open.

Modifying the MyBatis source code will definitely solve the problem, but using the modified MyBatis source code in order to use TypeHandler is a fuss. Is there any other solution?

As long as automatic mapping is used, MyBatis must not be able to select the TypeHandler correctly. We know that MyBatis provides a manual mapping method, as long as we configure the resultMap in the mapper xml file, and the resultMap can specify the TypeHandler that can be used.

Modify the mapper xml file used in our test as follows.

<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>

Execute our test method again, and the printed result is as follows.

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

At this time, MyBatis uses the manually specified TypeHandler, and the problem is solved.

So why can MyBatis find the correct TypeHandler at this time? Analyzing the source code of MyBatis parsing the mapper xml file, it is found that MyBatis calls the following method to obtain the 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;
  }
}

At this time, it is obtained according to the specific type of the TypeHandler we specified. The TypeHandlerRegistry will use the Class corresponding to the TypeHandler as the key for all the registered TypeHandlers, and the TypeHandler instance will be cached in the field allTypeHandlersMap of the type Map as the value. If it has been registered, it will be directly obtained from the registered TypeHandler; otherwise, the TypeHandler instance will be obtained through reflection instantiation.

to sum up

If we register different TypeHandlers for the same generic type, then the TypeHandler registered later will overwrite the TypeHandler registered first when using automatic mapping. At this point, we can manually specify typeHandler in resultMap and use resultMap instead of resultType to temporarily solve it. I believe that in future versions, MyBatis will deal with the problem that TypeHandler does not support generic types.