详解MyBatis中的TypeHandler

2,466 阅读17分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情

大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈


前言

一. 背景

在数据库的使用中,一个Java对象的字段有其Java类型,比如String类型,当这个Java对象存储到数据库中后,其字段在数据库中也需要以相应的Jdbc类型来存储,比如VARCHAR。同理,当从数据库中查询出一条数据时也需要将Jdbc类型转换成Java类型才能将这条数据映射成一个Java对象。Java类型到Jdbc类型和Jdbc类型到Java类型的转换,MyBatis框架提供了TypeHandler作为解决方案。

二. MyBatis的内置TypeHandler

MyBatis提供了大量内置的TypeHandler用于常规数据类型的转换,例如BooleanTypeHandler完成Java类型java.lang.BooleanbooleanJdbc类型BOOLEAN的转换,LocalDateTimeTypeHandler完成Java类型java.time.LocalDateTimeJdbc类型TIMESTAMP的转换,StringTypeHandler完成Java类型java.lang.StringJdbc类型CHARVARCHAR的转换,全量的内置TypeHandler可以参考官方文档, 这里不再列举。

三. 自定义TypeHandler

正是由于MyBatis提供了大量的内置TypeHandler,所以通常情况下使用MyBatis时不用关心Java类型与Jdbc类型的转换,但是也会有一些场景,MyBatis内置的TypeHandler是无法满足需求的,例如Java对象有一个List字段,而这个字段在数据库中对应的Jdbc类型是VARCHAR,那么此时传统的做法就是需要在程序中做大量的ListVARCHAR以及VARCHARList的转换,且代码不易复用,此时就可以通过自定义TypeHandler来解决这种问题。

本篇文章将先从0到1搭建示例工程,并演示如何自定义TypeHandler来解决ListVARCHAR的转换问题,然后会介绍TypeHandler的相关概念配置,以及生效条件

MyBatis版本:3.5.6

正文

一. 示例工程搭建

首先创建示例表,DDL语句如下。

CREATE TABLE student(
    id INT(11) PRIMARY KEY AUTO_INCREMENT,
    stu_name VARCHAR(255) NOT NULL,
    stu_age INT(11) NOT NULL,
    stu_num VARCHAR(255) NOT NULL,
    stu_intention VARCHAR(255) NOT NULL
)

student表主要存储一名学生的基本信息,包括:姓名年龄学号意向学校。意向学校在数据库中是以VARCHAR类型存储,例如清华,北大,复旦这样的形式。

然后创建MAVEN工程,pom文件如下所示。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.lee.learn.mybatis</groupId>
    <artifactId>learn-mybatis-typehandler</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.5.6</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.16</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.8.1</version>
        </dependency>
    </dependencies>

    <build>
        <resources>
            <resource>
                <directory>src/main/java</directory>
                <includes>
                    <include>**/*.xml</include>
                </includes>
                <filtering>false</filtering>
            </resource>
        </resources>
    </build>

</project>

MyBatis配置文件mybatis-config.xml如下所示。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="logImpl" value="STDOUT_LOGGING"/>
    </settings>

    <environments default="development">
        <environment id="development">
            <transactionManager type="Jdbc"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://192.168.101.7:3306/test?characterEncoding=utf-8&amp;serverTimezone=UTC&amp;useSSL=false"/>
                <property name="username" value="root"/>
                <property name="password" value="root"/>
            </dataSource>
        </environment>
    </environments>

    <mappers>
        <package name="com.lee.learn.mybatis.dao"/>
    </mappers>
</configuration>

映射接口(Mapper接口)和映射文件(XML文件)放在com.lee.learn.mybatis.dao路径下,映射接口StudentMapper如下所示。

public interface StudentMapper {

    /**
     * 添加一个学生。
     *
     * @param studentName 学生名。
     * @param studentAge 学生年龄。
     * @param studentNum 学生唯一标识串。
     * @param studentIntention 学生意向学校。
     */
    void addStudent(@Param("studentName") String studentName,
                    @Param("studentAge") int studentAge,
                    @Param("studentNum") String studentNum,
                    @Param("studentIntention") List<String> studentIntention);

    /**
     * 查询所有学生。
     *
     * @return {@link Student}的集合。
     */
    List<Student> queryAllStudents();

}

