• 第04篇_MyBatis源码解析

    第01章_配置文件解析

    第一节 配置文件解析

    1. 配置文件解析入口

    在单独使用 MyBatis 时,第一步要做的事情就是根据配置文件构建SqlSessionFactory对象。

    显然,这里的 build 方法是我们分析核心配置文件解析过程的入口方法。

    从上面的代码中,我们大致可以猜出 MyBatis 配置文件是通过XMLConfigBuilder进行解析的。不过目前这里还没有非常明确的解析逻辑,所以我们继续往下看parse方法。

    到这里大家可以看到一些端倪了,注意一个 xpath 表达式/configuration。这个表达式代表的是 MyBatis 的<configuration/>标签,这里选中这个标签,并传递给parseConfiguration方法。我们继续跟下去。

    到此,一个 MyBatis 的解析过程就出来了,每个配置的解析逻辑都封装在了相应的方法中。在下面分析过程中,我不打算按照方法调用的顺序进行分析,我会适当进行一定的调整。同时,MyBatis 中配置较多,对于一些不常用的配置,这里会略过。

     

     

    2. 解析 properties 配置

    properties 节点的配置内容示例如下:

    参照上面的配置,来分析一下 propertiesElement 的逻辑:

    上面是 properties 节点解析的主要过程,不是很复杂,但需要注意一点,通过 resource 和 url 引用外部Props文件中的属性会覆盖掉子节点配置的属性。

     

     

    3. 解析 settings 配置

    settings 相关配置是 MyBatis 中非常重要的配置,这些配置用于调整 MyBatis 运行时的行为。settings 配置繁多,在对这些配置不熟悉的情况下,保持默认配置即可,下面先来看一个比较简单的配置。

    接下来,对照上面的配置,来分析settingsAsProperties方法源码,并不复杂,只是将setting子节点配置的属性转换为Properties而已。

    注意:

    1. MetaClass:用来解析目标类的一些元信息,比如类的成员变量,getter/setter 方法等,文末将会专题介绍。

    转换出来的 Properties 要有一个存放的地方,以使其他代码可以找到这些配置。这个存放地方就是 Configuration 对象,下面就来看一下将 settings 配置设置到 Configuration 对象中的过程。

    上面代码就是调用 Configuration 的 setter 方法,就没太多逻辑了。重点需要注意一个resolveClass方法,它的源码如下:

    这里出现了一个新的类TypeAliasRegistry,用途就是将别名和类型进行映射,这样就可以用别名表示某个类了,方便使用。既然聊到了别名,那下面我们不妨看看别名的配置的解析过程。

     

     

    4. 解析 typeAliases 配置

    在MyBatis中,可以为类定义一个简短的别名,在书写配置的时候使用别名来配置,MyBatis在解析配置时会自动将别名替换为对应的全类名。有两种配置方式,第一种是按包进行配置,MyBatis会扫描包路径下的所有类(忽略匿名类/接口/内部类)自动生成别名(可以配合Alias注解自定义别名)。

    另一种方式是通过手动的方式,明确为某个类配置别名。

    下面我们来看一下两种不同的别名配置是怎样解析的。

    上面的代码通过一个if-else条件分支来处理两种不同的配置,这里我用⭐️标注了出来。下面我们来分别看一下这两种配置方式的解析过程,首先来看一下手动配置方式的解析过程。

     

    1) 从 typeAlias 节点中解析并注册别名

    在别名的配置中,type属性是必须要配置的,而alias属性则不是必须的。这个在配置文件的 DTD 中有规定。如果使用者未配置 alias 属性,则需要 MyBatis 自行为目标类型生成别名。对于别名为空的情况,注册别名的任务交由void registerAlias(Class<?>)方法处理。若不为空,则由void registerAlias(String, Class<?>)进行别名注册。这两个方法的分析如下:

    如上,若用户为明确配置 alias 属性,MyBatis 会使用类名的小写形式作为别名。比如,全限定类名xyz.coolblog.model.Author的别名为author。若类中有@Alias注解,则从注解中取值作为别名。

     

    2) 从指定的包中解析并注册别名

    从指定的包中解析并注册别名过程主要由别名的解析和注册两步组成。下面来看一下相关代码:

    上面的代码不多,相关流程也不复杂,可简单总结为下面两个步骤:

    1. 查找指定包下的所有类

    2. 遍历查找到的类型集合,为每个类型注册别名

    在这两步流程中,第2步流程对应的代码上一节已经分析过了,这里不再赘述。第1步的功能理解起来不难,但是背后对应的代码有点多。限于篇幅原因,这里我不打算详细分析这一部分的代码,只做简单的流程总结。如下:

    1. 通过 VFS(虚拟文件系统)获取指定包下的所有文件的路径名,比如xyz/coolblog/model/Article.class

    2. 筛选以.class结尾的文件名

    3. 将路径名转成全限定的类名,通过类加载器加载类名

    4. 对类型进行匹配,若符合匹配规则,则将其放入内部集合中

    以上就是类型资源查找的过程,并不是很复杂,大家有兴趣自己看看吧。

     

    3) 注册 MyBatis 内部类及常见类型的别名

    最后,我们来看一下一些 MyBatis 内部类及一些常见类型的别名注册过程。如下:

    好了,以上就是别名解析的全部流程,大家看懂了吗?如果觉得没啥障碍的话,那继续往下看呗。

     

     

    5. 解析 plugins 配置

    插件是 MyBatis 提供的一个拓展机制,通过插件机制我们可在 SQL 执行过程中的某些点上做一些自定义操作。实现一个插件需要比简单,首先需要让插件类实现Interceptor接口。然后在插件类上添加@Intercepts@Signature注解,用于指定想要拦截的目标方法。MyBatis 允许拦截下面接口中的一些方法:

    可拦截的类类中可拦截的方法
    Executorupdate/query/flushStatements/commit/rollback/getTransaction/close/isClosed
    ParameterHandlergetParameterObject/setParameters
    ResultSetHandlerhandleResultSets/handleOutputParameters
    StatementHandlerprepare/parameterize/batch/update/query

    比较常见的插件有分页插件、分表插件等,有兴趣的朋友可以去了解下。本节我们来分析一下插件的配置的解析过程,先来了解插件的配置。如下:

    解析过程分析如下:

    如上,插件解析的过程还是比较简单的。首先是获取配置,然后再解析拦截器类型,并实例化拦截器。最后向拦截器中设置属性,并将拦截器添加到 Configuration 中。好了,关于插件配置的分析就先到这,继续往下分析。

     

     

    6. 解析 environments 配置

    在 MyBatis 中,事务管理器和数据源是配置在 environments 中的。它们的配置大致如下:

    接下来我们对照上面的配置进行分析,如下:

    environments 配置的解析过程没什么特别之处,按部就班解析就行了,不多说了。

     

     

    7. 解析 typeHandlers 配置

    我们在向数据库存取数据时,需要将数据库字段类型Java类型进行相互转换,处理这个转换的模块就是类型处理器TypeHandler。下面,我们来看一下类型处理器的配置方法:

    下面开始分析代码。

    上面的代码中调用了 4 个重载的处理器注册方法,这些注册方法的逻辑不难理解,但之间的调用关系复杂,下面是它们的调用关系图。其中蓝色背景框内的方法称为开始方法,红色背景框内的方法称为终点方法,白色背景框内的方法称为中间方法。

    img

    下面我会分析从每个开始方法向下分析,为了避免冗余分析,我会按照③ → ② → ④ → ①的顺序进行分析。

     

    1) register(Class, JdbcType, Class)

    当代码执行到此方法时,表示javaTypeClass != null && jdbcType != null条件成立,即明确配置了javaTypejdbcType

    类型处理器的实际注册过程是在该终点方法完成的,就是把类型处理器进行双层映射而已,外层映射是JavaType和多个JdbcType的映射,内层映射是JdbcType和TypeHandler的映射。

     

    2) register(Class, Class)

    当代码执行到此方法时,表示javaTypeClass != null && jdbcType == null条件成立,即仅设置了javaType

    上面代码主要做的事情是尝试从注解中获取JdbcType的值,然后调用终点方法注册。(注意JdbcType可以配置为NULL。

     

    3) register(Class)

    当代码执行到此方法时,表示javaTypeClass == null条件成立,即javaTypejdbcType都未配置。

    上面的代码主要用于解析javaType,优先通过@MappedTypes注解来解析,其次使用反射来获取javaType。不管是通过哪种方式,解析完成后都会调用中间方法register(Type, TypeHandler),这个方法负责解析jdbcType,在上一节已经分析过。一个负责解析 javaType,另一个负责解析 jdbcType,逻辑比较清晰了。

     

    4) register(String)

    该方法主要是用于自动扫描类型处理器,并调用其他方法注册扫描结果,注册时忽略内部类,接口,抽象类等。

     

     

    8. 解析 mappers 配置

    mappers标签主要用于指定映射信息的存放位置,这些映射信息可以是注解形式或XML配置形式。

    上面代码主要逻辑是遍历 mappers 的子节点,并根据节点属性值判断通过什么方式加载映射文件或映射信息。

     

     

    第二节 映射文件解析

    1. 映射文件解析入口

    在展开映射文件的解析之前,先来看一下映射文件的解析入口。如下:

    如上,映射文件解析入口逻辑包含三个核心操作,分别如下:

    1. 解析 mapper 节点

    2. 通过命名空间绑定 Mapper 接口

    3. 处理未完成解析的节点

    这三个操作对应的逻辑,我将会在随后的章节中依次进行分析。下面,先来分析第一个操作对应的逻辑,下面是一个映射文件配置示例。

    上面是一个比较简单的映射文件,还有一些的节点没有出现在上面。以上每种配置中的每种节点的解析逻辑都封装在了相应的方法中,这些方法由 XMLMapperBuilder 类的 configurationElement 方法统一调用。

    下面将会先分析 <cache> 节点的解析过程,然后再分析 <cache-ref> 节点,之后会按照顺序分析其他节点的解析过程。

     

    2. 解析 cache 节点

    MyBatis 提供了一级/二级缓存,其中一级缓存是 SqlSession 级别的,默认为开启状态。二级缓存配置在映射文件中,需要显示配置开启。

    除此之外,还可以给 MyBatis 配置第三方缓存或者自己实现的缓存等。比如,我们将 Ehcache 缓存整合到 MyBatis 中,可以这样配置。

    下面来分析一下缓存配置的解析逻辑,如下:

    上面代码中,大段代码用来解析 <cache> 节点的属性和子节点,这些代码没什么好说的。缓存的构建逻辑封装在 BuilderAssistant 类的 useNewCache 方法中,下面我们来看一下该方法的逻辑。

    上面使用了建造模式构建 Cache 实例,Cache 实例的构建过程略为复杂,我们跟下去看看。

    上面的构建过程流程较为复杂,这里总结一下。如下:

    1. 设置默认的缓存类型及装饰器

    2. 应用装饰器到 PerpetualCache 对象上

      • 遍历装饰器类型集合,并通过反射创建装饰器实例

      • 将属性设置到实例中

    3. 应用一些标准的装饰器

    4. 对非 LoggingCache 类型的缓存应用 LoggingCache 装饰器

    在以上4个步骤中,最后一步的逻辑很简单,无需多说。下面按顺序分析前3个步骤对应的逻辑,如下:

    以上逻辑比较简单,主要做的事情是在 implementation 为空的情况下,为它设置一个默认值。如果大家仔细看前面的方法,会发现 MyBatis 做了不少判空的操作。比如:

    既然前面已经做了两次判空操作,implementation 不可能为空,那么 setDefaultImplementations 方法似乎没有存在的必要了。其实不然,如果有人不按套路写代码。比如:

    这里忘记设置 implementation,或人为的将 implementation 设为空。如果不对 implementation 进行判空,会导致 build 方法在构建实例时触发空指针异常,对于框架来说,出现空指针异常是很尴尬的,这是一个低级错误。这里以及之前做了这么多判空,就是为了避免出现空指针的情况,以提高框架的健壮性。好了,关于 setDefaultImplementations 方法的分析先到这,继续往下分析。

    我们在使用 MyBatis 内置缓存时,一般不用为它们配置自定义属性。但使用第三方缓存时,则应按需进行配置。比如前面演示 MyBatis 整合 Ehcache 时,就为 Ehcache 配置了一些必要的属性。下面我们来看一下这部分配置是如何设置到缓存实例中的。

    上面的大段代码用于对属性值进行类型转换,和设置转换后的值到 Cache 实例中。关于上面代码中出现的 MetaObject,大家可以自己尝试分析一下。最后,我们来看一下设置标准装饰器的过程。如下:

    以上代码为缓存应用了一些基本的装饰器,但除了 LoggingCacheSynchronizedCache 这两个是必要的装饰器外,其他的装饰器应用与否,取决于用户的配置。

     

     

    3. 解析 cache-ref 节点

    在 MyBatis 中,二级缓存是可以共用的。这需要使用 <cache-ref> 节点配置参照缓存,比如像下面这样。

    接下来,我们对照上面的配置分析 cache-ref 的解析过程。如下:

    如上所示,<cache-ref> 节点的解析逻辑封装在了 CacheRefResolverresolveCacheRef 方法中。

    关于XML和注解中同时配置缓存的问题?

    1. XML和注解不能同时开启缓存。会报 IllegalArgumentException: Caches collection already contains value for org.example.dao.XxxMapper错误。因为缓存对象创建后会被添加到Configuration中,而保存所有cache对象的是一个MyBatis自定义的StrictMap类型,该类型继承自HashMap,在put时会校验元素是否已存在。

    2. 其中一方开启缓存,另一方不能直接使用。由于XML解析和注解解析映射配置时分别创建了两个不同的对象(XmlMapperBuilder和MapperAnnotationBuilder类型),所以它们的内部类MapperBuilderAssistant中保存的currentCache(在解析cache节点时将创建的cache对象设置到currentCache)是两个不同的引用,因此由不同对象构建的MapperStatement(不能在XML和注解中配置同一个MapperStatement),保存了各自的cache对象。从而在查找二级缓存时,只能查找配置了cache节点的那一方。

    3. 可以使用缓存引用来解决上述问题。因为在缓存引用解析的过程中,会查找对应的cache设置到currentCache,后续构建MapperStatement时会保存此引用。

     

     

    4. 解析 resultMap 节点

    resultMap 是 MyBatis 框架中最重要的特性,主要用于映射结果,下面开始分析 resultMap 配置的解析过程。

    上面的代码比较多,看起来有点复杂,这里总结一下:

    1. 获取 <resultMap> 节点的各种属性

    2. 遍历 <resultMap> 的子节点,并根据子节点名称执行相应的解析逻辑

    3. 构建 ResultMap 对象

    4. 若构建过程中发生异常,则将 resultMapResolver 添加到 incompleteResultMaps 集合中

    如上流程,第1步和最后一步都是一些常规操作,无需过多解释。第2步和第3步则是接下来需要重点分析的操作,这其中,鉴别器 discriminator 不是很常用的特性,我觉得大家知道它有什么用就行了,所以就不分析了。

     

    1) 解析 id 和 result 节点

    <resultMap> 节点中,子节点 <id><result> 都是常规配置,比较常见。下面我们直接分析这两个节点的解析过程。

    上面的方法主要用于获取 <id><result> 节点的属性,其中,resultMap 属性的解析过程要相对复杂一些。该属性存在于 <association><collection> 节点中。下面以 <association> 节点为例,演示该节点的两种配置方式,分别如下:

    第一种配置方式是通过 resultMap 属性引用其他的 <resultMap> 节点,配置如下:

    第二种配置方式是采取 resultMap 嵌套的方式进行配置,如下:

    如上配置所示,<association> 的子节点也是一些结果映射配置,这些结果配置最终也会被解析成 ResultMap。

    如上,这些嵌套映射配置也是由 resultMapElement 方法解析的,并在最后返回 resultMap.id设置到主映射中。

    关于嵌套 resultMap 的解析逻辑就先分析到这,下面分析 ResultMapping 的构建过程。

    ResultMapping 的构建过程不是很复杂,主要过程说明如下:

    1. 获取映射属性名的 java 类型。

    2. 根据配置的 typeHandler 属性创建类型处理器实例。

    3. 处理复合 column。

    4. 通过建造器构建 ResultMapping 实例。

    关于上面方法中出现的一些方法调用,这里接不跟下去分析了,大家可以自己看看。

     

    2) 解析 constructor 节点

    constructor节点用于自定义映射对象的构造过程,可以通过有参构造来初始化构造的对象。有如下Java类。

    ArticleDO 的构造方法对应的配置如下:

    下面分析 constructor 节点的解析过程。

    首先是获取并遍历子节点列表,然后为每个子节点创建 flags 集合,并添加 CONSTRUCTOR 标志。对于 idArg 节点,额外添加 ID 标志。最后一步则是构建 ResultMapping,该步逻辑前面已经分析过,这里就不多说了。

     

    3) ResultMap 对象构建过程分析

    分析完 <resultMap> 的子节点 <id><result> 以及 <constructor> 的解析过程,下面来看看 ResultMap 实例的构建过程。下面是之前分析过的 ResultMap 构建的入口。

    ResultMap 的构建逻辑封装在 ResultMapResolverresolve 方法中,下面从该方法进行分析。

    上面的方法将构建 ResultMap 实例的任务委托给了 MapperBuilderAssistantaddResultMap,我们跟进到这个方法中看看。

    上面的方法主要用于合并 extend 属性指定的扩展映射,并删除一些多余的映射列。随后,通过建造模式构建 ResultMap 实例。

    以上代码看起来很复杂,但实际上只是将 ResultMapping 实例及属性分别存储到不同的集合中而已。写点代码测试一下,并把这些集合的内容打印到控制台上,大家直观感受一下。先定义一个映射文件,如下:

    测试代码如下:

    结果如下:

    image-20211023152052569

     

     

    5. 解析 sql 节点

    <sql> 节点用来定义一些可重复使用的 SQL 语句片段,如表名,或表的列名等。在映射文件中,可以通过 <include> 节点引用 <sql> 节点定义的内容。下面是 <sql> 节点的使用方式,如下:

    下面分析一下 sql 节点的解析过程,如下:

    这里需注意下 databaseId 属性的特殊处理,后面会多次用到。MyBatis一般采用 两次调用的方式来处理databaseId 问题,第一次带上下文中的数据库厂商调用,第二次使用NULL调用。即优先解析匹配数据库厂商标识的标签,如果不存在匹配的,则解析不带数据库厂商标识的标签。继续往下分析。

    首先是获取 <sql> 节点的 id 和 databaseId 属性,然后为 id 属性值拼接命名空间。最后,通过检测当前 databaseId 和 requiredDatabaseId 是否一致,来决定保存还是忽略当前的 <sql> 节点。

    下面,我们来看一下 databaseId 的匹配逻辑是怎样的。

    下面总结一下 databaseId 的匹配规则。

    1. databaseId 与 requiredDatabaseId 不一致,即失配,返回 false

    2. 当前节点与之前的节点出现 id 重复的情况,若之前的 <sql> 节点 databaseId 属性不为空,返回 false。

    3. 若以上两条规则均匹配失败,此时返回 true

    在上面三条匹配规则中,第二条规则稍微难理解一点。这里简单分析一下,考虑下面这种配置。

    在上面配置中,两个 <sql> 节点的 id 属性值相同,databaseId 属性不一致。假设 configuration.databaseId = mysql,第一次调用 sqlElement 方法,第一个 <sql> 节点对应的 XNode 会被放入到 sqlFragments 中。第二次调用 sqlElement 方法时,requiredDatabaseId 参数为空。由于 sqlFragments 中已包含了一个 id 节点,且该节点的 databaseId 不为空,此时匹配逻辑返回 false,第二个节点不会被保存到 sqlFragments。

     

     

    6. 解析 statement 节点

    Statement节点指 SQL 语句节点,包括用于查询的<select>节点,以及执行更新和其它类型语句<update><insert><delete>节点,四者配置方式非常相似,因此放在一起进行解析。

    上面的解析方法没有什么实质性的解析逻辑,我们继续往下分析。

    上面的代码中大都是用来获取节点属性,以及解析部分属性等,抛去这部分代码,以上代码做的事情如下。

    1. 解析 <include> 节点。

    2. 解析 <selectKey> 节点。

    3. 解析 SQL,获取 SqlSource。

    4. 构建 MappedStatement 实例。

    以上流程对应的代码比较复杂,每个步骤都能分析出一些东西来,下面我会每个步骤都进行分析。

     

    1) 解析 include 节点

    <include> 节点的解析逻辑封装在 applyIncludes 中,该方法的代码如下:

    由于解析 include 节点时会向 Properties 中添加新的元素,为了防止全局属性被污染,因此先创建了一个临时的 Properties,传给重载 applyIncludes 方法的使用。

    上面的代码由三个分支语句,外加两个递归调用组成,理解起来有一定难度,下面将结合案例来进行讲解。

    我们先来看一下 applyIncludes 方法第一次被调用时的状态,如下:

    第一次调用 applyIncludes 方法,source = <select>,代码进入条件分支2。在该分支中,首先要获取 <select> 节点的子节点列表。可获取到的子节点如下:

    编号子节点类型描述
    1SELECT id, title FROMTEXT_NODE文本节点
    2<include refid="table"/>ELEMENT_NODE普通节点
    3WHERE id = #{id}TEXT_NODE文本节点

    在获取到子节点类列表后,接下来要做的事情是遍历列表,然后将子节点作为参数进行递归调用。在上面三个子节点中,子节点1和子节点3都是文本节点,调用过程一致。下面先来看下子节点1的调用过程,如下:

    image-20211025200756919

    然后我们在看一下子节点2的调用过程,如下:

    image-20211025200828651

     

     

    2) 解析 selectKey 节点

    <selectKey>可以在主语句执行之前或之后执行额外的查询操作。一般用于在插入数据前查询主键值,这对一些不支持主键自增的数据库来说非常实用。

    下面我们来看一下 <selectKey> 节点的解析过程。

    selectkey节点解析完成后,会被从 dom 树中移除,这样后续可以更专注的解析 <insert><update> 节点中的 SQL,无需再额外处理 <selectKey> 节点。

    以上代码比较重要的步骤如下:

    1. 创建 SqlSource 实例

    2. 构建并缓存 MappedStatement 实例

    3. 构建并缓存 SelectKeyGenerator 实例

    第1步和第2步调用的是公共逻辑,其他地方也会调用,这两步对应的源码后续会分两节进行讲解。第3步则是创建一个 SelectKeyGenerator 实例,SelectKeyGenerator 创建的过程本身没什么好说的,所以就不多说了。

     

    3) 解析 SQL 语句

    前面分析了 <include><selectKey> 节点的解析过程,这两个节点解析完成后,都会以不同的方式从 dom 树中消失。所以目前的 SQL 语句节点由一些文本节点和普通节点组成,比如 <if><where> 等。那下面我们来看一下移除掉 <include><selectKey> 节点后的 SQL 语句节点是如何解析的。

    SQL 语句的解析逻辑被封装在了 XMLScriptBuilder 类的 parseScriptNode 方法中。该方法首先会调用 parseDynamicTags 解析 SQL 语句节点,在解析过程中,会判断节点是是否包含一些动态标记,比如 ${} 占位符以及动态 SQL 节点等。若包含动态标记,则会将 isDynamic 设为 true。后续可根据 isDynamic 创建不同的 SqlSource。下面,我们来看一下 parseDynamicTags 方法的逻辑。

    上面的代码主要是用来判断节点是否包含一些动态标记,比如 ${} 占位符以及动态 SQL 节点等。这里,不管是动态 SQL 节点还是静态 SQL 节点,我们都可以把它们看成是 SQL 片段,一个 SQL 语句由多个 SQL 片段组成。在解析过程中,这些 SQL 片段被存储在 contents 集合中。最后,该集合会被传给 MixedSqlNode 构造方法,用于创建 MixedSqlNode 实例。从 MixedSqlNode 类名上可知,它会存储多种类型的 SqlNode。除了上面代码中已出现的几种 SqlNode 实现类,还有一些 SqlNode 实现类未出现在上面的代码中。但它们也参与了 SQL 语句节点的解析过程,这里我们来看一下这些幕后的 SqlNode 类。

    img

    上面的 SqlNode 实现类用于处理不同的动态 SQL 逻辑,这些 SqlNode 是如何生成的呢?答案是由各种 NodeHandler 生成。我们再回到上面的代码中,可以看到这样一句代码:

    该代码用于处理动态 SQL 节点,并生成相应的 SqlNode。下面来简单分析一下 WhereHandler 的代码。

    如上,handleNode 方法内部会再次调用 parseDynamicTags 解析 <where> 节点中的内容(即子标签),这样又会生成一个 MixedSqlNode 对象。最终,整个 SQL 语句节点会生成一个具有树状结构的 MixedSqlNode。如下图:

    img

    到此,SQL 语句的解析过程就分析完了。现在,我们已经将 XML 配置解析了 SqlSource,但这还没有结束。SqlSource 中只能记录 SQL 语句信息,除此之外,这里还有一些额外的信息需要记录。因此,我们需要一个类能够同时存储 SqlSource 和其他的信息。这个类就是 MappedStatement。下面我们来看一下它的构建过程。

     

    4) 构建 MappedStatement

    SQL 语句节点可以定义很多属性,这些属性和属性值最终存储在 MappedStatement 中。下面我们看一下 MappedStatement 的构建过程是怎样的。

    上面就是 MappedStatement的构建过程,逻辑比较简单,没什么好说的。但有一个地方需要注意,构建时用了MapperBuilderAssistant类中的currentCache,改变量是局部的,导致了名称空间相同的XML和注解的缓存配置不能共享。

     

     

    7. mapper接口的绑定过程分析

    映射文件解析完成后,并不意味着整个解析过程就结束了。此时还需要通过命名空间绑定 mapper 接口,这样才能将映射文件中的 SQL 语句和 mapper 接口中的方法绑定在一起,后续即可通过调用 mapper 接口方法执行与之对应的 SQL 语句。下面我们来分析一下 mapper 接口的绑定过程。

    以上就是 Mapper 接口的绑定过程。这里简单一下:

    1. 获取命名空间,并根据命名空间解析 mapper 类型

    2. 将 type 和 MapperProxyFactory 实例存入 knownMappers

    3. 解析注解中的信息

    以上步骤中,第3步的逻辑较多。如果大家看懂了映射文件的解析过程,那么注解的解析过程也就不难理解了,这里就不深入分析了。好了,Mapper 接口的绑定过程就先分析到这。

     

     

    8. 处理未完成解析的节点

    在解析某些节点的过程中,如果这些节点引用了其他一些未被解析的配置,会导致当前节点解析工作无法进行下去。对于这种情况,MyBatis 的做法是抛出 IncompleteElementException 异常。外部逻辑会捕捉这个异常,并将节点对应的解析器放入 incomplet* 集合中。下面我们来看一下 MyBatis 是如何处理未完成解析的节点。

    从上面的源码中可以知道有三种节点在解析过程中可能会出现不能完成解析的情况,相关代码逻辑类似,下面以 parsePendingCacheRefs 方法为例进行分析,看一下如何配置映射文件会导致 <cache-ref> 节点无法完成解析。

    假设 MyBatis 先解析映射文件1,然后再解析映射文件2。按照这样的解析顺序,映射文件1中的 <cache-ref> 节点就无法完成解析,因为它所引用的缓存还未被解析。当映射文件2解析完成后,MyBatis 会调用 parsePendingCacheRefs 方法处理在此之前未完成解析的 <cache-ref> 节点。具体的逻辑如下:

     

     

    第02章_SQL语句执行过程

    本章节较为详细的介绍了 MyBatis 执行 SQL 的过程,包括但不限于 Mapper 接口代理类的生成、接口方法的解析、SQL 语句的解析、运行时参数的绑定、查询结果自动映射、关联查询、嵌套映射、懒加载等。下面一张图总结了MyBatis执行SQL的过程中涉及到的主要组件,把MyBatis框架分了为数层,每层都有相应的功能,其中 MyBatis 框架层又可细分为会话层执行器层JDBC处理层等。

    img

     

    最外层是与我们业务代码打交道的DAO层,也称为 Mapper 层,通过动态代理技术,简化了用户的持久层操作,主要做了接口方法解析、参数转换等工作。

    会话层主要封装了执行器,提供了各种语义清晰的方法,供使用者调用。执行器层用于协调其它组件以及实现一些公共逻辑,如一二级缓存、获取连接、转换和设置参数、映射结果集等,做的事情较多。JDCB处理层主要是与 JDBC 层面的接口打交道。

    除此之外,一些其它组件也起到了重要的作用。如 ParameterHandler 和 ResultSetHandler,一个负责向 SQL 中设置运行时参数,另一个负责处理 SQL 执行结果,它们俩可以看做是 StatementHandler 辅助类。

    最后看一下右边横跨数层的类,Configuration 是一个全局配置类,很多地方都依赖它。MappedStatement 对应 SQL 配置,包含了 SQL 配置的相关信息。BoundSql 中包含了已完成解析的 SQL 语句,以及运行时参数等。

    下面我们将从一个简单案例开始,逐步分析上述提到的一些组件。

     

     

    第一节 SQL执行入口分析

    在单独使用 MyBatis 进行数据库操作时,我们通常都会先调用 SqlSession 接口的 getMapper方法为我们的 Mapper 接口生成实现类,然后就可以通过 Mapper 进行数据库操作了。

    在执行操作时,方法调用会被代理逻辑拦截。在代理逻辑中可根据方法名及方法归属接口获取到当前方法对应的 SQL 以及其他一些信息,拿到这些信息即可进行数据库操作。下面就来看看实现类是如何生成的,以及代理逻辑是怎么样的?

     

     

    1. 为 Mapper 接口创建代理对象

    首先从 DefaultSqlSession 的 getMapper 方法开始看起,如下:

    经过连续的简单调用后,获取到了Mapper接口对应 MapperProxyFactory 对象,然后就可调用工厂方法为 Mapper 接口生成代理对象了

    上面的代码首先创建了一个 MapperProxy 对象,该对象实现了 InvocationHandler 接口。然后将对象作为参数传给重载方法,并在重载方法中调用 JDK 动态代理接口为 Mapper 生成代理对象。

    到此,关于 Mapper 接口代理对象的创建过程就分析完了。现在我们的 ArticleMapper 接口指向的代理对象已经创建完毕,下面就可以调用接口方法进行数据库操作了。由于接口方法会被代理逻辑拦截,所以下面我们把目光聚焦在代理逻辑上面,看看代理逻辑会做哪些事情。

     

    2. 执行代理逻辑

    Mapper 接口方法的代理逻辑实现的比较简单,首先会对拦截的方法进行一些检测,以决定是否执行后续的数据库操作。

    如上,代理逻辑会首先检测被拦截的方法是不是定义在 Object 中的,比如 equals、hashCode 方法等。对于这类方法,直接执行即可。除此之外,MyBatis 从 3.4.2 版本开始,对 JDK 1.8 接口的默认方法提供了支持,具体就不分析了。完成相关检测后,紧接着从缓存中获取或者创建 MapperMethod 对象,然后通过该对象中的 execute 方法执行 SQL。在分析 execute 方法之前,我们先来看一下 MapperMethod 对象的创建过程。MapperMethod 的创建过程看似普通,但却包含了一些重要的逻辑,所以不能忽视。

     

    1) 创建 MapperMethod 对象|获取形参

    本节来分析一下 MapperMethod 的构造方法,看看它的构造方法中都包含了哪些逻辑。如下:

    如上,MapperMethod 构造方法的逻辑很简单,主要是创建 SqlCommandMethodSignature 对象。这两个对象分别记录了不同的信息,这些信息在后续的方法调用中都会被用到。下面我们深入到这两个类的构造方法中,探索它们的初始化逻辑。

     

    1) 创建 SqlCommand 对象

    前面说了 SqlCommand 中保存了一些和 SQL 相关的信息,那具体有哪些信息呢?答案在下面的代码中。

    如上,SqlCommand 的构造方法主要用于初始化它的两个成员变量。代码不是很长,逻辑也不难理解,就不多说了。继续往下看。

     

    2) 创建 MethodSignature 对象

    MethodSignature 即方法签名,顾名思义,该类保存了一些和目标方法相关的信息。比如目标方法的返回类型,目标方法的参数列表信息等。下面,我们来分析一下 MethodSignature 的构造方法。

    上面的代码用于检测目标方法的返回类型,以及解析目标方法参数列表。其中,检测返回类型的目的是为避免查询方法返回错误的类型。比如我们要求接口方法返回一个对象,结果却返回了对象集合,这会导致类型转换错误。关于返回值类型的解析过程先说到这,下面分析参数列表的解析过程。

    以上就是方法参数列表的解析过程,解析完毕后,可得到参数下标到参数名的映射关系,这些映射关系最终存储在 ParamNameResolvernames 成员变量中。这些映射关系将会在后面的代码中被用到,大家留意一下。

    下面写点代码测试一下 ParamNameResolver 的解析逻辑。如下:

    测试结果如下:

    到此,关于 MapperMethod 的初始化逻辑就分析完了,继续往下分析。

     

     

    2) 执行 execute 方法|转换实参

    前面已经分析了 MapperMethod 的初始化过程,现在 MapperMethod 创建好了。那么,接下来要做的事情是调用 MapperMethod 的 execute 方法,执行 SQL。代码如下:

    如上,execute 方法主要由一个 switch 语句组成,用于根据 SQL 类型执行相应的数据库操作。该方法的逻辑清晰,不需要太多的分析。不过在上面的方法中 convertArgsToSqlCommandParam 方法出现次数比较频繁,这里分析一下:

    上节中讲解的 ParamNameResolver 构造函数建立了形参索引参数名的映射names,而本节的 getNamedParams 方法根据names从传入的实参对象中取参数值返回。有两种情形:

    1. 单个非特殊参数且无@Param注解。直接返回传入的实参对象。

    2. 有@Param参数或存在多个参数。逐个添加[参数名-参数值]到 ParamMap 后返回。同时为参数名添加一份固定名称paramXxx。

     

     

    第二节 查询语句的执行过程分析

    在JDBC中,将SELECT语句归类为查询语句,将其它一些语句(如UPDATE、DDL等)归类为更新语句。在本节中,先对查询语句的执行流程进行讲解。从上节MapperMathod代码可以看到,查询语句根据返回值类型以及是否使用ResultHandler处理结果大致分为了以下几类:

    这些方法在内部都是调用了 SqlSession 中的 selectXxxx 方法,比如 selectList、selectMap、selectCursor 等。而其中最常用的 selectList 被 selectOne 方法调用。因此我们从该方法来看看查询语句执行的主体流程。

     

    1. 查询语句执行主流程

    在 MapperMethod 执行查询语句时,当返回值不是List、Map或Cursor且没有使用结果处理器时,会调用 selectOne 方法进行查询。

    下面我们来看看 selectList 方法的实现。

    这里注意一下执行器 Executor,这是MyBatis中的一个重要组件。Executor 是一个接口,它的实现类如下:

    image-20211103201505987

    具体使用哪个Excutor实现类,可以在打开会话(openSession)时指定,也可以在MyBatis全局配置文件中修改默认的执行器类型。Excutor 默认为SimpleExcutor。如果开启了全局属性cacheEnabled,则还会被CachingExcutor所装饰(详情见newExecutor方法)。

    上面的代码用于获取 BoundSql 对象,创建 CacheKey 对象,然后再将这两个对象传给重载方法。关于 BoundSql 的获取过程较为复杂,我将在下一节进行分析。CacheKey 以及接下来即将出现的一二级缓存将会独立成文进行分析。

    上面的方法和 SimpleExecutor 父类 BaseExecutor 中的实现没什么区别,有区别的地方在于这个方法所调用的重载方法。我们继续往下看。

    上面的代码涉及到了二级缓存,若二级缓存为空,或未命中,则调用被装饰类的 query 方法。下面来看一下 BaseExecutor 的中签名相同的 query 方法是如何实现的。

    如上,上面的方法主要用于从一级缓存中查找查询结果。若缓存未命中,再向数据库进行查询。在上面的代码中,出现了一个新的类 DeferredLoad,这个类用于延迟加载,后面将会分析。现在先来看一下 queryFromDatabase 方法的实现。

    抛开缓存操作,queryFromDatabase 最终还会调用 doQuery 进行查询。下面我们继续进行跟踪。

    我们先跳过 StatementHandler 和 Statement 创建过程,这两个对象的创建过程会在后面进行说明。这里先看看Statement的 query 方法是怎样实现的。这里选择 PreparedStatementHandler 为例进行分析,至于 SimpleStatementHandler 和 CallableStatementHandler 分别用来处理非预编译SQL和存储过程的,用的比较少,就不分析了。

    首先经过 RoutingStatementHandler 进行路由,进入到 PreparedStatementHandler 中。

    PreparedStatementHandler 逻辑非常简单,调用JDBC 的原生api执行SQL,然后调用结果集处理器处理结果。

    到此,SQL是执行完毕了,但结果集处理是MyBatis中最复杂的一个部分,将会在后面重点进行讲解。现在,我们先看下之前跳过的获取 BoundSql 的过程,这也非常的重要。

     

     

    2. 获取 BoundSql

    在XML或注解中配置的原始SQL语句,可能带有字符串替换标记${}或动态标签,如 <if><where> 等。这些SQL语句会在应用启动时会被解析为动态SQL源,解析过程在前面的章节已经分析过,不再赘述。后续在每次执行SQL时,都必须先根据实际参数从动态SQL源解析出能被JDBC Api执行的BoundSql,这个过程叫动态SQL解析。

    简单来说,就是按部就班的执行一遍动态SQL源中的语法树节点,从每个节点中获取实际的SQL片段,最终拼接为一个完整的SQL语句。这个完整的 SQL 以及其他的一些信息最终会存储在 BoundSql 对象中。下面我们来看一下 BoundSql 类的成员变量信息,如下:

    接下来,开始分析 BoundSql 的构建过程,首先从 MappedStatementgetBoundSql 方法看起,代码如下:

    上述代码主要是调用了SQL源的getBoundSql方法来获取BoundSql,然后对参数映射列表做一些处理,这些配置都不常用了,这里不做深入分析,而SQL源的getBoundSql才是我们的重点。由上文分析的动态标签解析流程可知,这些的SQL源一般为 RawSqlSourceDynamicSqlSource。前者直接调用其内部 StaticSqlSource 的 getBoundSql 方法直接new一个即可,后者才是真正进行语法树的解析。下面对这个 DynamicSqlSource 进行分析。

    如上,DynamicSqlSource 的 getBoundSql 方法的代码看起来不多,但是逻辑却并不简单。该方法由数个步骤组成,这里总结一下:

    1. 创建 DynamicContext

    2. 解析 SQL 片段,并将解析结果存储到 DynamicContext 中

    3. 解析 SQL 语句,并构建 StaticSqlSource

    4. 调用 StaticSqlSource 的 getBoundSql 获取 BoundSql

    5. 将 DynamicContext 的 ContextMap 中的内容拷贝到 BoundSql 中

    如上5个步骤中,第5步为常规操作,就不多说了,其他步骤将会在接下来章节中一一进行分析。按照顺序,我们先来分析 DynamicContext 的实现。

     

    1) DynamicContext

    DynamicContext 是 SQL 语句构建的上下文,每个 SQL 片段解析完成后,都会将解析结果存入 DynamicContext 中。待所有的 SQL 片段解析完毕后,一条完整的 SQL 语句就会出现在 DynamicContext 对象中。下面我们来看一下 DynamicContext 类的定义。

    如上,其中 sqlBuilder 变量用于存放 SQL 片段的解析结果,bindings 则用于存储一些额外的信息,比如运行时参数 和 databaseId 等。bindings 类型为 ContextMap,ContextMap 定义在 DynamicContext 中,是一个静态内部类。该类继承自 HashMap,并覆写了 get 方法。它的代码如下:

    DynamicContext 对外提供了两个接口,用于操作 sqlBuilder。分别如下:

    以上就是对 DynamicContext 的简单介绍,DynamicContext 的源码不难理解,这里就不多说了。继续往下分析。

     

    2) 解析 SQL 片段

    对于一个包含了 ${} 占位符,或 <if><where> 等标签的 SQL,在解析的过程中,会被分解成多个片段。每个片段都有对应的类型,每种类型的片段都有不同的解析逻辑。在源码中,片段这个概念等价于 sql 节点,即 SqlNode。SqlNode 是一个接口,它有众多的实现类。其继承体系如下:

    img

    上图只画出了部分的实现类,还有一小部分没画出来,不过这并不影响接下来的分析。在众多实现类中,StaticTextSqlNode 用于存储静态文本,TextSqlNode 用于存储带有 ${} 占位符的普通文本,IfSqlNode 则用于存储 <if> 节点的内容。MixedSqlNode 内部维护了一个 SqlNode 集合,用于存储各种各样的 SqlNode。接下来,我将会对 MixedSqlNode 、StaticTextSqlNode、TextSqlNode、IfSqlNode、WhereSqlNode 以及 TrimSqlNode 等进行分析,其他的实现类请大家自行分析。

    MixedSqlNode 可以看做是 SqlNode 实现类对象的容器,凡是实现了 SqlNode 接口的类都可以存储到 MixedSqlNode 中,包括它自己。MixedSqlNode 解析方法 apply 逻辑比较简单,即遍历 SqlNode 集合,并调用其他 SalNode 实现类对象的 apply 方法解析 sql。那下面我们来看看其他 SalNode 实现类的 apply 方法是怎样实现的。

    StaticTextSqlNode 用于存储静态文本,所以它不需要什么解析逻辑,直接将其存储的 SQL 片段添加到 DynamicContext 中即可。StaticTextSqlNode 的实现比较简单,看起来很轻松。下面分析一下 TextSqlNode。

    GenericTokenParser 是一个通用的标记解析器,用于解析形如 ${xxx}#{xxx} 等标记。GenericTokenParser 负责将标记中的内容抽取出来,并将标记内容交给相应的 TokenHandler 去处理。BindingTokenParser 负责解析标记内容,并将解析结果返回给 GenericTokenParser,用于替换 ${xxx} 标记。举个例子说明一下吧,如下。

    我们有这样一个 SQL 语句,用于从 article 表中查询某个作者所写的文章。如下:

    假设我们我们传入的 author 值为 tianxiaobo,那么该 SQL 最终会被解析成如下的结果:

    并且在替换时,可以支持传入的正则表达式进行校验,防止SQL注入问题。

    分析完 TextSqlNode 的逻辑,接下来,分析 IfSqlNode 的实现。

    IfSqlNode 对应的是 <if test='xxx'> 节点,<if> 节点是日常开发中使用频次比较高的一个节点。它的具体用法我想大家都很熟悉了,这里就不多啰嗦。IfSqlNode 的 apply 方法逻辑并不复杂,首先是通过 ONGL 检测 test 表达式是否为 true,如果为 true,则调用其他节点的 apply 方法继续进行解析。需要注意的是 <if> 节点中也可嵌套其他的动态节点,并非只有纯文本。因此 contents 变量遍历指向的是 MixedSqlNode,而非 StaticTextSqlNode。

    关于 IfSqlNode 就说到这,接下来分析 WhereSqlNode 的实现。

    在 MyBatis 中,WhereSqlNode 和 SetSqlNode 都是基于 TrimSqlNode 实现的,所以上面的代码看起来很简单。WhereSqlNode 对应于 <where> 节点,关于该节点的用法以及它的应用场景,大家请自行查阅资料。我在分析源码的过程中,默认大家已经知道了该节点的用途和应用场景。

    接下来,我们把目光聚焦在 TrimSqlNode 的实现上。

    如上,apply 方法首选调用了其他 SqlNode 的 apply 方法解析节点内容,这步操作完成后,FilteredDynamicContext 中会得到一条 SQL 片段字符串。接下里需要做的事情是过滤字符串前缀后和后缀,并添加相应的前缀和后缀。这个事情由 FilteredDynamicContext 负责,FilteredDynamicContext 是 TrimSqlNode 的私有内部类。我们去看一下它的代码。

    在上面的代码中,我们重点关注 applyAll 和 applyPrefix 方法,其他的方法大家自行分析。applyAll 方法的逻辑比较简单,首先从 sqlBuffer 中获取 SQL 字符串。然后调用 applyPrefix 和 applySuffix 进行过滤操作。最后将过滤后的 SQL 字符串添加到被装饰的类中。applyPrefix 方法会首先检测 SQL 字符串是不是以 "AND ","OR ",或 “AND\n”, “OR\n” 等前缀开头,若是则将前缀从 sqlBuffer 中移除。然后将前缀插入到 sqlBuffer 的首部,整个逻辑就结束了。下面写点代码简单验证一下,如下:

    测试结果如下:

     

    3) 解析 #{} 占位符

    经过前面的解析,我们已经能从 DynamicContext 获取到完整的 SQL 语句了。但这并不意味着解析过程就结束了,因为当前的 SQL 语句中还有一种占位符没有处理,即 #{}。与 ${} 占位符的处理方式不同,MyBatis 并不会直接将 #{} 占位符替换为相应的参数值。#{} 占位符的解析逻辑这里先不多说,等相应的源码分析完了,答案就明了了。

    #{} 占位符的解析逻辑是包含在 SqlSourceBuilderparse 方法中,该方法最终会将解析后的 SQL 以及其他的一些数据封装到 StaticSqlSource 中。下面,一起来看一下 SqlSourceBuilder 的 parse 方法。

    如上,GenericTokenParser 的用途上一节已经介绍过了,就不多说了。接下来,我们重点关注 #{} 占位符处理器 ParameterMappingTokenHandler 的逻辑。

    ParameterMappingTokenHandler 的 handleToken 方法看起来比较简单,但实际上并非如此。GenericTokenParser 负责将 #{} 占位符中的内容抽取出来,并将抽取出的内容传给 handleToken 方法。handleToken 负责将传入的参数解析成对应的 ParameterMapping 对象,这步操作由 buildParameterMapping 方法完成。下面我们看一下 buildParameterMapping 的源码。

    如上,buildParameterMapping 代码很多,逻辑看起来很复杂。但是它做的事情却不是很多,只有3件事情。如下:

    1. 解析 content

    2. 解析 propertyType,对应分割线之上的代码

    3. 构建 ParameterMapping 对象,对应分割线之下的代码

    buildParameterMapping 代码比较多,不太好理解,下面写个示例演示一下。如下:

    测试结果如下:

    正如测试结果所示,SQL 中的 #{age, …} 占位符被替换成了问号 ?。#{age, …} 也被解析成了一个 ParameterMapping 对象。

    本节的最后,我们再来看一下 StaticSqlSource 的创建过程。如下:

    上面代码没有什么太复杂的地方,从上面代码中可以看出 BoundSql 的创建过程也很简单。正因为前面经历了这么复杂的解析逻辑,BoundSql 的创建过程才会如此简单。到此,关于 BoundSql 构建的过程就分析完了,稍作休息,我们进行后面的分析。

     

     

    3. 创建 StatementHandler

    StatementHandler 接口是 Mybatis 源码与 JDBC 接口的边界,往上调用 MyBatis 的参数处理器结果集处理器填充参数和处理结果集,往下直接操作 JDBC Api 来创建 Statement 对象并执行SQL语句。其实现类与 JDBC 中三类 Stetement 十分相似,继承体系如下图。

    img

    首先派生一个抽象类 BaseStatementHandler 处理公共逻辑,然后三个具体的实现类分别对应 JDBC 三种不同的 Statement 。除外之外,额外增加了一个实现类 RoutingStatementHandler,起路由作用(其实并没有什么卵用,可能是为了后续扩展吧)。

    下面看下 StatementHandler 的创建过程,为了实现拦截逻辑,和执行器等组件的创建过程类似,统一放在了 Configuration 中创建。

    关于 MyBatis 的插件机制,后面独立成文进行讲解,这里就不分析了。下面分析一下 RoutingStatementHandler。

    基本就是根据之前解析好的 StatementType 类型创建对应的 StatementHandler ,没有什么特殊的逻辑。如果未在Mapper文件中的SQL语句标签修改 statementType 属性,则默认创建 PreparedStatementHandler 。关于 StatementHandler 创建的过程就先分析到这,StatementHandler 创建完成了,后续要做到事情是创建 Statement,以及将运行时参数和 Statement 进行绑定。

     

     

    4. 设置运行时参数

    JDBC 提供了三种 Statement 接口,分别是 Statement、PreparedStatement 和 CallableStatement。他们的关系如下:

    img

    其中,Statement 接口提供了执行 SQL,获取执行结果等基本功能。PreparedStatement 在此基础上,对 IN 类型的参数提供了支持,使得我们可以使用运行时参数替换 SQL 中的问号 ? 占位符,而不用手动拼接 SQL。CallableStatement 则是 在 PreparedStatement 基础上,对 OUT 类型的参数提供了支持,该种类型的参数用于保存存储过程输出的结果。

    下面以最常用的 PreparedStatement 的创建为例分析,根据前文分析的 selectOne 方法可知,Statement 是在 Executor 的 prepareStatement 方法中创建的,先来看看这个方法。

    首先获取连接,然后创建 Statement ,最后设置参数,等待后续的执行操作,这和 JDBC 操作非常相似。下面来看看 PreparedStatement 具体是怎样创建的。

    如上,PreparedStatement 的创建过程没什么复杂的地方,就不多说了。下面分析运行时参数是如何被设置到 SQL 中的过程。

    如上代码,分割线以上的大段代码用于获取 #{xxx} 占位符属性所对应的运行时参数。分割线以下的代码则是获取 #{xxx} 占位符属性对应的 TypeHandler,并在最后通过 TypeHandler 将运行时参数值设置到 PreparedStatement 中。

     

    5. 参数转换和设置小结

    下面对之前参数转换和设置的过程做一个小结,假设我们有这样一条 SQL 语句:

    这个 SQL 语句中包含两个 #{} 占位符,在运行时这两个占位符会被解析成两个 ParameterMapping 对象。如下:

    #{} 占位符解析完毕后,得到的 SQL 如下:

    这里假设下面这个方法与上面的 SQL 对应:

    该方法的参数列表会被 ParamNameResolver 解析成一个 map,如下:

    假设该方法在运行时有如下的调用:

    此时,需要再次借助 ParamNameResolver 力量。这次我们将参数名和运行时的参数值绑定起来,得到如下的映射关系。

    下一步,我们要将运行时参数设置到 SQL 中。由于原 SQL 经过解析后,占位符信息已经被擦除掉了,我们无法直接将运行时参数 SQL 中。不过好在,这些占位符信息被记录在了 ParameterMapping 中了,MyBatis 会将 ParameterMapping 会按照 #{} 的解析顺序存入到 List 中。这样我们通过 ParameterMapping 在列表中的位置确定它与 SQL 中的哪个 ? 占位符相关联。同时通过 ParameterMapping 中的 property 字段,我们到“参数名与参数值”映射表中查找具体的参数值。这样,我们就可以将参数值准确的设置到 SQL 中了,此时 SQL 如下:

    整个流程如下图所示。

    img

    当运行时参数被设置到 SQL 中 后,下一步要做的事情是执行 SQL,然后处理 SQL 执行结果。对于更新操作,数据库一般返回一个 int 行数值,表示受影响行数,这个处理起来比较简单。但对于查询操作,返回的结果类型多变,处理方式也很复杂。接下来,我们就来看看 MyBatis 是如何处理查询结果的。

     

    6. 处理查询结果

    MyBatis 可以将查询结果,即结果集 ResultSet 自动映射成实体类对象。这样使用者就无需再手动操作结果集,并将数据填充到实体类对象中。这可大大降低开发的工作量,提高工作效率。

     

    1) 结果集映射主流程

    在 MyBatis 中,结果集的处理工作由结果集处理器 ResultSetHandler 执行。ResultSetHandler 是一个接口,它只有一个实现类 DefaultResultSetHandler。结果集的处理入口方法是 handleResultSets,下面来看一下该方法的实现。

    如上,该方法首先从 Statement 中获取第一个结果集,然后调用 handleResultSet 方法对该结果集进行处理。一般情况下,如果我们不调用存储过程,不会涉及到多结果集的问题。由于存储过程并不是很常用,所以关于多结果集的处理逻辑我就不分析了。下面,我们把目光聚焦在单结果集的处理逻辑上。

    在上面代码中,出镜率最高的 handleRowValues 方法,该方法用于处理结果集中的数据。下面来看一下这个方法的逻辑。

    如上,handleRowValues 方法中针对两种映射方式进行了处理。一种是嵌套映射,另一种是简单映射。嵌套是指 ResultMap 的子标签也存在 ResultMap,有内联嵌套映射和外引用嵌套映射两种情形,后面将会进行分析。这里先来简单映射的处理逻辑。

    在如上几个步骤中,鉴别器相关的逻辑就不分析了,不是很常用。先来分析第一个步骤对应的代码逻辑。如下:

    这段逻辑主要用于处理 RowBounds ,来跳过前 rowBounds.getOffset() 行,效率并不高,能不能尽量不用。

    第二个步骤主要是通过 JDBCApi resultSet.next()来遍历剩余的结果集,需要注意的是,如果结果集被关闭或者解析被终止解析到足够的结果行则停止遍历。

    第五个步骤主要是存储解析出来的对象到 Resulthandler 中,并同步上下文信息。

    除此之外,如果 Mapper 接口方法返回值为 Map 类型,此时则需要另一种 ResultHandler 实现类处理结果,即 DefaultMapResultHandler。

    最后再着重分析下第四步, ResultSet 的映射过程,如下:

    在上面的方法中,重要的逻辑已经注释出来了。有三处代码的逻辑比较复杂,接下来按顺序进行分节说明。首先分析实体类的创建过程。

     

    2) 创建实体类对象

    MyBatis支持多种方式创建实体类对象,并在在创建时织入懒加载处理逻辑。代码如下:

    重点注意,懒加载的处理逻辑是在此处织入的,后续章节会对懒加载再进行详细分析,先来看看 MyBatis 创建实体类对象的具体过程。

    如上,createResultObject 方法中包含了4种创建实体类对象的方式。一般情况下,若无特殊要求,MyBatis 会通过 ObjectFactory 调用默认构造方法创建实体类对象。ObjectFactory 是一个接口,大家可以实现这个接口,以按照自己的逻辑控制对象的创建过程。到此,实体类对象已经创建好了,接下里要做的事情是将结果集中的数据映射到实体类对象中。

     

    3) 自动配置映射

    在 MyBatis 中,全局自动映射行为有三种等级。如下:

    这可以通过配置 <resultMap> 节点的 autoMapping 属性来进行修改。下面来看看具体的代码实现:

    如上,该方法用于检测是否应为当前结果集应用自动映射,逻辑不难理解,接下来分析 MyBatis 如何进行自动映射。

    首先对未手工配置的列生成映射配置UnMappedColumnAutoMapping,该类定义在 DefaultResultSetHandler 内部,如下:

    然后使用该配置逐一进行映射。映射过程即通过 TypeHandler 从结果集获取值,然后通过 value 的 MetaObject 对象设置该值到对象中。下面再来看一下生成 UnMappedColumnAutoMapping 集合的过程,如下:

    以上步骤中,除了第一步,其他都是常规操作,无需过多说明。下面来分析第一个步骤的逻辑,如下:

    如上,已映射列名与未映射列名的分拣逻辑并不复杂。

     

    4) 手工配置映射

    到此为止,自动映射配置的创建过程已分析完毕,接下来看看手工配置的映射。

    如上,首先从 ResultSetWrapper 中获取已映射列名集合 mappedColumnNames,从 ResultMap 获取映射对象 ResultMapping 集合。然后遍历 ResultMapping 集合,再此过程中调用 getPropertyMappingValue 获取指定指定列的数据,最后将获取到的数据设置到实体类对象中。到此,基本的结果集映射过程就分析完了。下面章节将分析之前提到的获取关联查询的结果。

     

     

    5) 关联查询

    在进行数据库查询时,经常会碰到一对一和一对多的查询场景。如查询 USER_INFO(pk:USER_ID) 中用户的基本信息时,去同步查询 USER_FUND(pk:USER_ID) 中该用户的总资产,就是一对一查询。如果同步查询 USER_FUND_DETAIL(pk:USER_ID,FUND_CLS) 中用户的资产明细,则是一对多查询。MyBatis为这两种场景提供了四种解决方案:

    其中,定制VO类使用的是简单映射,前文已经做了详细分析,嵌套映射将会在下一章节讲解,而多结果集使用较少,本文暂不分析。下面先来看看上文提到的关联查询。关联查询相关的标签有<association><collection>,分别用作一对一和一对多查询,如果你对这两种方式的使用还不太了解,可以先阅读本系列文章的基础使用篇。

    我们从之前提到的 getNestedQueryMappingValue 方法开始分析,如下:

    首先看看从结果集获取关联查询参数的过程。

    接着分析关联查询的懒加载机制。懒加载在此处仅是将关联查询相关信息添加到 loaderMap 集合中而已。

    那么懒加载是如何触发的呢?又是谁触发的呢?答案在实体类的代理对象, 回顾之前创建实体类对象时,MyBatis 会为需要延迟加载的类生成代理类,代理逻辑会拦截实体类的方法调用。

    EnhancedResultObjectProxyImpl 类的具体代理逻辑,我们可以看看它的invoke方法。

    接下来,我们来看看延迟加载逻辑是怎样实现的。

    上面的代码比较多,但是没什么特别的逻辑,下面看一下 ResultLoader 的 loadResult 方法逻辑是怎样的。

    如上,我们在 ResultLoader 中终于看到了执行关联查询的代码,即 selectList 方法中的逻辑。该方法在内部通过 Executor 进行查询。至于查询结果的抽取过程,代码贴在下面,了解下即可。

     

    6) 嵌套映射

    嵌套映射指将二维表形式的查询结果映射到复杂VO对象,并对重复出现的数据进行折叠。嵌套映射分为两种,一种是一对一的嵌套映射。如查询Blog及所属的User,返回结果集如下所示,这时将行中的id、title映射到Blog对象,将user_id和user_name映射到Blog的成员变量user。

    image-20210618175231491

    还有一种是一对多的嵌套映射,如查询博客及博客下的所有评论,返回数据格式如下图所示,这时将行中的id、title映射到Blog对象,将comment_id和comment_body映射到Blog的成员变量List<Comment>

    image-20210618175323495

    mybatis是如何知道哪一列映射到哪个对象的呢?

    • 这是在ResultMap元素中配置的,并且可以进行前缀匹配(columnPrefix在匹配时会添加该前缀与列名匹配)和限定不为空的列(notNullColumn可以指定一个或多个列,以逗号分隔,如果全部为空,则会忽略该行数据)。

    • 注意:在嵌套映射的场景下,autoMapping=false,自动映射默认关闭

    • 提示:可以在ResultMap中指定id列,则在进行嵌套映射时,优先使用id列进行结果行分组。如果没有指定id列,则使用所有的result配置创建RowKey。例如,上面一对多映射中,将id一致的记录视为同一个Blog对象。

     

    下面是 MyBatis 处理嵌套映射的流程图,可以看到嵌套映射时首先会创建一个RowKey,去暂存区读数据,如果不存在则创建对象(Blog),并进行自动映射和手动映射,然后进行复合属性填充。复合属性填充依旧先创建RowKey,流程与前类似。

    在读取暂存区的时候,如果根据RowKey找到对象,则表示该对象在之前已经创建过了,直接进入复合属性填充即可。

    image-20220117130936830

    下面进行源码跟踪验证,代码和配置摘要如下:

    在 DefaultResultSetHandler 的 handleRowValues 打上断点,开始进行跟踪。如果存在嵌套结果集映射,则调用 handleRowValuesForNestedResultMap 处理嵌套结果映射,否则进行之前讲解的简单结果映射。

    处理嵌套结果映射时,对照流程图可以看到,首先创建RowKey,尝试从暂存区nestedResultObjects读取未映射完成的对象partialObject(如暂未映射comments的Blog对象)。把该对象传给getRowValue继续进行映射,映射完成后继续把该对象保存起来(storeObject)。

    getRowValue中(嵌套映射的重载形式),如果 partialObject != null,也就是根据RowKey查找到了对象,则直接进行嵌套属性映射(一般在一对多映射的子属性第二次及以上映射)。 如果在暂存区没有找到映射的对象,则先创建对象,进行自动映射和手动映射后再进行嵌套属性映射。

    applyNestedResultMappings中映射嵌套属性时,又回到了流程图中创建RowKey的逻辑,不过这次的Key是combinedKey

    在上面源码跟踪的最后一步,发现又回到了创建RowKey的起始位置。观察上面案例,blogNestedMappingMap中映射comments时,coments内部又引用了blogNestedMappingMap进行blog的映射,这样会不会产生循环映射呢?

    MyBatis使用ancestorObjects暂存区解决的循环映射的问题,在进行嵌套属性映射前,以当前resultMapId为key,当前对象为value存入ancestorObjects容器,在嵌套属性映射完成后,再从容器中删除。

    image-20210619113446683

    而在 applyNestedResultMappings 中,获取嵌套映射MapnestedResultMap后,先去ancestorObjects中查找,是否与父对象的映射一致,如果是则直接进行linkObjects,不必要再进行combinedKey的创建及后续的getRowValue了。

    image-20210619113823061

     

     

     

    第三节 更新语句的执行过程分析

    MyBatis 中更新语句指的是除查询之外的所有语句,包括插入删除修改数据库定义语句(DDL)等。它们在处理上大同小异,与查询语句相比,最大的区别是查询结果的映射变的非常简单,其次是在缓存方面,更新语句刷新缓存的时机也不同,当然还有其它一些不同点,都将会在这节一一讲解。

     

    1. 更新语句执行主流程

    首先,我们还是从 MapperMethod 的 execute 方法开始看起,这里根据不同的命令类型,处理参数后路由到 SqlSession 的不同入口。

    因为三种类型的语句对JDBC来说是不区分的,因此都在内部调用 SqlSession 的 update 方法来进行下一步的处理。在 update 方法中,从全局配置中获取 MappedStatement 后,调用执行器的 update 方法来执行SQL。

    如果全局属性 cacheEnabled 开启,则会先进入到执行器的装饰器类 CachingExecutor,再进入到基类 BaseExecutor ,最后调用子类的具体实现。装饰器类和基类中都仅执行了各自的缓存刷新逻辑,是否刷新取决于具体的配置。

    下面分析 BaseExecutordoUpdate 方法,该方法是一个抽象方法,默认情况下,使用的实现类是 SimpleExecutor ,这可以通过全局属性配置或在打开会话时进行设置。

    前两步已经分析过,这里就不重复分析了。下面分析 PreparedStatementHandler 的 update 方法。

    如上,前两步调用 JDBCApi 执行 SQL 和获取更新结果(影响的行数),逻辑非常简单。第三步为自增主键值的回填,实现逻辑封装在 KeyGenerator 的实现类中,下面一起来看看。

     

    2. KeyGenerator

    KeyGenerator 是一个接口,目前它有三个实现类:

     

     

    1) Jdbc3KeyGenerator

    先来分析 Jdbc3KeyGenerator 的源码,配置可参考本系列文档的基础使用篇。

     

    2) SelectKeyGenerator

    再来看看 SelectKeyGenerator 的源码,在执行更新语句前事先查询出Key,或在更新语句执行完后回填key。

     

     

    3. 处理更新结果

    更新语句的执行结果是一个整型值,表示本次更新所影响的行数,处理逻辑非常简单。

     

     

     

    第03章_其它模块源码解析

    第一节 内置数据源

    MyBatis 支持三种类型的数据源配置,分别是UNPOOLEDPOOLEDJNDI。其中UNPOOED 是一种无连接缓存的数据源实现,每次都向数据库获取新连接。而 POOLED 在 UNPOOLED 的基础上加入了连接池技术,获取连接时更加高效。另外,为了能够在 EJB 或应用服务器上运行,还引入了 JNDI 类型的配置,但使用较少,稍作了解即可。

     

    1. 数据源工厂

    MyBatis 在解析 environment 节点时,会一并解析内嵌的 dataSource 节点,根据不同类型的配置,创建不同类型的数据源工厂。

    如果type属性配置的类型是 UNPOOLED ,则会创建 UnpooledDataSourceFactory ,下面来看看它的源码。

    如果type属性配置的类型是 POOLED,则会创建 PooledDataSourceFactory。PooledDataSourceFactory 继承自 UnpooledDataSourceFactory,复用了父类的逻辑,因此它的实现很简单。

    如果type属性配置的类型是 JNDI,则会创建 JndiDataSourceFactory,从容器上下文查找数据源。

     

     

    2.UnpooledDataSource

    UnpooledDataSource 是对 JDBC 获取连接的一层简单封装,不具有池化特性,无需提供连接池功能,因此它的实现非常简单。

    如上,将一些配置信息放入到 Properties 对象中,然后将数据库连接和 Properties 对象传给 DriverManager 的 getConnection 方法即可获取到数据库连接。

     

    3.PooledDataSource

    PooledDataSource 是一个支持连接池的数据源实现,从性能上来说,要优于 UnpooledDataSource。

     

    1) 辅助类介绍

    为了实现连接池的功能,PooledDataSource 抽象了两个辅助类 PoolStatePooledConnection。PoolState 用于记录连接池运行时的状态,比如连接获取次数,无效连接数量等。除此之外,还定义了两个 PooledConnection 集合,分别用于存储空闲连接和活跃连接。

    PooledConnection 内部包含一个真实的连接和一个 Connection 的代理,代理的拦截逻辑为 PooledConnection 的 invoke 方法,后面将会讲解。除此之外,其内部也定义了一些字段,用于记录数据库连接的一些运行时状态。

     

    2) 获取连接

    PooledDataSource 会对数据库连接进行缓存,获取连接时可能会遇到多种情况,请看图。

    img

    下面我们深入到源码中一探究竟。

     

    3) 回收连接

    相比于获取连接,回收连接的逻辑要简单的多。回收连接成功与否只取决于空闲连接集合的状态,所需处理情况很少,因此比较简单。

    上面代码首先将连接从活跃连接集合中移除,然后再根据空闲集合是否有空闲空间进行后续处理。如果空闲集合未满,此时复用原连接的字段信息创建新的连接,并将其放入空闲集合中即可。若空闲集合已满,此时无需回收连接,直接关闭即可。

    我们知道获取连接的方法 popConnection 是由 getConnection 方法调用的,那回收连接的方法 pushConnection 是由谁调用的呢?答案是 PooledConnection 中的代理逻辑。相关代码如下:

    代理对象中的方法被调用时,如果判断是 close 方法,那么MyBatis 并不会直接调用真实连接的 close 方法关闭连接,而是调用 pushConnection 方法回收连接。同时会唤醒处于睡眠中的线程,使其恢复运行。

     

     

    第二节 缓存实现

    为了缓解数据库查询压力,提高查询性能,MyBatis提供了一级缓存和二级缓存。

     

    1. 一级缓存

    1) 缓存内存结构

    一级缓存的结构非常简单,仅为一个基于HashMap 的缓存类 PerpetualCache,直接定义在BaseExecutor 中,没有附加任何的装饰器。

    为什么是基于 HashMap 而非ConcurrentHashMap?因为一级缓存是定义在执行器中的,而执行器是与会话绑定的,不存在并发情形。

     

    2) 缓存执行流程

    首先,在创建执行器时,会初始化内部的一级缓存,名称固定为 LocalCache。

    后续在每次查询前,都会去执行 BaseExecutor 中的一级缓存逻辑,查询一级缓存。

    若一级缓存未命中,BaseExecutor 会调用 queryFromDatabase 查询数据库,并将查询结果写入缓存中。

     

     

    2. 二级缓存

    1) 缓存内存结构

    在查询一级缓存之前,MyBatis会优先去找到应用级别的缓存,即二级缓存。二级缓存本质上也是一个 PerpetualCache,但在此基础上添加了一些装饰器。常用的装饰器如下。

    image-20210531121133915

    下面写段测试代码看看二级缓存的具体内存结构是哪样的。

    跟踪代码可以看到,二级缓存是通过责任链+装饰器模式构建的一个多功能缓存实现。

    image-20210531121620809

     

     

    2) 缓存装饰器

    下面是MyBatis提供的一些缓存装饰器的简要说明,后续将会对部分装饰器进行详细分析。

    缓存装饰器说明
    LruCache基于 LinkedHashMap 的 LRU(最近最少使用) 淘汰策略
    FifoCache基于Deque 实现的先进先出的淘汰策略
    SoftCache/WeakCache基于JDK的软引用和弱引用实现的淘汰策略
    BlockingCache基于Java重入锁实现的线程阻塞特性
    SynchronizedCache基于synchronized方法实现的线程同步
    SerializedCache基于ObjectOutputStream和ObjectInputStream的缓存结果序列化和反序列化
    ScheduledCache基于System.currentTimeMillis()的过期清理
    LoggingCache基于请求次数/命中次数的命中率统计
    TransactionalCache用于解决事务安全问题

     

    1) LruCache

    LruCache 是一种具有 LRU 策略的缓存实现类,该特性是基于 LinkedHashMap 实现的。代码如下。

    如上,LruCache 的 keyMap 属性是实现 LRU 策略的关键,该属性类型继承自 LinkedHashMap,并覆盖了 removeEldestEntry 方法。LinkedHashMap 可保持键值对的插入顺序,当插入一个新的键值对时,LinkedHashMap 内部的 tail 节点会指向最新插入的节点。head 节点则指向第一个被插入的键值对,也就是最久未被访问的那个键值对。默认情况下,LinkedHashMap 仅维护键值对的插入顺序。若要基于 LinkedHashMap 实现 LRU 缓存,还需通过构造方法将 LinkedHashMap 的 accessOrder 属性设为 true,此时 LinkedHashMap 会维护键值对的访问顺序。比如,上面代码中 getObject 方法中执行了这样一句代码 keyMap.get(key),目的是刷新 key 对应的键值对在 LinkedHashMap 的位置。LinkedHashMap 会将 key 对应的键值对移动到链表的尾部,尾部节点表示最久刚被访问过或者插入的节点。除了需将 accessOrder 设为 true,还需覆盖 removeEldestEntry 方法。LinkedHashMap 在插入新的键值对时会调用该方法,以决定是否在插入新的键值对后,移除老的键值对。在上面的代码中,当被装饰类的容量超出了 keyMap 的所规定的容量(由构造方法传入)后,keyMap 会移除最长时间未被访问的键,并保存到 eldestKey 中,然后由 cycleKeyList 方法将 eldestKey 传给被装饰类的 removeObject 方法,移除相应的缓存项目。

     

    2) SynchronizedCache

    二级缓存是从 MappedStatement 中获取的,而非由 CachingExecutor 创建,由于 MappedStatement 存在于全局配置中,可被多个 CachingExecutor 获取到,这样就会出现线程安全问题。MyBatis为解决这个问题,在创建二级缓存实例时,应用了一个 SynchronizedCache 装饰器,通过同步方法解决线程安全问题。

     

    3) BlockingCache

    BlockingCache 实现了阻塞特性,该特性是基于 Java 重入锁实现的。同一时刻下,BlockingCache 仅允许一个线程访问指定 key 的缓存项,其他线程将会被阻塞住。下面我们来看一下 BlockingCache 的源码。

    如上,查询缓存时,getObject 方法会先获取与 key 对应的锁,并加锁。若缓存命中,getObject 方法会释放锁,否则将一直锁定。getObject 方法若返回 null,表示缓存未命中。此时 MyBatis 会进行数据库查询,并调用 putObject 方法存储查询结果。同时,putObject 方法会将指定 key 对应的锁进行解锁,这样被阻塞的线程即可恢复运行。

    上面的描述有点啰嗦,倒是 BlockingCache 类的注释说到比较简单明了。这里引用一下:

    It sets a lock over a cache key when the element is not found in cache. This way, other threads will wait until this element is filled instead of hitting the database.

    这段话的意思是,当指定 key 对应元素不存在于缓存中时,BlockingCache 会根据 lock 进行加锁。此时,其他线程将会进入等待状态,直到与 key 对应的元素被填充到缓存中。而不是让所有线程都去访问数据库。

    在上面代码中,removeObject 方法的逻辑很奇怪,仅调用了 releaseLock 方法释放锁,却没有调用被装饰类的 removeObject 方法移除指定缓存项。这样做是为什么呢?大家可以先思考,答案将在分析二级缓存的相关逻辑时分析。

     

     

    3) 缓存执行流程

    在创建执行器时,如果开启了全局缓存配置cacheEnabled,则会对创建的执行器使用CachingExecutor进行装饰。

    当我们执行query等方法时,会进入到 CachingExecutor 的相应方法之中。在该方法中,第三步重载的query方法被替换为CachingExecutor中的重载方法,注入了二级缓存逻辑。

    从缓存事务管理器中查找二级缓存,如果未找到,再通过delegate调用之前BaseExecutor中被重写的query方法,去一级缓存查找。

     

     

    4) 事务安全

    前文分析可知,Mybatis通过 SynchronizedCache 装饰器做了同步操作,解决了二级缓存的线程安全问题。但同一个缓存实例依然被多个事务所共享,如下情形依然可能出现脏读。

    img

    如上图所示,事务A和事务B分别开启事务;事务A先对记录A进行修改,在未提交事务的情况下又读取记录A,并写入缓存;此时,事务B去查询记录A,直接从缓存中取事务A存入的脏数据,这个数据可能被事务A回滚掉。

    要想解决脏读问题,必须将各个事务未提交时的缓存数据单独存放在别处。MyBatis在创建CachingExecutor时初始化了一个事务缓存管理器 TransactionalCacheManager,其内部通过事务缓存装饰器 TransactionalCache 对事务未提交时的缓存数据进行了暂存。

    下面是事务缓存管理器的代码,负责对真实缓存实例Cache进行装饰,并维护与事务缓存实例 TransactionalCache 间的映射关系。

    TransactionalCache是一个对事务中未提交数据进行暂存的缓存装饰类。下面分析一下该类的逻辑。

    缓存实例被 TransactionalCache 装饰后,查询出来的数据不会直接缓存到 delegate 所表示的缓存中,而是先暂存到 entriesToAddOnCommit 集合,当事务提交时,再将事务缓存中的缓存项转存到共享缓存中,这样就不会读取到其它事务未提交的脏数据了。下面看下加入事务缓存后的流程图。

    如上图所示,时刻1,事务A和事务B分别开启事务;时刻2,事务 A 和 B 同时查询记录 A。此时共享缓存中还没没有数据,所以两个事务均会向数据库发起查询请求,并将查询结果存储到各自的事务缓存中。时刻3,事务 A 更新记录 A,这里把更新后的记录 A 记为 A′。时刻4,两个事务再次进行查询。此时,事务 A 读取到的记录为修改后的值,而事务 B 读取到的记录仍为原值。时刻5,事务 A 被提交,并将事务缓存 A 中的内容转存到共享缓存中。时刻6,事务 B 再次查询记录 A,由于共享缓存中有相应的数据,所以直接取缓存数据即可。因此得到记录 A′,而非记录 A。但由于事务 A 已经提交,所以事务 B 读取到的记录 A′ 并非是脏数据。

    扩展MyBatis的二级缓存不支持可重复读

    MyBatis 引入事务缓存解决了脏读问题,事务间只能读取到其他事务提交后的内容,这相当于事务隔离级别中的“读已提交(Read Committed)”。但需要注意的时,MyBatis 缓存事务机制只能解决脏读问题,并不能解决“不可重复读”问题。再回到上图,事务 B 在被提交前进行了三次查询。前两次查询得到的结果为记录 A,最后一次查询得到的结果为 A′。最有一次的查询结果与前两次不同,这就会导致“不可重复读”的问题。MyBatis 的缓存事务机制最高只支持“读已提交”,并不能解决“不可重复读”问题。即使数据库使用了更高的隔离级别解决了这个问题,但因 MyBatis 缓存事务机制级别较低。此时仍然会导致“不可重复读”问题的发生,这个在日常开发中需要注意一下。

     

    接下来,我们再来分析下 entriesMissedInCache 集合,这个集合是用于存储未命中缓存的查询请求对应的 CacheKey。单独分析与 entriesMissedInCache 相关的逻辑没什么意义,要搞清 entriesMissedInCache 的实际用途,需要把它和 BlockingCache 的逻辑结合起来进行分析。在 BlockingCache,同一时刻仅允许一个线程通过 getObject 方法查询指定 key 对应的缓存项。如果缓存未命中,getObject 方法不会释放锁,导致其他线程被阻塞住。其他线程要想恢复运行,必须进行解锁,解锁逻辑由 BlockingCache 的 putObject 和 removeObject 方法执行。其中 putObject 会在 TransactionalCache 的 flushPendingEntries 方法中被调用,removeObject 方法则由 TransactionalCache 的 unlockMissedEntries 方法调用。flushPendingEntries 和 unlockMissedEntries 最终都会遍历 entriesMissedInCache 集合,并将集合元素传给 BlockingCache 的相关方法。这样可以解开指定 key 对应的锁,让阻塞线程恢复运行。

     

     

    3. CacheKey

    1) Cacehkey内存结构

    由于不同的SQL语句、查询参数以及分页条件都会导致查询结果不一致,因此MyBatis为此创建一个复合键Cacehkey,定义如下。

    提供了 update 方法,用于加入新的影响因子,并进行计算。

    当不断有新的影响因子参与计算时,hashcode 和 checksum 将会变得愈发复杂和随机。这样可降低冲突率,使 CacheKey 可在缓存中更均匀的分布。CacheKey 最终要作为键存入 HashMap,因此它需要覆盖 equals 和 hashCode 方法。下面我们来看一下这两个方法的实现。

    equals 方法的检测逻辑比较严格,对 CacheKey 中多个成员变量进行了检测,已保证两者相等。hashCode 方法比较简单,返回 hashcode 变量即可。下面是一个CacehKey 的内存结构。

    image-20210526114330539

     

     

    2) Cachekey的创建过程

    在从SqlSession进入到执行器时,首先会进入到 BaseExecutor 的 query 方法,创建CacheKey,然后再调用重载方法进行后续处理。如果开启了二级缓存全局配置,则会进入到 CachingExecutor 的重写方法中,不过逻辑基本一致,创建CacheKey时也是直接调用代理对象的createCacheKey方法。(注意区分,CachingExecutor 中后续调用的重载方法query被重写了,植入了二级缓存逻辑)

    下面我们来看一下 createCacheKey 方法的逻辑:

    如上,在计算 CacheKey 的过程中,有很多影响因子参与了计算。比如 MappedStatement 的 id 字段,SQL 语句,分页参数,运行时变量,Environment 的 id 字段等。通过让这些影响因子参与计算,可以很好的区分不同查询请求。所以,我们可以简单的把 CacheKey 看做是一个查询请求的 id。有了 CacheKey,我们就可以使用它读写缓存了。

     

     

    第三节 插件机制

    为了增加框架的灵活性,让开发者可以根据实际需求对框架进行扩展,MyBatis 在 Configuration 中对下面四个类进行统一实例化,以植入拦截逻辑。

    如果我们想要拦截 Executor 的 query 方法,那么可以这样定义插件,首先实现 Interceptor 接口,然后配置插件的拦截点。

    最后将插件在 mybatis.xml 文件中声明,Mybatis在启动时就会将该插件加入到拦截器链(InterceptorChain)。由于是在Configuration中统一创建的,因此有机会做动态代理,植入拦截逻辑。下面我们来看看它的源码。

     

     

    1. 植入插件逻辑

    以 Executor 为例,分析 MyBatis 是如何为 Executor 实例植入插件逻辑的。Executor 实例是在开启 SqlSession 时被创建的,因此,下面我们从源头进行分析,先来看一下 SqlSession 开启的过程。

    Executor 的创建过程封装在 Configuration 中,我们跟进去看看看。

    如上,newExecutor 方法在创建好 Executor 实例后,紧接着通过拦截器链 interceptorChain 为 Executor 实例植入代理逻辑。那下面我们看一下 InterceptorChain 的代码是怎样的。

    以上是 InterceptorChain 的全部代码,比较简单。它的 pluginAll 方法会调用具体插件的 plugin 方法植入相应的插件逻辑。如果有多个插件,则会多次调用 plugin 方法,最终生成一个层层嵌套的代理类。形如下面:

    img

    当 Executor 的某个方法被调用的时候,插件逻辑会先行执行。执行顺序由外而内,比如上图的执行顺序为 plugin3 → plugin2 → Plugin1 → Executor。plugin 方法是由具体的插件类实现,不过该方法代码一般比较固定,所以下面找个示例分析一下。

    如上,plugin 方法在内部调用了 Plugin 类的 wrap 方法,用于为目标对象生成代理。Plugin 类实现了 InvocationHandler 接口,因此它可以作为参数传给 Proxy 的 newProxyInstance 方法。接下来,我们来看看插件拦截逻辑是怎样的。

     

     

    2. 执行插件逻辑

    Plugin 实现了 InvocationHandler 接口,因此它的 invoke 方法会拦截所有的方法调用。invoke 方法会对所拦截的方法进行检测,以决定是否执行插件逻辑。该方法的逻辑如下:

    invoke 方法的代码比较少,逻辑不难理解。首先,invoke 方法会检测被拦截方法是否配置在插件的 @Signature 注解中,若是,则执行插件逻辑,否则执行被拦截方法。插件逻辑封装在 intercept 中,该方法的参数类型为 Invocation。Invocation 主要用于存储目标类,方法以及方法参数列表。下面简单看一下该类的定义。

    关于插件的执行逻辑就分析到这,整个过程不难理解,大家简单看看即可。

     

    3. 实现一个分页插件

    为了更好的向大家介绍 MyBatis 的插件机制,下面我将手写一个针对 MySQL 的分页插件。

     

     

    第四节 日志体系

    为了记录SQL执行日志,MyBatis定义了一套日志打印的接口Log、日志工厂LogFactory以及日志相关的异常LogException,并提供了多种日志实现的适配器,在启动时通过静态代码块来初始化某个具体的实现。

     

    1. 日志接口介绍

    先来看看日志接口Log,它里面抽象了日志打印的5种方法和2种判断方法,方便于面向接口编程。

    MyBatis为常用的几种日志实现提供了适配器,按选择的先后顺序排列如下:Slf4jImplJakartaCommonsLoggingImplLog4j2ImplLog4jImplJdk14LoggingImplNoLoggingImpl以及备选的StdOutImpl,从名称不难看出它们所对应的日志实现。下面以 Slf4jImpl 为例看看日志实现是如何适配到Log接口的。

     

    2. 日志实现实例化

    日志实现的实例化是在日志工厂LogFactory的静态方法中进行的,在项目启动时,按上文所述顺序对第一个发现的日志实现进行实例化,后续日志实现将会被忽略。

    此外,还可以通过全局属性logImpl直接指定所需的日志实现,这将会在 parseConfiguration 方法中进行解析。

    逐步调用到上文提到的 useCustomLogging 方法,内部依然是通过 setImplementation 来设置日志实现的构造器的。

     

    3. 日志的使用

    光有日志实现还不够,必须得用起来才能打印所需的日志。MyBatis为需要打印日志的一些类定义了 InvocationHandler,如果需要打印日志,只需要给对应的对象做动态代理即可。

    下面我们以 ConnectionLogger 为例,解析日志打印的代理逻辑。其它三个类逻辑类似,请自行分析。

    通过代码分析可以知道,PreparedStatementLoggerStatementLoggerResultSetLogger的代理对象创建(newInstance)都是在ConnectionLogger中完成的,那么ConnectionLogger的代理对象在什么时候创建的呢?

    回顾之前的执行器相关分析代码,可以发现,在执行SQL前,需要先获取连接创建 Statement,而在创建连接后,如果判断日志是debug级别,则会创建连接的代理对象返回,在调用相关方法时就会被拦截执行打印日志逻辑。

     

    第五节 常用基础类

    1. MetaClass

    元信息类MetaClass是用来解析目标类的一些元信息,比如类的成员变量,getter/setter 方法等。其构造方法为私有类型,所以不能直接创建,必须使用其提供的forClass方法进行创建。

    上面代码出现了两个新的类ReflectorFactoryReflector,MetaClass 通过引入这些新类帮助它完成功能。下面我们以MetaClass的hasSetter方法为例看一下它的源码就知道是怎么回事了。

    从上面的代码中,我们可以看出 MetaClass 中的 hasSetter 方法最终调用了 ReflectorhasSetter 方法。关于 Reflector 的 hasSetter 方法,这里先不分析,Reflector 这个类的逻辑较为复杂,本章会在随后进行详细说明。下面来简单介绍一下上面代码中出现的几个类:

     

     

    2. MetaObject

    MetaObject是MyBatis底层的一个反射工具类,主要结构和功能如下:

    image-20210602145211772

    MetaObject获取属性的流程图如下,在上述案例打上断点跟踪调试看看吧!

    image-20210602145744823

     

    从MetaObject的getValue方法进入后,首先使用PropertyTokenizer类进行分词,再判断是否有子属性。

    image-20210602160641964

     

    递归完成后如下图所示,简化为使用username从User对象中取值。

    image-20210602161350212

     

    提示:在递归过程中,metaObjectForProperty(prop.getIndexedName())方法内部需要获取cmments[0]、user等对象构建新的MetaObject,可能多次调用objectWrapper.get(prop),要注意区分!

     

    BeanWrapper类中,可获取当前对象的属性值,这里没有index,走下面的逻辑。

    image-20210602162222639

     

    再往下就是JDK反射的一些包装了,这里不详细展开讲解,可自行跟踪理解。

    image-20210602162640874

    image-20210602162712657

     

    如果在BeanWrapper中有index,如comments[0],则会先调用MetaObject.getValue获取 collection 对象(传入prop.getName())。

    image-20210602163439106

    image-20210602163520902

     

    获取到集合对象后,根据不同的集合类型,用 index 从集合中拿数据返回。

    image-20210602163020917

     

    image-20210602163319040

     

     

    3. Reflector

    Reflector 这个类的用途主要是是通过反射获取目标类的 getter 方法及其返回值类型,setter 方法及其参数值类型等元信息。并将获取到的元信息缓存到相应的集合中,供后续使用。

     

    1) ReflectorFactory

    ReflectorFactory 是一个接口,MyBatis 中目前只有一个实现类 DefaultReflectorFactory。DefaultReflectorFactory 用于创建 Reflector,同时兼有缓存的功能,它的源码如下。

    DefaultReflectorFactory 的findForClass方法逻辑不是很复杂,包含两个缓存操作,和一个对象创建操作。代码注释的比较清楚了,就不多说了。接下来,来分析一下反射器 Reflector。

     

    2) 构造方法及成员变量

    Reflector 构造方法中包含了很多初始化逻辑,目标类的元信息解析过程也是在构造方法中完成的,这些元信息最终会被保存到 Reflector 的成员变量中。下面我们先来看看 Reflector 的构造方法和相关的成员变量定义,代码如下:

    Reflector 的构造方法看起来略为复杂,不过好在一些比较复杂的逻辑都封装在了相应的方法中,这样整体的逻辑就比较清晰了。Reflector 构造方法所做的事情均已进行了注释,大家对照着注释先看一下。相关方法的细节待会会进行分析。看完构造方法,下面我来通过表格的形式,列举一下 Reflector 部分成员变量的用途。如下:

    变量名类型用途
    readablePropertyNamesString[]可读属性名称数组,用于保存 getter 方法对应的属性名称
    writeablePropertyNamesString[]可写属性名称数组,用于保存 setter 方法对应的属性名称
    setMethodsMap<String, Invoker>用于保存属性名称到 Invoke 的映射。setter 方法会被封装到 MethodInvoker 对象中,Invoke 实现类比较简单,大家自行分析
    getMethodsMap<String, Invoker>用于保存属性名称到 Invoke 的映射。同上,getter 方法也会被封装到 MethodInvoker 对象中
    setTypesMap<String, Class<?>>用于保存 setter 对应的属性名与参数类型的映射
    getTypesMap<String, Class<?>>用于保存 getter 对应的属性名与返回值类型的映射
    caseInsensitivePropertyMapMap<String, String>用于保存大写属性名与属性名之间的映射,比如 <NAME, name>

    上面列举了一些集合变量,这些变量用于缓存各种元信息。关于这些变量,这里描述的不太好懂,主要是不太好解释。要想了解这些变量更多的细节,还是要深入到源码中。所以我们成热打铁,继续往下分析。

     

    3) getter 方法解析过程

    getter 方法解析的逻辑被封装在了addGetMethods方法中,这个方法除了会解析形如getXXX的方法,同时也会解析isXXX方法。

    如上,addGetMethods 方法的执行流程如下:

    在上面的执行流程中,前三步比较简单,大家自行分析吧。第4步也不复杂,下面我会把源码贴出来,大家看一下就能懂。在这几步中,第5步逻辑比较复杂,这一步逻辑我们重点关注一下。

    以上就是解除冲突的过程,代码有点长,不太容易看懂。这里大家只要记住解决冲突的规则即可理解上面代码的逻辑。相关规则如下:

    分析完 getter 方法的解析过程,下面继续分析 setter 方法的解析过程。

     

    4) setter 方法解析过程

    与 getter 方法解析过程相比,setter 方法的解析过程与此有一定的区别。主要体现在冲突出现的原因,以及冲突的解决方法上。那下面,我们深入源码来找出两者之间的区别。

    从上面的代码和注释中,我们可知道 setter 方法之间出现冲突的原因。即方法存在重载,方法重载导致methodToProperty方法解析出的属性名完全一致。而 getter 方法之间出现冲突的原因是getXXX和isXXX对应的属性名一致。既然冲突发生了,要进行调停,那接下来继续来看看调停冲突的逻辑。

    关于 setter 方法冲突的解析规则,这里也总结一下吧。

    到此关于 setter 方法的解析过程就说完了。我在前面说过 MetaClass 的hasSetter最终调用了 Refactor 的hasSetter方法,那么现在是时候分析 Refactor 的hasSetter方法了。代码如下如下:

    代码如上,就两行,很简单,就不多说了。

     

    4. PropertyTokenizer

    对于较为复杂的属性,需要进行进一步解析才能使用。那什么样的属性是复杂属性呢?来看个测试代码就知道了。

    如上,Article类中包含了一个Author引用。然后我们调用 articleMeta 的 hasSetter 检测author.idauthor.name属性是否存在,我们的期望结果为 true。测试结果如下:

    img

    如上,标记⑤处的输出均为 true,我们的预期达到了。标记②处检测 Article 数组的是否存在 setter 方法,结果也均为 true。这说明 PropertyTokenizer 对数组和复合属性均进行了处理。那它是如何处理的呢?答案如下:

    以上是 PropertyTokenizer 的源码分析,注释的比较多,应该分析清楚了。大家如果看懂了上面的分析,那么可以自行举例进行测试,以加深理解。

     

    5. ExpressionEvaluator

    对象导航图语言(OGNL)是一种开源的JAVA表达式语言,可以方便的存取对象属性和调用方法。下面是一个OGNL表达式的案例: