Mybatis源码之美:3.5.5.配置构造方法的constructor元素

1,284 阅读9分钟

配置构造方法的constructor元素

这就是强者的世界吗

简单了解constructor元素

mybatis为我们提供了一个constructor元素来配置PO对象的构造方法,通常来说,mybatis会通过无参构造方法实例化PO对象,但是在某些特殊的场景下,基于特定的原因,PO对象可能没有提供无参构造,或者必须通过特定的构造方法才能被实例化,这时候,我们就用到了constructor元素.

悠闲

关于constructor元素,mybatis官方文档是这样介绍的:

构造方法注入允许你在初始化时为类设置属性的值,而不用暴露出公有方法。 MyBatis 也支持私有属性和私有 JavaBean 属性来完成注入,但有一些人更青睐于通过构造方法进行注入。 constructor 元素就是为此而生的。

constructor元素的定义并不复杂,按照resultMap元素的定义来看,一个resultMap最多只能配置一个constructor元素.

constructor定义

constructor元素没有属性定义,只有两个子元素定义:

<!ELEMENT constructor (idArg*,arg*)>

而且这两个子元素的属性定义还是完全一样的:

<!ELEMENT idArg EMPTY>
<!ATTLIST idArg
javaType CDATA #IMPLIED
column CDATA #IMPLIED
jdbcType CDATA #IMPLIED
typeHandler CDATA #IMPLIED
select CDATA #IMPLIED
resultMap CDATA #IMPLIED
name CDATA #IMPLIED
columnPrefix CDATA #IMPLIED
>

<!ELEMENT arg EMPTY>
<!ATTLIST arg
javaType CDATA #IMPLIED
column CDATA #IMPLIED
jdbcType CDATA #IMPLIED
typeHandler CDATA #IMPLIED
select CDATA #IMPLIED
resultMap CDATA #IMPLIED
name CDATA #IMPLIED
columnPrefix CDATA #IMPLIED
>

真好

不仅如此,就连idArgarg这两个元素的作用都十分相似,他们都用来配置构造方法的构造参数,唯一不同的是用idArg子元素配置的构造参数会被标记为当前对象的唯一标识符.

映射关系

如何根据constructor元素来获取对应构造方法

因为java类定义中的方法是允许重载的,所以一个类定义中有可能会出现多个同名不同参的构造方法,比如:

@Data
@NoArgsConstructor
public class User {

    private Integer id;
    private String name;

    public User(Integer id) {
        this.id = id;
    }
    public User(String name) {
        this.name = name;
    }
    public User(Integer id, String name) {
        this.id = id;
        this.name = name;
    }
}

在上面这个示例中,一个小小的User类定义,就提供了三个不同的构造方法.

那么,当我们配置了constructor元素时,mybatis该如何选择对应的构造方法呢?

疑惑

按照mybatis官方文档的描述,最初mybatis只能根据用户声明idArg/arg子元素的顺序以及该元素所对应的java类型来获取对应的构造方法.

后来,因为JDK1.8+的版本可以获取方法定义中的形参名称了,所以从版本3.4.3开始,mybatis开始支持根据参数名称匹配所对应的构造方法.

这是官方介绍:

当你在处理一个带有多个形参的构造方法时,很容易搞乱 arg 元素的顺序。 从版本 3.4.3 开始,可以在指定参数名称的前提下,以任意顺序编写 arg 元素。 为了通过名称来引用构造方法参数,你可以添加 @Param 注解,或者使用 '-parameters' 编译选项并启用 useActualParamName 选项(默认开启)来编译项目。

抱着好奇的心态,我去对比了一下3.4.23.4.3两个版本中idArg/arg元素的DTD定义:

DTD对比

果然不出我所料,name属性是在3.4.3版本中新增的.

惊讶

useActualParamName全局配置和-parameters编译选项

-parameters编译选项

说到-parameters编译选项,就不得不提一下JDK增强提案:JEP 118: Access to Parameter Names at Runtime

访问地址:openjdk.java.net/jeps/118

118提案提出:

提供一种机制,可在运行时通过核心反射轻松可靠地检索方法和构造函数的参数名称。

java中,java.lang.reflect.Parameter对象用于描述方法参数,它的getName()方法用于获取方法名称.

本篇文章不会对java源码做深入的探究,如果想了解更多关于形参名称的生成方案,可以参考java.lang.reflect.ExecutablegetParameters()方法. getParameters()方法的实现其实很简单,这里不做探究的原因是因为想控制学习的深度.

喝水

针对同样的测试代码:

public interface Example {
    void simpleMethod(String id, String name);
}

@Slf4j
public class ParametersCompileParameterTest {

    @Test
    @SneakyThrows
    public void simpleMethodTest() {
        Method method = Example.class.getMethod("simpleMethod", String.class, String.class);
        for (Parameter parameter : method.getParameters()) {
            log.debug(parameter.getName());
        }
    }

}

如果我们在编译文件时启用了-parameters编译选项,simpleMethod()方法的两个形参名称就会被编译进Example.class文件中:

Example.class

这样在为simpleMethod()方法的两个形参创建对应的Parameter对象时,就会使用的真实的形参名称.

Parameter

这时候,我们再调用Parameter对象的getName()方法就会获得真实的形参名称.

开心

运行结果:

DEBUG [main] - paramName
DEBUG [main] - otherName

但是如果我们在编译文件时未启用-parameters编译选项,在Example.class文件中就不会包含simpleMethod()方法的两个形参名称的描述信息:

Example.class

因此,我们得到的形参所对应的Parameter对象的name值是一个合成的argN,其中N表示形参在方法参数列表中的索引位置.

这时候,再调用Parameter对象的getName()方法,就无法获取真实的形参名称,只能获取类似于argN的取值.

Parameter

Oh,No

运行结果:

DEBUG [main] - arg0
DEBUG [main] - arg1

IDEA配置javac -parameters方法

配置

允许使用方法形参作为参数名称的全局配置useActualParamName

在前面的文章中,我们了解到mybatis有一个默认值为true的全局配置useActualParamName,这个配置项的作用是在运行时,允许使用方法形参作为参数名称.

比如,在没有启用useActualParamName的前提下,针对下面的语句配置:

<select id="selectForExample" resultMap="...some...">
    SELECT *
    FROM USER u
    WHERE u.id = #{id} AND u.name=#{name}
</select>

如果我们直接定义方法selectForExample(),不做任何特殊处理:

User selectNormalUser(Integer id, String name);

在运行时,将会得到报错信息:

Caused by: org.apache.ibatis.binding.BindingException: Parameter 'id' not found. Available parameters are [0, 1, param1, param2]

我太难了

这是因为mybatis无法识别idname这两个参数名称,我们只能通过参数定义的下标来获取对参数对象的访问.

针对这种场景,我们可以选择使用@Param注解为参数指定名称:

User selectNormalUser(@Param("id") Integer id, @Param("name")String name);

也可以选择启用useActualParamName配置,让mybatis自己使用形参名称作为参数名称,但是如果我们只是单纯的配置了useActualParamName参数的值为true,针对方法定义:

User selectNormalUser(Integer id, String name);

我们会得到另一个报错:

Caused by: org.apache.ibatis.binding.BindingException: Parameter 'id' not found. Available parameters are [arg1, arg0, param1, param2]

我太难了

解决这个报错的方案就是在编译文件时启用-parameters编译选项.

下面是不同场景下,mybatis生成的有效参数名称数组:

场景 参数名称1 参数名称2 参数名称3 参数名称4
禁用useActualParamName 0 1 param1 param2
使用@Param注解 id name param1 param2
启用useActualParamName,禁用-parameters编译选项 arg0 arg1 param1 param2
启用useActualParamName,启用-parameters编译选项 id name param1 param2

mybatis生成有效参数名称数组的逻辑在org.apache.ibatis.reflection.ParamNameResolver中被实现,在后面的文章中会学到该类.

了解idArg/arg元素的属性定义

idArg/arg元素的column,select,resultMap,columnPrefix,javaType,jdbcType,typeHandler这些属性和前面学习到的基本一致,这里就不做赘述了.

怕被打

唯一有些陌生的是idArg/arg元素新增的用来配置构造方法形参名字的name属性.

实践一下

根据用户声明idArg/arg子元素的顺序以及该元素所对应的java类型来获取对应的构造方法

创建一个简单的示例代码:

<resultMap id="constructorUser" type="org.apache.learning.result_map.constructor.User">
    <constructor>
        <arg column="id" javaType="int" />
        <arg column="name" javaType="String"/>
    </constructor>
</resultMap>
@Data
@NoArgsConstructor
public class User {

    private Integer id;
    private String name;

    public User(Integer id, String name) {
        this.id = id;
        this.name = name;
    }
}

在上面的代码中,根据constructor元素的配置,它所对应的构造方法的参数列表类型依次为IntegerString,刚好匹配User提供的构造方法,所以下面的单元测试是能够正常运行的:

@Test
public void selectConstructorUser() {
    sqlSessionFactory.getConfiguration().addMapper(ConstructorMapper.class);
    @Cleanup
    SqlSession sqlSession = sqlSessionFactory.openSession();
    ConstructorMapper constructorMapper = sqlSession.getMapper(ConstructorMapper.class);
    User user = constructorMapper.selectConstructorUser(1, "Panda");

    assert user != null;
}

但是如果我们将两个arg元素的位置互换:

<!-- 翻转顺序-->
<resultMap id="reversalConstructorUser" type="org.apache.learning.result_map.constructor.User">
    <constructor>
        <arg column="name" javaType="String"/>
        <arg column="id" javaType="int"/>
    </constructor>
</resultMap>

<select id="selectReversalConstructorUser" resultMap="reversalConstructorUser">
    SELECT *
    FROM USER u
    WHERE u.id = #{id}
    AND u.name = #{name}
</select>

我们将得到一条报错信息:

java.lang.NoSuchMethodException: org.apache.learning.result_map.constructor.User.<init>(java.lang.String, java.lang.Integer)

这是因为根据constructor元素的配置,它所对应的构造方法的参数列表类型依次为StringInteger,但是User中并没有提供对应的构造方法.

虽然myabtis没有要求必填javaType属性,但是在没有指定构造参数名称时,最好是传入javaType属性,除非你的构造参数类型是Object,否则你会得到一个类似于下面的异常信息:

Caused by: java.lang.NoSuchMethodException: org.apache.learning.result_map.constructor.User.<init>(java.lang.Object, java.lang.Object)

听话

跟据参数名称匹配所对应的构造方法

我们继续看一下如何跟据参数名称匹配对应的构造方法.

<!-- 根据名称匹配  -->
<resultMap id="namedConstructorUser" type="org.apache.learning.result_map.constructor.User">
    <constructor>
        <arg column="name" name="name"/>
        <arg column="id" name="id"/>
    </constructor>
</resultMap>
<select id="selectNamedConstructorUser" resultMap="namedConstructorUser">
    SELECT *
    FROM USER u
    WHERE u.id = #{id}
        AND u.name = #{name}
</select>

在上面的代码中,我们通过name属性指定了arg元素对应的构造参数名称,同时arg元素的配置顺序和实际声明的构造方法的参数顺序是相反的.

@Test
public void selectReversalConstructorUserWillSuccess() {
    sqlSessionFactory.getConfiguration().addMapper(ConstructorMapper.class);
    @Cleanup
    SqlSession sqlSession = sqlSessionFactory.openSession();
    ConstructorMapper constructorMapper = sqlSession.getMapper(ConstructorMapper.class);
    User user = constructorMapper.selectNamedConstructorUser(1, "Panda");
    assert user != null;
}

上面的单元测试能够正确运行,表示mybatis是根据参数名称来匹配对应的构造方法的,那既然如此,如果我们有两个具有相同构造参数名称,但是不一样的构造方法会怎样呢?

嗯哼

说干就干,我们编写代码来测试一下这个场景:

@Data
public class Resource {
    private Integer id;
    private Integer pid;
    private String name;

    public Resource(Integer id, Integer pid, String name) {
        this.id = id;
        this.pid = pid;
        this.name = name;
    }

    public Resource(Integer id, String name, Integer pid) {
        this.id = id;
        this.pid = pid;
        this.name = name;
    }
}

仔细看,Resource拥有两个参数完全一致,但是参数位置不同的构造方法,然后我们再声明一个和上述两个构造方法参数位置都不一样的constructor配置:

<!-- 多个匹配的构造方法 -->
<resultMap id="namedConstructorResouce" type="org.apache.learning.result_map.constructor.Resource">
    <constructor>
        <arg column="name" name="name"/>
        <arg column="id" name="id"/>
        <arg column="pid" name="pid"/>
    </constructor>
</resultMap>
<select id="selectNamedConstructorResource" resultMap="namedConstructorResouce">
    SELECT *
    FROM RESOURCE R
    WHERE r.id = #{id}
</select>

提供一个单元测试:

@Test
public void selectNamedConstructorResource() {
    sqlSessionFactory.getConfiguration().addMapper(ConstructorMapper.class);
    @Cleanup
    SqlSession sqlSession = sqlSessionFactory.openSession();
    ConstructorMapper constructorMapper = sqlSession.getMapper(ConstructorMapper.class);
    Resource resource = constructorMapper.selectNamedConstructorResource(1);
    assert resource != null;
}

单元测试成功运行,这是为什么呢?

这是因为,在根据参数名称来匹配对应的构造方法时,如果有多个匹配的构造方法,那么第一个被匹配的构造方法生效.

结束

上面的内容就是constructor元素的相关知识了,涉及到的相关代码可以在gitee仓库中找到:gitee.com/topanda/myb…

砖厂繁忙,告辞!

完美谢幕

关注我,一起学习更多知识

关注我