映射文件StudentMapper.xml如下所示。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lee.learn.mybatis.dao.StudentMapper">
    <resultMap id="studentResultMap" type="com.lee.learn.mybatis.entity.Student">
        <id property="id" column="id"/>
        <result property="studentName" column="stu_name"/>
        <result property="studentAge" column="stu_age"/>
        <result property="studentNum" column="stu_num"/>
        <result property="intentions" column="stu_intention"/>
    </resultMap>

    <insert id="addStudent">
        INSERT INTO student (stu_name, stu_age, stu_num, stu_intention)
        VALUES (#{studentName}, #{studentAge}, #{studentNum}, #{studentIntention})
    </insert>

    <select id="queryAllStudents" resultMap="studentResultMap">
        SELECT 
            id, 
            stu_name, 
            stu_num, 
            stu_intention
        FROM student
    </select>
</mapper>

与数据库记录做映射的实体对象Student放在com.lee.learn.mybatis.entity.Student路径下,且其结构如下。

@Getter
@Setter
@ToString
public class Student {

    private int id;
    private String studentName;
    private int studentAge;
    private String studentNum;
    private List<String> intentions;

}

学生实体对象Studentintentions字段是一个List类型,这里就存在一个ListVARCHAR的相互转换问题。

最后是单元测试程序,MybatisTest如下所示。

public class MybatisTest {

    private SqlSession sqlSession;

    @Before
    public void setUp() throws Exception {
        String resource = "mybatis-config.xml";
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder()
                .build(Resources.getResourceAsStream(resource));
        sqlSession = sqlSessionFactory.openSession(false);
    }

    @After
    public void tearDown() {
        sqlSession.close();
    }

    @Test
    public void 添加一个学生() {
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        studentMapper.addStudent("Lee", 20,
                "0001", Arrays.asList("清华", "北大", "复旦"));
        sqlSession.commit();
    }

    @Test
    public void 查询所有学生() {
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        List<Student> students = studentMapper.queryAllStudents();
        students.forEach(System.out::println);
    }

}

示例工程的目录结构如下所示。

示例工程目录图

二. 问题演示与解决

执行上述示例工程的添加一个学生测试案例,会执行失败,且打印的错误日志中会有如下的提示。

java.lang.IllegalStateException: No typehandler found for property intentions

因为Student#intentions字段是List类型,而Student#intentions字段对应数据库中的类型是VARCHAR,同时MyBatis没有内置的List转换VARCHARTypeHandler以及示例工程的业务代码中也没有去做转换,最终导致报错。

下面演示如何自定义一个TypeHandler来解决上述问题。

自定义用于完成ListVARCHAR相互转换的类型处理器ListStringTypeHandler放在com.lee.learn.mybatis.typehandler路径下,且其实现如下。

@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.CHAR})
public class ListStringTypeHandler extends BaseTypeHandler<List<String>> {

    private final String seq = ",";

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

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

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

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

    private String listToString(List<String> parameter) throws SQLException {
        if (parameter == null || parameter.size() == 0) {
            throw new SQLException();
        }
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < parameter.size(); i++) {
            stringBuilder.append(parameter.get(i));
            if (i != parameter.size() - 1) {
                stringBuilder.append(seq);
            }
        }
        return stringBuilder.toString();
    }

    private List<String> StringToList(String paramStr) {
        if (StringUtils.isEmpty(paramStr)) {
            return new ArrayList<>();
        }
        String[] params = paramStr.split(seq);
        return new ArrayList<>(Arrays.asList(params));
    }

}

MyBatis配置文件中将自定义的TypeHandler进行注册,mybatis-config.xml修改如下所示。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="logImpl" value="STDOUT_LOGGING"/>
    </settings>

    <typeHandlers>
        <typeHandler handler="com.lee.learn.mybatis.typehandler.ListStringTypeHandler"/>
    </typeHandlers>

    <environments default="development">
        <environment id="development">
            <transactionManager type="Jdbc"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://192.168.101.7:3306/test?characterEncoding=utf-8&amp;serverTimezone=UTC&amp;useSSL=false"/>
                <property name="username" value="root"/>
                <property name="password" value="root"/>
            </dataSource>
        </environment>
    </environments>

    <mappers>
        <package name="com.lee.learn.mybatis.dao"/>
    </mappers>
</configuration>

