MyBatis原理(二) —— Mapper文件

1,385 阅读9分钟

我正在参加「掘金·启航计划」

前言

在上文中,研究了MyBatis针对配置文件的解析,但节省篇幅没有编写对mapper文件的解析。本文便来探究下MyBatis如何将Mapper文件与对应接口关联起来,主要涉及以下代码:

mapperElement(root.evalNode("mappers"));

建议配合官方文档食用更佳。有些部分可能并没太细,写多了又占篇幅,也容易绕进去。更多的是希望读者通过给出的大体思路,去debug验证这些流程,而不是味同嚼蜡般的阅读。

1. 概述

MyBatis提供以下三种方式配置Mapper文件:


<mappers>
  <!-- 使用相对于类路径的资源引用 -->
  <mapper resource="org/mybatis/builder/xxx.xml"/>
   <!-- 使用完全限定资源定位符(URL) -->
  <mapper url="file:///var/mappers/AuthorMapper.xml"/>
  <!-- 使用映射器接口实现类的完全限定类名 -->
  <mapper class="org.mybatis.builder.BlogMapper"/>
  <!-- 将包内的映射器接口全部注册为映射器 -->
  <package name="org.mybatis.builder"/>
</mappers>

注意resource,url,class不能共存,但他们其中之一可以和<package>共存。对于mapperElement()源码,节省篇幅此处就不贴出来了。

resourceurl处理逻辑一致,而class又和package内部相似,并增加了注解处理,所以本文以<package>为主线路。

2. addMapper()

MyBatis内部使用了MapperRegistry来保存和注册mapper

addMapper() knownMappers是一个Map对象,用于存放已注册的mapper,key是对应接口的类对象,valueMapperProxyFactory。现在无需知道MapperProxyFactory是个啥东东,只要知道里面保存了当前接口的类对象即可。

解析文件的核心方法交给了MapperAnnotationBuilder,别被名称迷惑了,这个类不仅会解析注解,也会解析xml

MapperAnnotationBuilder内部还包含一个MapperBuilderAssistant对象。从名字上看该类就是一个构建mapper的助手。其内部有一个字符串resource属性被赋值为如下内容:com/demo/xxx.java (best guess),xxx表示当前处理的接口。

以上内容便是在parser.parse()之前所做的处理,接下来看看parse()干了啥事。

3. MapperAnnotationBuilder#parse()

方法一览:

MapperAnnotationBuilder#parse()

已给出部分方法说明。我们通过上述流程一步一个脚印,先看loadXmlResource()

3.1 loadXmlResource()

先看一眼整个方法流程 loadXmlResource() 在第一步if判断中加上namespace进行判断的原因是可能在与Spring整合中,已经由Spring调用 XMLMapperBuilder#bindMapperForNamespace加载过了,这部分内容在研究MyBatis-spring时会再提及(可能要点时间😣)。

后续内容就是查找和接口同目录或者在资源路径中是否包含同名的.xml文件,有就使用XMLMapperBuilder进行解析。这就是前面为啥选择<package>为主流程的原因以及前面所说的不要被"迷惑"。

3.1.1 XMLMapperBuilder#parse()

XMLMapperBuilder#parse() 什么这个代码就是处理XML格式的mapper的。不过核心解析方法在configurationElement()中。此处有个细节点需要注意,在MapperAnnotationBuilder#parse()中的resource表示的值是interface xxx.xxx,而到了此处resource的值为xxx/xxx/xxx.xml,代表接口和xml都被加载,这不难理解吧!

3.1.2 XMLMapperBuilder#configurationElement()

configurationElement() 整个方法就是解析mapper文件中的各个标签,转换成对应对象。

3.1.2.1 cacheRefElement()

解析<cache-ref>标签。这部分代码比较简单,我就不贴了,大家可自行参照代码阅读。

  1. 将当前namespacekey和引用的namespacevalue保存至ConfigurationcacheRefMap中。
  2. Configuration中获取其他namespace中的缓存组件,如果指定namespace没有缓存组件,则会抛出异常,待整个流程结束后,会进行重试。

3.1.2.2 cacheElement()

解析<cache/>,标签。此处假设各位对缓存的使用都非常了解。

useNewCache() 核心方法在MapperBuilderAssistant#useNewCache()中,显然是通过建造者模式创建Cache对象。在出build()方法外,前面的方法都是在给建造者设置参数。

真核心方法,还是在build()中:

build() 上述流程做个说明吧:

  1. setDefaultImplementations()处理默认情况,默认实现为PerpetualCache,添加默认装饰LruCache
  2. 使用反射创建缓存实例,id为当前namespace值。
  3. setCacheProperties()这个类通过反射设置Cache中的属性。知识点:此处也用到了上一篇中提到的MetaObject,而MetaObjectObjectWrapper又有关系。这里阐述下:MetaObject也是使用了MetaClass所提供的方法,但为了将对象和MetaClass关联起来,便使用了ObjectWrapper这个结构。相较于MetaClass,MetaObject新增了对复杂属性名称的处理(节省篇幅,各位自行了解吧)。
  4. 对默认实现会添加装饰器。非默认同时不是LoggingCache子类则只会添加LoggingCche

并不会装饰每个装饰器,而是根据<cache/>标签定义的属性进行装饰,默认情况会只会装饰LruCache,SerializedCacheLoggingCacheSynchronizedCache。执行时便是从后开始。

3.1.2.3 resultMapElements()

resultMap标签解析。跳过parameterMapElement()原因在该标签已经不推荐使用了,在将来可能废弃。

处理的核心方法有点长,我们一点一点来看。首先是下面这一段

String type = resultMapNode.getStringAttribute("type",
        resultMapNode.getStringAttribute("ofType",
            resultMapNode.getStringAttribute("resultType",
                resultMapNode.getStringAttribute("javaType"))));
    Class<?> typeClass = resolveClass(type);
    if (typeClass == null) {
      typeClass = inheritEnclosingType(resultMapNode, enclosingType);
    }

type代表标签中type属性,如果没有则会从ofType,resultType,javaType中获取一个不为null的。inheritEnclosingType()是处理associationcase派上用场的,用于处理类型优先级,是从父级获取,还是标签中指定的属性。

有人可能会说了,resultMap标签只能配置type属性啊,为啥还要解析其他属性呢?答案看这里

在继续往下之前,先了解下ResultMapping。它表示<resultMap>标签下的每个子标签。Discriminator则对应<discriminator/>标签。ok,我们继续:

for (XNode resultChild : resultChildren) {
      // 解析constructor标签
      if ("constructor".equals(resultChild.getName())) {
        processConstructorElement(resultChild, typeClass, resultMappings);
      } else if ("discriminator".equals(resultChild.getName())) {
        //解析discriminator标签
        discriminator = processDiscriminatorElement(resultChild, typeClass, resultMappings);
      } else {
        // 解析普通标签
        List<ResultFlag> flags = new ArrayList<>();
        if ("id".equals(resultChild.getName())) {
          flags.add(ResultFlag.ID);
        }
        resultMappings.add(buildResultMappingFromContext(resultChild, typeClass, flags));
      }
    }

看起来好像没啥,依次解析各个标签。解析普通标签和<constructor>标签只是添加的ResultFlag类型不同,普通标签只对<id>标签添加ResultFlag.ID,而构造器式每个子标签都会添加ResultFlag.CONSTRUCTORidArg标签还会多添加一个ResultFlag.ID

也就是说无论是解析<construtor>标签还是普通标签都会使用buildResultMappingFromContext()

buildResultMappingFromContext()

这个方法不是很难,大部分都是赋值操作,但在其中有个细节,在处理collectionassociationcase时,如果没有定义resultMapselect属性,则会重新走一遍上述流程,认为你可能是在对应子标签配置映射,而不是使用外部resultMap

这方法最后使用builderAssistant.buildResultMapping()完成创建resultMapping,针对普通标签和构造器标签生成ResultMapping就结束了。回到上文,我们看看<discriminator>标签是如何处理的。

private Discriminator processDiscriminatorElement(XNode context, Class<?> resultType, List<ResultMapping> resultMappings) {
    String column = context.getStringAttribute("column");
    String javaType = context.getStringAttribute("javaType");
    String jdbcType = context.getStringAttribute("jdbcType");
    String typeHandler = context.getStringAttribute("typeHandler");
    Class<?> javaTypeClass = resolveClass(javaType);
    Class<? extends TypeHandler<?>> typeHandlerClass = resolveClass(typeHandler);
    JdbcType jdbcTypeEnum = resolveJdbcType(jdbcType);
    Map<String, String> discriminatorMap = new HashMap<>();
    for (XNode caseChild : context.getChildren()) {
      String value = caseChild.getStringAttribute("value");
      String resultMap = caseChild.getStringAttribute("resultMap", processNestedResultMappings(caseChild, resultMappings, resultType));
      discriminatorMap.put(value, resultMap);
    }
    return builderAssistant.buildDiscriminator(resultType, column, javaTypeClass, jdbcTypeEnum, typeHandlerClass, discriminatorMap);
  }

