一直对mybatis的sql的参数名(#{}
里面的字符串)感到模棱两可的,于是找时间跟踪阅读了下mybatis执行的源码,本文中的mybtis版本为3.5.0
。
源码阅读
1. 入口方法
假设查询方法代码为下面的代码:
1 |
|
代码片段1
那么跟踪dao.findList
方法就会进入到图1的MapperProxy.invoke
,从这一步开始,mybatis开始执行参数的解析。
图1 sql执行入口
图1中的代码主要执行2个方法:
创建
MapperMethod
,对应图1中的①②,这一步重点关注解析参数名。执行sql,对应图1中的③,这一步是继续下面的执行操作。
题外话:
可以看到mybatis的命名是比较容易表达代码意义的,首先通过Mapper(Dao接口)的代理MapperProxy
来启动sql的执行(invoke
),执行过程中,先创建MapperMethod
,就是创建Mapper的方法对象(Dao方法的对象)。
2. 解析方法的参数名(第一次涉及到参数名)
跟踪图1的②,创建MapperMethod
对象时会执行图2~图4的代码,先创建MethodSignature
对象,再在MethodSignature
中创建ParamNameResolver
对象。
题外话:
可以看下mybatis的命名,MethodSignature
就是方法签名,即方法名和参数,ParamNameResolver
是参数名解析,方法的命名都反应了代码意义。
图2 创建MapperSignature
跟踪图2代码new MethodSignature
进入图3,创建ParamNameResolver
对象。
图3 创建ParamNameResolver
主要看图4的ParamNameResolver
的创建,创建过程中会解析方法参数名,这个时候是对Dao方法的参数名解析。
① 查看参数是否标注了
@Param
注解,如果标注了,则使用注解自定的值(@Param的value()值)② 判断name是否为空,如果为空,即参数上没有@Param注解,那么再判断是否开启真实参数名获取,如果是则进行真实的参数名获取。如果是JDK8编译的,那么是有方法获取到实际方法名,即代码片段1中的
userId
,JDK7及以下就只能获取到arg0
(具体见文末补充说明)。③ 如果②中没有开启真实参数名获取,那么会使用参数顺序号,即 0,1,…
最后将参数名放入map,key就是遍历序号,即 0,1,…,这个方法就将参数名解析完毕,后面表示这些参数将使用上面面解析出来的参数名。来看看参数名可能出现的方法组合:[自定义参数名]
或者[userId]
或者[arg0]
或者[0]
(注意:最后这个[0]
在开启了真实参数名获取的情况下是不会出现的,默认是开启的,具体见文末补充说明)。
图4 解析参数名(第一次组装参数名)
3. 映射参数名和参数值(第二次涉及到参数名)
上述代码执行完成后,会回到图1的invoke
方法,此时再进入到图1的③的mapperMethod.execute
方法,当前以sql返回List为例,将执行到图5的executeForMany
方法。
图5 执行executeForMany(以返回List为例)
继续进入方法,将执行图6的①~图8的方法,这一步是将传入的参数值和前面解析的参数名对应上,并保存为参数名到参数值的映射对象。
图6的①的方法method.convertArgsToSqlCommandParam
将调用图7的paramNameResolver.getNamedParams
方法,paramNameResolver
就是前面图3创建的。
图6 执行转换参数
图7 执行获取参数命名
主要看图8,是图7中调用getNamedParams
方法的代码,该代码将前面第2步(解析方法的参数名)解析的参数名和对应的参数值建立起映射。
① 判断如果参数没有
@Param
注解并且参数只有一个,那么直接返回第一个参数值。name.firstKey()
返回的就是0
,names
是前面第2步解析的参数map,key为0,1,2 …,第一个就是0
,args
就是参数值数组。这种情况非常特殊,直接返回的参数对象,不是参数名到参数值的映射对象。② 如果执行if的另一分支,即没有@Param注解或者有多个参数,那么将创建参数名到参数值的映射对象,即map,比如参数名是
userId
,参数值为1,那么map中将是{userId=1}。前面第2步将参数名解析后是按照参数顺序放入TreeMap的,即图8中的names
,参数值也是按照参数顺序存放到args
中的,所以可以按照序号进行关联和映射。③ 除了②中的映射关系,还将
paramX
形式的参数名放入map,即param1
,param2
…,可以从代码看到如果参数名不包含paramX
对应的参数名,就会将paramX
的参数名放入。代码中的GENERIC_NAME_PREFIX
就是param
。
参数名和参数值映射完成,看看最后得到的映射可能的组合:{第一个参数值}
(特殊情况)、{userId=1, param1=1}
、{arg0=1, param1=1}
。
图8 映射参数名和参数值(第二次组装参数)
4. 重新映射集合或数组参数名(第三次涉及到参数名)
参数映射完成后,将回到图6继续执行sqlSession.<E>selectList
方法,即图9所示代码,其中parameter
是第3步返回的参数名到参数值映射对象。在执行executor.query
前还执行了wrapCollection
方法,其代码如图10所示。
图9 执行查询前包装集合
主要看图10,代码判断了参数值类型,如果参数值类型是集合或数组,就会重新做参数名和参数值的映射。这种情况只会对单个参数并且没有@Param注解产生效果(即上述第3步中第①中情况),因为只有这种情况下才有可能返回集合或数组类型的参数值(其他②和③的情况下返回的均为Map)。
执行完后,再看看最后得到的映射可能的组合:{第一个参数值}
(特殊情况)、{userId=1, param1=1}
、{arg0=1, param1=1}
、{collection=List}
、{list=List}
、{array=Array}
。
图10 映射集合或数组参数名(第三次涉及到参数名)
5. sql参数值装配
经过前面4步,参数名的解析和参数值的映射都完成了,继续跟踪代码,会执行图11的代码。代码执行会将前面得到的映射对象封装为统一访问对象
(MetaObject),然后再通过sql上的参数名在统一访问对象中查询参数值。
图11中的configuration.newMetaObject
就是将参数值封装为统一访问对象,这样不论是对象还是map都可以通过统一的getValue
方法获取到属性或者value值。之后,sql中的参数名就是通过在统一访问对象上执行getValue
方法获取。
图11 通过sql参数名获取参数值
执行getValue
方法会执行图12~图13的代码。
图12 获取参数值
主要看图13,sql上的参数名的解析,可以看到,将参数名按照.
或[
分开,然后得到name
和children
。完成后再回到图12的代码,对参数名进行递归执行getValue
(主要针对有.
或[]
的参数名),最终通过objectWrapper.get
方法(统一访问方法)获取到对应的参数值。
图13 解析sql中的参数名
最终可以看到,sql中的参数名能够对应上前面4步得到的映射对象中的参数名时才可以获取到参数值,也就是说经过这些步骤,Mapper方法参数名被解析成什么形式的参数名,sql中的参数名就必须写成什么形式,参数名的形式包括:对象属性
、map的key
、mapper方法参数名
、argX
、paramX
、collection
、list
、array
。
建议用法
源码阅读和代码执行过程还是比较耗时和复杂的,其实也不是必须把所有情况都记住的,下面给出了一般写法的建议。
单个参数
且没有@Param注解
且简单类型
,即Integer、String等非集合、非Map、非数组以及非bean对象的类型,那么sql的参数名无论写成什么都没有问题,因为单个无注解参数会直接返回参数值,和参数名没有关系(见图8的①)。单个参数
且类型为集合或数组
,建议Mapper方法使用@Param注解,这个时候参数名和参数值映射关系就变成了指定的参数名到参数值了,sql的参数名就是指定的参数名,如果不使用@Param注解参数名会是collection
、list
、array
,看起来不是太明确,但如果清楚其中的原由也是可以的。单个参数
且类型为bean对象或map
,建议Mapper方法的参数名不要使用@Param注解,这时sql的参数名可以直接使用bean对象属性或map的key。如果使用了@Param注解,这个时候参数名和参数值映射关系就变成了指定的参数名到参数值了,那么sql的参数名需要使用注解指定的参数名.对象属性
或者注解指定的参数名.map的key
,这样反而更麻烦了(见图8的②)。多个参数
,如果是JDK8编译的(开启了保留方法参数名配置),那么sql的参数名可以直接使用Dao方法的参数名,如果是JDK7及以下编译的,那么建议使用@Param注解指定参数名,否则需要使用argX或者paramX等形式的参数名,就太麻烦了。复杂参数类型
,sql的参数名(#{}
中的字符串)中“.
”表示调用对象属性值或者map的key对应的value值,“[]
”表示调用List或数组的下标对应的元素值(不能是Set)。比如:list[0].key
表示调用list的第一个元素,这个元素是个对象或map,有属性或key为key
,比如:obj.prop.list[0]
表示一个对象或者map的属性或key为prop
,prop对应的值是一个数组,然后调用这个数组的第一个元素。
补充说明
1. JDK8开启保留方法参数名配置
这是JDK8的一个新特性,即可以在代码编译后保留方法参数名。在编译时使用参数-parameters
即可,如果使用Intellij编码,则会默认开启,建议是开启这个功能,毕竟可以方便拍码。
如果项目使用的springboot2,那么这个参数也是默认开启的,可以参见spring-boot-starter-parent-2.x.x.RELEASE.xml
中的代码,如下:
1 | <plugin> |
2. mybatis使用实际方法名配置
图4的②判断是否使用真实的参数名,这个mybatis默认是开启的,即为true,需要关闭的话,设置参数use-actual-param-name
为false,不建议关闭这个功能。
3. mybatis的统一访问对象(MetaObject)
mybatis通过将参数值封装为MetaObject来达到统一访问参数值的目的,这种思路和模式可以继续研究和使用(图11)。
后续思考
刚开始看到图9的wrapCollection
方法要将集合或者数组重新做映射时还是很奇怪的,为什么mybatis要做这么一个如此不优雅的操作呢?后面看懂统一访问对象的原理后才知道,统一访问对象只能做到,将bean对象和map对象类型封装后,通过getValue
方法传入属性或key名称来获取属性或key对应的值。
而对于单个参数无注解的情况,后面代码会直接操作这个参数值,如果参数值是集合或数组被封装成统一访问对象了,那该怎么获取元素值呢?通过下标?再结合sql写法(如下代码),发现根本无法方便的编写sql的,我想mybatis也应该是因为这个原因才把集合或者数组重新映射吧。
而对应有注解或者有多个参数的时候,是肯定会映射到参数名的,所以也就不用担心无法使用的问题,此时wrapCollection
方法也是不会做重新映射操作的。
1 | <foreach collection="list" separator="," open="(" close=")" item="id"> |
仔细想想,造成要再执行一个如此不优雅的wrapCollection
方法最根本的原因还是因为图8的①的逻辑造成的,这里直接将第一个参数值返回了,这个参数值可能是集合或者数组,而这两种类型通过统一访问对象不容易操作,那么就强制重新映射吧。如果图8的①的逻辑和后面②的逻辑是一样,那么就不用再执行wrapCollection
了,估计mybatis是出于方便单个参数时写sql参数名吧,也许也是因为大多数情况下Mapper方法的参数都是单个吧。
不过从我现有的了解来看,我认为不应该在图8的①那里改变逻辑,这个操作应该放到图11去判断parameterObject
是否为集合或数组,如果是则直接让value=parameterObject
即可。由于我是跟踪代码阅读的,因此阅读并不是非常全面,也许有不知道的其他地方还有的用处吧。