光完成注册还无法让ListStringTypeHandler生效,还需要在映射文件中的<resultMap>和<insert>标签中引用注册好的ListStringTypeHandlerStudentMapper.xml文件修改如下。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lee.learn.mybatis.dao.StudentMapper">
    <resultMap id="studentResultMap" type="com.lee.learn.mybatis.entity.Student">
        <id property="id" column="id"/>
        <result property="studentName" column="stu_name"/>
        <result property="studentAge" column="stu_age"/>
        <result property="studentNum" column="stu_num"/>
        <result property="intentions" column="stu_intention"
                typeHandler="com.lee.learn.mybatis.typehandler.ListStringTypeHandler"/>
    </resultMap>

    <insert id="addStudent">
        INSERT INTO student (stu_name, stu_age, stu_num, stu_intention)
        VALUES (#{studentName}, #{studentAge}, #{studentNum}, #{studentIntention, typeHandler=com.lee.learn.mybatis.typehandler.ListStringTypeHandler})
    </insert>

    <select id="queryAllStudents" resultMap="studentResultMap">
        SELECT 
            id, 
            stu_name, 
            stu_num, 
            stu_intention
        FROM student
    </select>
</mapper>

修改点如下。

  1. <resultMap>标签下的<result>标签中使用了typeHandler属性来指定使用ListStringTypeHandler
  2. <insert>标签里的第四个#{}占位符中使用了typeHandler属性来指定使用ListStringTypeHandler

上述都是显示的使用TypeHandler(还有隐式的使用,后面再讲)。运行添加一个学生测试用例,可以执行成功。此时运行查询所有学生测试用例,日志打印如下。

查询所有学生

可见自定义的ListStringTypeHandler在设置预处理语句(PreparedStatement)中的参数或将查询记录与Java实体对象做映射时,完成了类型转换功能。

三. 如何自定义TypeHandler

类型处理器(TypeHandler)通常用于MyBatis在设置预处理语句(PreparedStatement)中的参数时,完成Java类型到Jdbc类型的转换(通常用在#{}参数占位符中),以及在将查询到的结果记录映射到Java实体对象时,完成Jdbc类型到Java类型的转换(通常用在<result>标签中)。

MyBatis提供了大量内置的TypeHandler作为默认类型处理器,用于基本数据类型的转换,而对于非标准的数据类型的转换,可以通过实现TypeHandler接口,或继承BaseTypeHandler。先看一下TypeHandler接口。

public interface TypeHandler<T> {

    // 用于将Java类型参数设置到预处理语句PreparedStatement中(JavaType -> JdbcType)
    void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException;

    // 用于将查询结果集ResultSet的columnName列的数据转换成Java类型(JdbcType -> JavaType)
    T getResult(ResultSet rs, String columnName) throws SQLException;

    // 用于将查询结果集ResultSet的第columnIndex列的数据转换成Java类型(JdbcType -> JavaType)
    T getResult(ResultSet rs, int columnIndex) throws SQLException;

    // 用于将存储过程CallableStatement的第columnIndex列的数据转换成Java类型(JdbcType -> JavaType)
    T getResult(CallableStatement cs, int columnIndex) throws SQLException;
	
}

TypeHandler接口定义了setParameter() 方法用于在将Java类型参数设置到预处理语句PreparedStatement中时完成JavaTypeJdbcType的转换,也定义了getResult() 方法用于在将查询结果映射到Java实体对象时完成JdbcTypeJavaType的转换。而BaseTypeHandler是相比于TypeHandler更易用的实现类型处理器的抽象类,BaseTypeHandler实现了TypeHandler接口并会在实现的四个方法中分别调用BaseTypeHandler定义的四个抽象方法,并对调用做了异常捕获与处理,如下所示。

@Override
public T getResult(ResultSet rs, String columnName) throws SQLException {
    try {
        return getNullableResult(rs, columnName);
    } catch (Exception e) {
        throw new ResultMapException("Error attempting to get column '" + columnName + "' from result set.  Cause: " + e, e);
    }
}

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

更推荐通过继承BaseTypeHandler的方式来自定义类型处理器

下面是示例工程中的自定义类型处理器,如下所示。

@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.CHAR})
public class ListStringTypeHandler extends BaseTypeHandler<List<String>> {

    private final String seq = ",";

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

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

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

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