处理过程和<result>标签类似,不过返回的不再是ResultMapping,而是一个Discriminator对象。内部保存了case中的映射关系。由于使用较少此处便不再展开了。

这最后就是将resultMapping整合成一个resultMap对象,对于继承的会进行resultMapping替换。

3.1.2.4 sqlElement()

此方法是解析<spl>标签。会先获取包含databaseIdsql片段(如果你忘了这是啥,回头看看第一篇吧),然后再获取和databaseId无关的。

这个方法可以说是so easy的,就是获取符合条件的<sql>标签就添加进一个为Map机构的sqlFragments属性中。核心逻辑:先加载和配置文件中配置的databaseId相同的片段,此时,相同会进行覆盖,再加载和databaseId无关的片段,存在和上一步相同的id则跳过该片段,但两个没有databaseIdsql片段,则以后面的为准。

总结:以符合配置文件的databaseId优先,其次以定义顺序。

3.1.2.5 buildStatementFromContext()

这一步可以说是核心了,MyBatis如何将配置和接口对应起来就在这里展现。也做了databaseId判断,大体流程和上一小节相似。

核心方法是在XMLStatementBuilder#parseStatementNode()中,一眼望去,全是属性设置。但还是有几个方法值得一看的。

XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
    includeParser.applyIncludes(context.getNode());

这段代码是处理<include>标签的,内部处理过程和jquery处理节点有点像。大致流程是:

  1. 定位目标<include>标签位置。
  2. 用目标标签替换<include>内容(目标标签就是<sql>标签)。
  3. sql标签内容替换到该节点前。
  4. 删除刚才替换的<sql>标签。

感兴趣的可自行debug。接下来还有个需要注意的地方,就是SqlSource的创建,里面就包含了我们的sql资源。

  public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
    XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
    return builder.parseScriptNode();
  }

对于SqlSource是啥我们不做讲解,网上大把文章,这里注重流程。

  1. 通过XmlScriptBuilder解析sql标签。
  2. 根据标签是否是动态(是否是包含$符号)的创建不同的SqlSource实现。
  3. 如果是非动态就会创建RawSqlSource,在创建时就会将#{}内容替换为?
  4. 最终封装成StaticSqlSource返回。

<if><where>这些标签的解析在builder.parseScriptNode()中的parseDynamicTags(context)中就完成了,方法挺简单就是通过不同的sqlNode解析最后组装。

再提一嘴,在使用${}时,此处并没有解析,而是在使用时,而#{}在构建xml时就解析了,到时可以直接发送给数据库,性能相对较好。

最终将每一个<select>..标签替换成对应的MappedStatement

3.1.3 bindMapperForNamespace()

此处会尝试解析接口,是的,你没看错。问题来了,我们就是从接口进来的啊,为什么还要加载接口?这个类是专门加载xml的,我们扫描接口只是顺带使用的,原则上来说并没有什么问题(我解析xml,顺带解析下接口注解啥的合理吧?)。

那怎么避免重复加载呢?接口避免重复解析xml的方式是使用!configuration.isResourceLoaded("namespace:" + type.getName())判断的,而xml避免重复扫描接口使用的就是!configuration.hasMapper(boundType)。大家可以在上述流程中看看哪出现了对应语句。

由于我们在调用MapperAnnotationBuilder#parse()之前已经填充了knownMappers,所以此处并不会再次解析接口注解(knownMappers.put(type, new MapperProxyFactory<>(type)))。

3.2 parseCache()

根据注解配置缓存。在前面解析xml的时候已经解析了缓存标签,如果两个共存则会报错(namespace值一致)。

其实不止该方法,后续的所有方法都可以说是和xml解析配置相似,只是解析的是注解。后续流程可自行了解。

4. 总结

解析mapper文件的流程如果宏观来看就是依据配置的路径扫描解析文件中的每一个标签。但其中还包括了对同路径下的接口或者xml过程,使得流程看起来略显复杂。这其中也能看出MyBatis期望我们接口和mapper文件放在同包下。

还有较为难的就是使用了比较多的XXXResolver类来生成保存xxx对象,导致流程有些许跳跃,不过也是职责分离的一种方式。

总的来说,这两篇文章就跟厨房的配菜师傅一样,将食材处理好,分类,对应成MyBatis就是解析文件,封装成对应对象。