    private String listToString(List<String> parameter) throws SQLException {
        if (parameter == null || parameter.size() == 0) {
            throw new SQLException();
        }
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < parameter.size(); i++) {
            stringBuilder.append(parameter.get(i));
            if (i != parameter.size() - 1) {
                stringBuilder.append(seq);
            }
        }
        return stringBuilder.toString();
    }

    private List<String> StringToList(String paramStr) {
        if (StringUtils.isEmpty(paramStr)) {
            return new ArrayList<>();
        }
        String[] params = paramStr.split(seq);
        return new ArrayList<>(Arrays.asList(params));
    }

}

上述自定义类型处理器,支持的Java类型是List,支持的Jdbc类型是VARCHARCHAR。如果要决定一个类型处理器支持哪些Java类型,有如下途径。

  1. 类型处理器的泛型可以决定类型处理器支持的JavaType
  2. 在类型处理器上使用注解@MappedTypes来指定,例如@MappedTypes({List.class})
  3. 在配置文件中注册类型处理器时,通过<typeHandler>标签的javaType属性来指定,例如<typeHandler handler="com.lee.learn.mybatis.typehandler.ListStringTypeHandler" javaType="List"/>。

优先级是1到3依次增加。

如果要决定一个类型处理器支持哪些Jdbc类型,有如下途径。

  1. 在类型处理器上使用注解@MappedJdbcTypes来指定,例如@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.CHAR})
  2. 在配置文件中注册类型处理器时,通过<typeHandler>标签的jdbcType属性来指定(注意:同时也需要设置了javaType属性,否则jdbcType属性不生效), 例如<typeHandler handler="com.lee.learn.mybatis.typehandler.ListStringTypeHandler" javaType="List" jdbcType="VARCHAR"/>。

优先级是1到2依次增加。

四. TypeHandler如何生效

通常,TypeHandler的使用场景有两个。

  1. 在设置预处理语句(PreparedStatement)中的参数时,完成Java类型到Jdbc类型的转换,通常就是INSERTUPDATE的场景;
  2. 在将查询到的结果记录映射到Java实体对象时,完成Jdbc类型到Java类型的转换,通常就是会使用到<resultMap>的场景。

在使用场景下,如何让我们自定义的TypeHandler生效,如下直接给出结论,再做验证。

  1. 显式使用。示例中就是显示使用,即在<result>标签中和#{}占位符中使用typeHandler属性来指定使用的类型处理器,这种方式是最简单粗暴的,就算不在配置文件中注册类型处理器,就算没有为类型处理器配置任何支持的Jdbc类型,只要在<result>标签中和#{}占位符中使用了typeHandler属性来指定要使用的类型处理器,那么MyBatis就会使用这个类型处理器;
  2. 隐式使用。通常,我们是不会关注到TypeHandler的,然而大部分时候Java类型到Jdbc类型的相互转换都能成功完成,是因为MyBatis会隐式使用其内置的TypeHandler,而隐式使用哪个内置TypeHandler,是通过<result>标签和#{}占位符的JavaTypeJdbcType进行推断的。

显式使用没什么好说的,最为简单明了。下面重点说一下隐式使用

首先能够被隐式使用的TypeHandler,都需要完成注册,自定义的TypeHandler可以在配置文件中通过<typeHandler>标签注册,而内置的TypeHandler是在TypeHandlerRegistry#TypeHandlerRegistry方法完成的注册,这个方法有点长,这里不再展示。

然后每一个TypeHandler都有其支持的Java类型,以及可能支持的Jdbc类型(也可能没有),TypeHandler注册到MyBatis中后,是按照如下形式存储的。

// Map<JavaType, Map<JdbcType, TypeHandler>>
private final Map<Type, Map<JdbcType, TypeHandler<?>>> typeHandlerMap = new ConcurrentHashMap<>();

下面得跟一下TypeHandlerRegistry#register方法,才能更好的理解TypeHandler是如何在MyBatis中被存放以及如果TypeHandler支持的Jdbc类型为空时这个TypeHandler如何被隐式使用。方法源码如下所示。

public <T> void register(Class<T> javaType, TypeHandler<? extends T> typeHandler) {
    register((Type) javaType, typeHandler);
}

继续跟进,如下所示。

private <T> void register(Type javaType, TypeHandler<? extends T> typeHandler) {
    MappedJdbcTypes mappedJdbcTypes = typeHandler.getClass().getAnnotation(MappedJdbcTypes.class);
    if (mappedJdbcTypes != null) {
        // 如果配置了@MappedJdbcTypes注解且通过注解配置了支持的Jdbc类型
        // 则按照Map<JavaType, Map<JdbcType, TypeHandler>>的结构来存放TypeHandler
        // 同一个TypeHandler可能会在Map中存多份,因为@MappedJdbcTypes注解可以配置多个Jdbc类型
        for (JdbcType handledJdbcType : mappedJdbcTypes.value()) {
            register(javaType, handledJdbcType, typeHandler);
        }
        // 如果配置了@MappedJdbcTypes注解且将includeNullJdbcType属性设置为了true
        // 则再将TypeHandler与一个null的Jdbc类型做关联
        if (mappedJdbcTypes.includeNullJdbcType()) {
            register(javaType, null, typeHandler);
        }
    } else {
        // 因为没有支持的Jdbc类型
        // 所以将TypeHandler与一个null的Jdbc类型做关联
        register(javaType, null, typeHandler);
    }
}

所以就算没有为一个TypeHandler设置支持的Jdbc类型,但是这个TypeHandler一样会被存放在MyBatis中。不过请注意,如果有两个及以上的TypeHandler支持的Java类型一样,支持的Jdbc类型也一样(包括null),那么后注册的TypeHandler会覆盖先注册的TypeHandler(自定义的TypeHandler注册顺序是按照配置文件里的先后顺序来依次加载的,先配置的先加载,后配置的后加载)。

现在已经知道了注册到MyBatis中的TypeHandler是按照Map<JavaType, Map<JdbcType, TypeHandler>> 这样的形式存放在MyBatisTypeHandlerRegistry#typeHandlerMap中。同时,MyBatis在解析映射文件时,每一个<result>标签会被解析为一个ResultMapping对象,每一个#{}参数占位符会被解析为一个ParameterMapping对象,在这个过程中也会将需要使用的TypeHandler绑定到ResultMappingParameterMapping上。那么再看示例工程中的如下一个<result>标签。

<result column="stu_intention" property="intentions"/>

MyBatis在解析这个标签时,会将这个标签解析并构造为一个ResultMapping,具体可看ResultMapping.Builder#build方法,如下所示。

private void resolveTypeHandler() {
    if (resultMapping.typeHandler == null && resultMapping.javaType != null) {
	Configuration configuration = resultMapping.configuration;
	TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
	// 根据从JavaType和JdbcType从TypeHandlerRegistry中拿TypeHandler
	resultMapping.typeHandler = typeHandlerRegistry.getTypeHandler(resultMapping.javaType, 
                            resultMapping.jdbcType);
    }
}

上述方法表明ResultMapping使用的TypeHandler是根据<result>标签对应的JavaTypeJdbcTypeTypeHandlerRegistry中匹配。通常,如果不为<result>标签指定JavaTypeJdbcType,则JavaType是已知的,因为可以通过映射对象的字段类型做推断(示例工程中可以根据映射对象Studentintentions字段类型做推断),但是JdbcType是未知的(因为MyBatis是不知道数据库的元信息的也不会去检测数据库元信息),那么对于上述示例工程中的<result>标签,会以JavaType=List, JdbcType=nullTypeHandlerRegistry中匹配TypeHandler。继续看TypeHandlerRegistry#getTypeHandler的实现,如下所示。

public <T> TypeHandler<T> getTypeHandler(Class<T> type, JdbcType jdbcType) {
    return getTypeHandler((Type) type, jdbcType);
}

private <T> TypeHandler<T> getTypeHandler(Type type, JdbcType jdbcType) {
    if (ParamMap.class.equals(type)) {
        return null;
    }
    // 先根据JavaType得到这个JavaType对应的Map<JdbcType, TypeHandler>
    Map<JdbcType, TypeHandler<?>> jdbcHandlerMap = getJdbcHandlerMap(type);
    TypeHandler<?> handler = null;
    if (jdbcHandlerMap != null) {
        // 根据JdbcType拿到TypeHandler
        handler = jdbcHandlerMap.get(jdbcType);
        if (handler == null) {
            // 根据JdbcType拿不到TypeHandler
            // 则再以JdbcType为null去拿TypeHandler
            handler = jdbcHandlerMap.get(null);
        }
        if (handler == null) {
            // 如果根据JdbcType和null都拿不到TypeHandler
            // 则调用pickSoleHandler()方法拿TypeHandler
            handler = pickSoleHandler(jdbcHandlerMap);
        }
    }
    return (TypeHandler<T>) handler;
}

private TypeHandler<?> pickSoleHandler(Map<JdbcType, TypeHandler<?>> jdbcHandlerMap) {
    TypeHandler<?> soleHandler = null;
    // 遍历JavaType对应的所有TypeHandler
    // 所有TypeHandler都是同一个TypeHandler时,就返回这个TypeHandler
    // 只要出现两种以上的TypeHandler,则返回null
    for (TypeHandler<?> handler : jdbcHandlerMap.values()) {
        if (soleHandler == null) {
            soleHandler = handler;
        } else if (!handler.getClass().equals(soleHandler.getClass())) {
            return null;
        }
    }
    return soleHandler;
}

也就是会先以JavaType来拿到这个JavaType对应的Map<JdbcType, TypeHandler>,然后再以JdbcTypeMap<JdbcType, TypeHandler> 中匹配TypeHandler,如果没有匹配的TypeHandler,则再以nullMap<JdbcType, TypeHandler> 中匹配TypeHandler(这里主要是为了使配置了@MappedJdbcTypes注解且将includeNullJdbcType属性设置为trueTypeHandler能够被匹配到),如果还是没匹配到TypeHandler,则调用pickSoleHandler() 方法判断当前JavaType是不是只有一个对应的TypeHandler,如果是,则返回这个TypeHandler,如果不是,则返回null(这是MyBatis在3.4.0版本做的优化,以支持某个Java类型只有一个注册的类型处理器时,即使没有设置@MappedJdbcTypes注解的includeNullJdbcType属性为true,这个类型处理器也能被选择用于处理这个Java类型)。

这里直接对隐式使用TypeHandler做一个小结,不关心源码的可以直接看这里。

  1. 每一个TypeHandler都有其支持的Java类型,以及可能支持的Jdbc类型(也可能没有),并且在MyBatis中以Map<JavaType, Map<JdbcType, TypeHandler>> 的形式存放;
  2. 如果有多个TypeHandler的支持的Java类型和Jdbc类型都一样,则后注册的TypeHandler会覆盖先注册的TypeHandler
  3. 如果在MyBatis的参数占位符#{}或者结果映射标签<result>中通过javaType属性指定了JavaType,则MyBatis在推断使用哪种TypeHandler时依据的JavaType会使用javaType属性的值,否则,如果是<result>的话则MyBatis能根据映射对象推断出JavaType,如果是#{}的话则JavaTypeObject
  4. 如果在MyBatis的参数占位符#{}或者结果映射标签<result>中通过jdbcType属性指定了JdbcType,则MyBatis在推断使用哪种TypeHandler时依据的JdbcType会使用jdbcType属性的值,否则依据的JdbcType会为null
  5. MyBatis在推断使用哪个TypeHandler时,会先使用JavaType拿到JavaType对应的Map<JdbcType, TypeHandler>,然后使用JdbcType去匹配TypeHandler,匹配不到则再使用JdbcType=null去匹配TypeHandler,如果还匹配不到,则判断JavaType对应的TypeHandler是否有多个,如果是多个则返回null表示匹配失败,如果只有一个则使用这个TypeHandler

下面是几个例子,加深理解。

例子1

自定义的TypeHandler,<result>标签和参数占位符#{}如下所示。

@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.CHAR})
public class ListStringTypeHandler extends BaseTypeHandler<List<String>>

<result column="stu_intention" property="intentions"/>

#{studentIntention}

<result>能使用到ListStringTypeHandler#{}使用不到ListStringTypeHandler

例子2

自定义的TypeHandler,<result>标签和参数占位符#{}如下所示。

@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.CHAR})
public class ListStringTypeHandler extends BaseTypeHandler<List<String>>

<result column="stu_intention" property="intentions" jdbcType="DATE"/>

#{studentIntention, javaType=List}

<result>能使用到ListStringTypeHandler#{}也能使用到ListStringTypeHandler

例子3

自定义的TypeHandler,<result>**标签和参数占位符#{}如下所示。

@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.CHAR})
public class ListStringTypeHandler extends BaseTypeHandler<List<String>>

@MappedJdbcTypes({JdbcType.DATE})
public class AnotherListStringTypeHandler extends BaseTypeHandler<List<String>>

<result column="stu_intention" property="intentions"/>

#{studentIntention, javaType=List}

<result>和#{}都使用不到ListStringTypeHandlerAnotherListStringTypeHandler

例子4

自定义的TypeHandler,<result>标签和参数占位符#{}如下所示。

@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.CHAR})
public class ListStringTypeHandler extends BaseTypeHandler<List<String>>

@MappedJdbcTypes({JdbcType.DATE})
public class AnotherListStringTypeHandler extends BaseTypeHandler<List<String>>

<result column="stu_intention" property="intentions" jdbcType="VARCHAR"/>

#{studentIntention, javaType=List, jdbcType=CHAR}

<result>和#{}都能使用到ListStringTypeHandler

最后建议自定义的TypeHandler都要为其指定支持的JavaTypeJdbcType,以及必要时在<result>标签和#{}占位符中都把javaTypejdbcType属性配置上,这样MyBatis能够快速无误的帮我们推断出应该使用哪个类型处理器。

五. 小补充

这里再对为参数占位符#{}推断类型处理器时的一些逻辑进行补充说明,不看也不影响对本篇文章的理解。

为参数占位符#{}推断类型处理器时,如果没有通过javaType来指定Java类型,那么MyBatis是无法知道Java类型是什么的(而<result>标签是可以的,这是不同点),此时MyBatis会默认Java类型是Object,然后通过Object这个JavaType拿到一个UnknownTypeHandler内置类型处理器,下面看一下UnknownTypeHandlersetNonNullParameter() 方法。

@Override
public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)
        throws SQLException {
    TypeHandler handler = resolveTypeHandler(parameter, jdbcType);
    handler.setParameter(ps, i, parameter, jdbcType);
}

private TypeHandler<?> resolveTypeHandler(Object parameter, JdbcType jdbcType) {
    TypeHandler<?> handler;
    if (parameter == null) {
        handler = OBJECT_TYPE_HANDLER;
    } else {
        // 根据需要设置到PreparedStatement中的参数判断出Java类型
        // 然后再调用到TypeHandlerRegistry#getTypeHandler拿TypeHandler
        handler = typeHandlerRegistrySupplier.get().getTypeHandler(parameter.getClass(), jdbcType);
        if (handler == null || handler instanceof UnknownTypeHandler) {
            handler = OBJECT_TYPE_HANDLER;
        }
    }
    return handler;
}

已知setNonNullParameter() 方法是会在实际执行SQL语句前被调用到,此时会完成PreparedStatement的参数设置,因此这时能够拿到实际设置到PreparedStatement中的参数值从而得到参数的JavaType,所以这时会再尝试基于JavaTypeJdbcType去匹配TypeHandler

所以本质上就算没有通过javaType指定JavaType,<result>标签和#{}参数占位符都是能够拿到JavaType,只不过<result>标签在构建ResultMapping时就能够拿到JavaType,而#{}参数占位符需要在SQL语句实际执行前为PreparedStatement设置参数时才能够拿到JavaType

那么按照本节的结论,为什么第四节最后的例子1中的#{}使用不到ListStringTypeHandler呢,这是因为在为PreparedStatement设置参数时,studentIntention这个参数的实际类型是ArrayList,而不是List,但在MyBatis中,认为ListStringTypeHandler是支持List而不是ArrayList的。

总结

TypeHandler能够帮助完成Java类型到Jdbc类型的相互转换,对于常规的转换,MyBatis提供了内置的TypeHandler,而对于非常规的转换,需要自定义TypeHandler。自定义方式有两种,如下所示。

  1. 实现TypeHandler接口;
  2. 继承BaseTypeHandler抽象类。

更推荐使用继承BaseTypeHandler抽象类的方式来自定义TypeHandler

自定义的TypeHandler有如下两种方式被使用。

  1. 显示使用。在<result>标签或者#{}中通过typeHandler属性指定要使用的TypeHandler
  2. 隐式使用。通过<result>标签或者#{}JavaTypeJdbcType,由MyBatis推断出需要使用的TypeHandler

大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情