深入分析 MyBatis 映射器文件解析过程

416 阅读15分钟

本文为稀土掘金技术社区首发签约文章,30 天内禁止转载,30 天后未获授权禁止转载,侵权必究!
大家好,我是 王有志,一个分享硬核 Java 技术的金融摸鱼侠,欢迎大家加入 Java 人自己的交流群“共同富裕的 Java 人”。

上一篇文章 中,我们已经分析了 MyBatis 应用程序初始化过程中除了映射器文件之外的所有配置项解析的源码,并对 XMLConfigBuilder 解析 MyBatis 映射器文件的源码做了简单的分析。

今天我们继续学习 MyBatis 解析映射器文件的源码,深入到 XMLMapperBuilder 中,对XMLMapperBuilder#parse方法做一个整体上的分析。

由于解析 MyBatis 映射器文件的 resultMap 元素和解析 SQL 语句(sql 元素,select 元素,insert 元素,update 元素和 delete 元素)的内容过于庞大,塞到一篇文章中实在有些困难,因此本文中会对这部分内容一笔带过,后面单独成文进行分析。

XMLConfigBuilder#mappersElement 方法解析

XMLConfigBuilder#mappersElement方法的部分源码如下:

private void mappersElement(XNode context) throws Exception {
  // 循环 MyBatis核心配置文件的 mappers 元素
  for (XNode child : context.getChildren()) {
    // 使用 package 元素配置的映射器
    if ("package".equals(child.getName())) {
      String mapperPackage = child.getStringAttribute("name");
      configuration.addMappers(mapperPackage);
    } else {
      String resource = child.getStringAttribute("resource");
      String url = child.getStringAttribute("url");
      String mapperClass = child.getStringAttribute("class");
      // 使用 resource 属性配置的映射器
      if (resource != null && url == null && mapperClass == null) {
        ErrorContext.instance().resource(resource);
        try (InputStream inputStream = Resources.getResourceAsStream(resource)) {
          XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
          mapperParser.parse();
        }
      } 
        /// 使用 url 属性配置的映射器
      else if (resource == null && url != null && mapperClass == null) {
        ErrorContext.instance().resource(url);
        try (InputStream inputStream = Resources.getUrlAsStream(url)) {
          XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
          mapperParser.parse();
        }
      }
        // 使用 mapperClass 属性配置的映射器
      else if (resource == null && url == null && mapperClass != null) {
        Class<?> mapperInterface = Resources.classForName(mapperClass);
        configuration.addMapper(mapperInterface);
      } else {
        throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
      }
    }
  }
}

上面的源码中,总共有 4 种解析 MyBatis 映射器文件的逻辑,总体可以归纳为两大类:

  • 使用 mappers 元素的子元素 package 配置的 MyBatis 映射器和使用 mappers 元素的子元素 mapper 的 class 属性配置的 MyBatis 映射器,这两种方式最终通过调用MapperAnnotationBuilder#parse方法实现 MyBatis 映射器文件的解析;
  • 使用 mappers 元素的子元素 mapper 的 resource 属性和 url 属性配置的 MyBatis 映射器,这两种方式最终是通过调用XMLMapperBuilder#parse方法实现 MyBatis 映射器文件的解析。

虽然方式上有所差异,但它们最终的目的是相同的,都是为了解析 MyBatis 映射器文件,构建出 MyBatis 应用程序内部使用的实例,以实现后续 MyBatis 执行 SQL 语句的功能

由于我们一直是以 MyBatis 原生应用程序为例进行学习的,并且使用的是 XML 形式的 MyBatis 映射器配置,因此我们下面只对XMLMapperBuilder#parse方法进行分析。

Tips:MapperAnnotationBuilder 主要功能是处理 MyBatis 注解配置的。

构建 XMLMapperBuilder

了解XMLMapperBuilder#parse方法前,我们先来看构建 XMLMapperBuilder 实例的过程,它构造方法源码如下:

public class XMLMapperBuilder extends BaseBuilder {
  public XMLMapperBuilder(InputStream inputStream, Configuration configuration, String resource,Map<String, XNode> sqlFragments) {
    this(new XPathParser(inputStream, true, configuration.getVariables(), new XMLMapperEntityResolver()), configuration, resource, sqlFragments);
  }

  private XMLMapperBuilder(XPathParser parser, Configuration configuration, String resource, Map<String, XNode> sqlFragments) {
    super(configuration);
    this.builderAssistant = new MapperBuilderAssistant(configuration, resource);
    this.parser = parser;
    this.sqlFragments = sqlFragments;
    this.resource = resource;
  }
}

与 XMLConfigBuilder 一样,XMLMapperBuilder 也是继承自 BaseBuilder,这也就说明每一个 XMLMapperBuilder 实例内部都存储着 Configuration 实例,TypeAliasRegistry 实例和 TypeHandlerRegistry 实例

除了 XMLConfigBuilder 和 XMLMapperBuilder 外,MyBatiis 构建 SQL 语句使用到的 Builder 也是继承自 BaseBuilder 的,它的集成体系如下:

BaseBuilder继承体系.png

本文中还会出现继承自 BaseBuilder 的 MapperBuilderAssistant,至于其它的继承自 BaseBuilder 的子类,我们还会在后续的文章中看到。

XMLMapperBuilder 在构造方法中为 4 个自身独有的成员变量进行了赋值,来看下这 4 个变量的作用:

  • MapperBuilderAssistant 类型的变量 builderAssistant,用于解析和注册 MyBatis 映射器的辅助类;
  • XPathParser 类型的变量 parser,与 XMLConfigBuilder 中的 parser 类似,此时用于构建 XPathParser 的变量 inputStream 是由 MyBatis 映射器解析而来;
  • Map<String, XNode> 类型的变量 sqlFragments,用于存储 SQL 语句的容器;
  • String 类型的变量 resource,MyBatis 映射器文件的路径。

关于 MapperBuilderAssistant 的内容,我们已经在 《MyBatis中二级缓存的配置与实现原理》 中聊过了,这里就不赘述了。

XMLMapperBuilder#parse 方法解析

XMLMapperBuilder#parse方法的源码如下:

public class XMLMapperBuilder extends BaseBuilder {

  public void parse() {
    if (!configuration.isResourceLoaded(resource)) {
      configurationElement(parser.evalNode("/mapper"));
      // 将已经解析的资源文件存储到 Configuration 实例的 loadedResources 变量中,该变量底层使用 HasSet
      configuration.addLoadedResource(resource);
      bindMapperForNamespace();
    }
    parsePendingResultMaps();
    parsePendingCacheRefs();
    parsePendingStatements();
  }
}

XMLMapperBuilder#parse 方法中先后调用了 6 个方法:

  • Configuration#isResourceLoaded方法,用于判断当前 Configuration 实例是否加载过资源文;
  • XMLMapperBuilder#configurationElement方法,用于处理 MyBatis 映射器文件中 mapper 元素的配置内容;
  • XMLMapperBuilder#bindMapperForNamespace方法,用于注册 MyBatis 映射器文件对应的 Mapper 接口;
  • XMLMapperBuilder#parsePendingResultMaps方法,用于处理XMLMapperBuilder#configurationElement方法中解析失败的 resultMap 元素的配置内容;
  • XMLMapperBuilder#parsePendingCacheRefs方法,用于处理XMLMapperBuilder#configurationElement方法中解析失败的 cache-ref 元素的配置内容;
  • XMLMapperBuilder#parsePendingStatements方法,用于处理XMLMapperBuilder#configurationElement方法中解析失败的 SQL 语句。

Configuration#isResourceLoaded 方法解析

Configuration#isResourceLoaded方法非常简单,用于判断当前的 Configuration 实例是否加载过资源文件,源码如下:

public class Configuration {

  protected final Set<String> loadedResources = new HashSet<>();

  public boolean isResourceLoaded(String resource) {
    return loadedResources.contains(resource);
  }
}

实现方式上,Configuration 使用 HashSet 类型的变量 loadedResources 存储加载完成的资源文件,通过调用Set#contains方法判断改文件是否已经完成加载。

XMLMapperBuilder#configurationElement 方法解析

XMLMapperBuilder#configurationElement方法负责调用解析 MyBatis 映射器中每项配置的方法,该方法的部分源码如下:

public class XMLMapperBuilder extends BaseBuilder {
  private void configurationElement(XNode context) {
    String namespace = context.getStringAttribute("namespace");
    if (namespace == null || namespace.isEmpty()) {
      throw new BuilderException("Mapper's namespace cannot be empty");
    }
    // 将 namespace 赋值给 builderAssistant
    builderAssistant.setCurrentNamespace(namespace);
    cacheRefElement(context.evalNode("cache-ref"));
    cacheElement(context.evalNode("cache"));
    parameterMapElement(context.evalNodes("/mapper/parameterMap"));
    resultMapElements(context.evalNodes("/mapper/resultMap"));
    sqlElement(context.evalNodes("/mapper/sql"));
    buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
  }
}

是不是非常眼熟?它和我们在 《深入分析 MyBatis 应用程序初始化流程》 中分析的XMLConfigBuilder#parseConfiguration方法不能说十分相似,简直是一模一样。

不过这里需要注意,XMLMapperBuilder#configurationElement方法是在XMLConfigBuilder#mappersElement方法的循环中调用的(跳过了“中间商”XMLMapperBuilder#parse 方法),XMLConfigBuilder#mappersElement方法循环的是 MyBatis 核心配置文件中的 mappers 元素的子元素 mapper,即循环每个 MyBatis 映射器文件,并别调用XMLMapperBuilder#configurationElement方法进行解析,那么我们可以理解为XMLMapperBuilder#configurationElement方法的入参是每个 MyBatis 映射器,这也就是说XMLMapperBuilder#configurationElement方法是在每个 MyBatis 映射器对应的 namespace 下操作的。

解析 namespace 属性

XMLMapperBuilder#configurationElement方法的第 3 行代码,获取 MyBatis 映射器文件中 mapper 元素的 namespace 属性的配置,XNode 是 MyBatis 内部的封装,底层借助了 java.util.Properties 类实现配置的读取;第 4 行代码到第 6 行代码的条件语句是对 namespace 进行的非空校验,如果 namespace 为空则直接抛出异常,这会导致 MyBatis 应用程序启动失败,这点我们在 《MyBatis 入门》 中也提到过。

解析 cache-ref 元素

XMLMapperBuilder#configurationElement方法的第 9 行代码通过调用XMLMapperBuilder#cacheRefElement方法解析 MyBatis 映射器中 cache-ref 元素的配置,该方法源码如下:

private void cacheRefElement(XNode context) {
if (context != null) {
  configuration.addCacheRef(builderAssistant.getCurrentNamespace(), context.getStringAttribute("namespace"));
  CacheRefResolver cacheRefResolver = new CacheRefResolver(builderAssistant, context.getStringAttribute("namespace"));
  try {
    cacheRefResolver.resolveCacheRef();
  } catch (IncompleteElementException e) {
    configuration.addIncompleteCacheRef(cacheRefResolver);
  }
}
}

《MyBatis中二级缓存的配置与实现原理》 中,我们已经介绍过了 cache 元素,可以知道每个 MyBatis 映射器中的 cache 元素(二级缓存)都是与它的 namespace 绑定的,并且会存储到 Configuration 的成员变量 caches 中

cache-ref 元素负责引用其它 MyBatis 映射器定义的二级缓存(cache 元素的配置),并将关联关系保存到 Configuration 的 Map<String, String>类型的变 cacheRefMap 中。

第 3 行代码,将当前 MyBatis 映射器的 namespace 作为 Key,被引用的二级缓存对应的 MyBatis 映射器的 namespace 作为 Value,存储到 cacheRefMap 中;第 4 行代码,创建 CacheRefResolver 实例,该实例中保存了 MapperBuilderAssistant 实例和被引用的二级缓存对应的 MyBatis 映射器的 namespace;第 6 行代码,调用CacheRefResolver#resolveCacheRef方法,解析被引用的二级缓存,源码如下:

public Cache resolveCacheRef() {
  return assistant.useCacheRef(cacheRefNamespace);
}

接着来看MapperBuilderAssistant#useCacheRef方法,部分源码如下:

public Cache useCacheRef(String namespace) {
// 标记未成功解析 cache-ref
unresolvedCacheRef = true;
// 通过 namespace获取 Cache 实例
Cache cache = configuration.getCache(namespace);
// Cache实例为空时抛出异常
if (cache == null) {
  throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.");
}
// 将当前 namespace 的 currentCache 变量指向被引用的 Cache 实例
currentCache = cache;
// 标记已成功解析 cache-ref
unresolvedCacheRef = false;
return cache;
}

来看第 7 行的条件语句,此时的 namespace 为被引用的二级缓存对应的 MyBatis 映射器的 namespace,如果此时获取到的 Cache 实例为空,则说明该 namespace 所在的 MyBatis 映射器还未被解析,这时会抛出异常,并回到XMLMapperBuilder#cacheRefElement方法的第 8 行,将 CacheRefResolver 实例存储到 Configuration 的成员变量 incompleteCacheRefs 中。

这里我们需要关注下 Configuration 的成员变量 incompleteCacheRefs,它存储第一次解析 cache-ref 元素失败的 CacheRefResolver 实例。第一次解析会失败的原因是,由于配置顺序的不同,可能存在使用到 cache-ref 元素的 MyBatis 映射器先于被引用二级缓存的 MyBatis 映射器解析,导致解析 cache-ref 元素时,引用 Cache 实例失败。

Configuration 中除了 incompleteCacheRefs 外,Configuration 中还有 3 个用于存储未完成解析的元素的容器:

// 用于存储未完成解析的 SQL 语句
protected final Collection<XMLStatementBuilder> incompleteStatements = new LinkedList<>();

// 用于存储未完成解析的结果集映射(resultMap 元素)
protected final Collection<ResultMapResolver> incompleteResultMaps = new LinkedList<>();

// 用于存储未完成解析或未绑定到SQL语句的接口方法
protected final Collection<MethodResolver> incompleteMethods = new LinkedList<>();

其中 incompleteStatements 和 incompleteResultMaps 我们在后面的文章中还会涉及到,至于 incompleteMethods 是 MyBatis 注解开发时使用的,我们不会涉及到这部分内容。

解析 cache 元素和 parameterMap 元素

XMLMapperBuilder#configurationElement方法的第 10 行代码调用的XMLMapperBuilder#cacheElement方法是用来解析 MyBatis 映射器文件中的 cache 元素的,也就是解析配置创建 MyBatis 二级缓存的过程,这部分源码我们已经在 《MyBatis中二级缓存的配置与实现原理》 中做过了分析,这里就不再赘述了,不熟悉的小伙伴可以回顾下这篇文章。

XMLMapperBuilder#configurationElement方法的第 11 行代码调用的XMLMapperBuilder#parameterMapElement方法是用来解析 parameterMap 元素的,该元素已经被 MyBatis 官方标记为废弃,并且在未来的版本中可能会被移除,所以关于 parameterMap 元素的源码也不在我们分析的范畴中。

解析 resultMap 元素

XMLMapperBuilder#configurationElement方法的第 12 行代码调用的XMLMapperBuilder#resultMapElements方法是负责解析 resultMap 元素的,由于这

其中,第 12 行调用的XMLMapperBuilder#resultMapElements方法和第 14 行调用的XMLMapperBuilder#buildStatementFromContext方法中,如果解析失败,会将解析失败的内容存储到 Configuration 实例的 incompleteResultMaps 变量和 incompleteStatements 变量中。

解析 sql 元素和 SQL 语句

第 13 行调用的XMLMapperBuilder#sqlElement方法是负责解析 sql 元素的,第 14 行调用的XMLMapperBuilder#buildStatementFromContext方法是负责解析 select 元素,insert 元素,update 元素和 delete 元素的,由于这部分内容非常庞大,因此这部分我们单独成文,再做分析。

XMLMapperBuilder#bindMapperForNamespace 方法分析

XMLMapperBuilder#bindMapperForNamespace方法用于实现将 MyBatis 映射器文件绑定到对应的 Mapper 接口上,并注册到 MapperRegistry 中,修改后的方法源码如下:

private void bindMapperForNamespace() {
  String namespace = builderAssistant.getCurrentNamespace();
  if (namespace != null) {
    //使用反射获取 namespace 对应的 Java 接口
    Class<?> boundType = Resources.classForName(namespace);
    // 判断当前的 Mapper 接口是否被注册
    if (boundType != null && !configuration.hasMapper(boundType)) {
      // 将当前的 MyBatis 映射器添加到 Configuration 的 loadedResources 变量中,表示已经完成了当前资源文件的加载
      configuration.addLoadedResource("namespace:" + namespace);
      // 将 MyBatis 的映射器注册到 MapperRegistry 的 knownMappers 中
      configuration.addMapper(boundType);
    }
  }
}

处理未完成的解析

上面我们提到,在解析 cache-ref,resultMap 元素和 SQL 语句时,可能存在部分未完成解析的情况,并将它们分别存储到 Configuration 的 incompleteCacheRefs,incompleteResultMaps 和 incompleteStatements 中。

而在XMLMapperBuilder#parse方法中,最后调用的 3 个方法就是为了再次处理这些未完成解析的配置:

  • XMLMapperBuilder#parsePendingCacheRefs方法,解析 incompleteCacheRefs;
  • XMLMapperBuilder#parsePendingResultMaps方法,解析 incompleteResultMaps;
  • XMLMapperBuilder#parsePendingStatements方法,解析 incompleteStatements。

由于这 3 个方法除了解析的内容不同外,其余完全一致,这里我就以XMLMapperBuilder#parsePendingCacheRefs方法方法为例,源码如下:

private void parsePendingCacheRefs() {
  // 获取 incompleteCacheRefs
  Collection<CacheRefResolver> incompleteCacheRefs = configuration.getIncompleteCacheRefs();
  // 加锁
  synchronized (incompleteCacheRefs) {
    Iterator<CacheRefResolver> iter = incompleteCacheRefs.iterator();
    while (iter.hasNext()) {
      try {
        // 重新解析 cache-ref 元素
        iter.next().resolveCacheRef();
        iter.remove();
      } catch (IncompleteElementException e) {
      }
    }
  }
}

逻辑上非常简单,只是遍历了 incompleteCacheRefs 中的元素,重新进行解析,解析成功后便将其从容器中删除。

文章中使用的 MyBatiis 版本为 3.5.15,这个版本中XMLMapperBuilder#parsePendingResultMaps方法,XMLMapperBuilder#parsePendingCacheRefs方法和 XMLMapperBuilder#parsePendingStatements方法使用了 synchronized 关键字进行加锁,防止并发问题。

2024 年 4 月 3 日发布 MyBatis 3.5.16 版本之后(包括尚未正式发布的 3.5.17),这 3 个方法中使用了 ReentrantLock 替代 synchronized,并使用了Collection#removeIf方法和 Lambda 表达式替换了迭代器(Iterator),因此如果你使用的是最新版的 MyBatiis,源码上会有所出入。

好了,到这里我们就把解析 MyBatis 映射器的整体流程介绍完了,下一篇文章中,我会和大家一起分析 MyBatis 是如何解析 resultMap 元素的。

回顾与思考

到这里,我们已经从整体上了解了 MyBatis 解析映射器的过程,现在我们用一张图来总结下源码的执行流程,如下:

02.MyBaits映射器文件解析:总览.png

最后,我们来看一下通过上述的源码,我们能够借鉴哪些技巧并运用到我们日常的开发工作中。

try...catch...resources 语句

XMLConfigBuilder#mappersElement 方法中使用了一段特殊的 try 语句块,如下:

try (InputStream inputStream = Resources.getResourceAsStream(resource)) {
    XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
    mapperParser.parse();
}

这是 Java 1.7 中引入的特性“try...catch...resources”语句,它允许直接在 try 语句中声明一个或多个继承自 java.lang.AutoCloseable 的资源,这些资源会在 try 语句块执行完毕后自动关闭,即便是 try 语句块中发生了异常。

上述的代码等价于如下形式的代码:

InputStream inputStream = null;
try {
  inputStream = Resources.getResourceAsStream(resource);
  XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
  mapperParser.parse();  
} finally {
  if(inputStream != null) {
    inputStream.close();
  }
}

对比两者的差异,最直观的便是“try...catch...resources”语句的代码量会少很多,且不需要在 try 语句块外声明变量,不过最重要的是在“try...catch...resources” 语句中,无需显示调用 AutoCloseable#close 方法,JVM 会自动调用 AutoCloseable#close 方法,这样可以有效的防止资源泄漏

良好的代码组织形式

我们注意下XMLMapperBuilder#configurationElement方法和上一篇文章中的XMLConfigBuilder#parseConfiguration方法,它们自身都不负责元素的解析,而是通过调用不同的方法,实现不同元素的解析。

public class XMLMapperBuilder extends BaseBuilder {
  private void configurationElement(XNode context) {
    String namespace = context.getStringAttribute("namespace");
    builderAssistant.setCurrentNamespace(namespace);
    cacheRefElement(context.evalNode("cache-ref"));
    cacheElement(context.evalNode("cache"));
    parameterMapElement(context.evalNodes("/mapper/parameterMap"));
    resultMapElements(context.evalNodes("/mapper/resultMap"));
    sqlElement(context.evalNodes("/mapper/sql"));
    buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
  }
}

public class XMLConfigBuilder extends BaseBuilder {
  private void parseConfiguration(XNode root) {
    propertiesElement(root.evalNode("properties"));
    Properties settings = settingsAsProperties(root.evalNode("settings"));
    loadCustomVfsImpl(settings);
    loadCustomLogImpl(settings);
    typeAliasesElement(root.evalNode("typeAliases"));
    pluginsElement(root.evalNode("plugins"));
    objectFactoryElement(root.evalNode("objectFactory"));
    objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
    reflectorFactoryElement(root.evalNode("reflectorFactory"));
    settingsElement(settings);
    environmentsElement(root.evalNode("environments"));
    databaseIdProviderElement(root.evalNode("databaseIdProvider"));
    typeHandlersElement(root.evalNode("typeHandlers"));
    mappersElement(root.evalNode("mappers"));
  }
}

这是一种模块化的设计,将解析 MyBatis 映射器的任务拆分成多个解析子元素的任务,并为每个解析子元素的任务编写方法,通过调度方法统一调度,使得每个子元素的解析过程都是相互的独立的,这样的代码组织形式在结构上更加清晰,可读性更高,而且当我们需要修改某个子元素的解析逻辑时,只需要修改对应的方法即可,大大提高了可维护性,避免了高耦合带来的“牵一发而动全身”的情况。

Tips:这种形式是不是很像我们之前写作文时的“总分总”的结构?

依赖注入的应用

MyBatis 中,无论是用于解析 MyBatis 核心配置文件的 XMLConfigBuilder 还是用来解析 MyBatis 映射器的 XMLMapperBuilder,它们都继承自 BaseBuilder,因此它们都持有指向 Configuration 实例的指针。

这是一种依赖注入的应用,无论是 MyBatis 应用程序初始化阶段的 XMLConfigBuilder 和 XMLMapperBuilder,还是未来会见到的 XMLStatementBuilder(用于解析 MyBatis 映射器中的 SQL 语句) 和 XMLScriptBuilder(用于解析 SQL 语句中的动态元素),它们功能的实现都依赖于 Configuration 实例,通过在外部创建并解析 Configuration 实例,并注入到不同的组件中,使得 Configuration 实例在这个组件之间可重用,并且各个组件无需单独解析 MyBatis 核心配置文件。

这样的实现方式,保证了各个组件之间使用到的 Configuration 实例是一致的,而共享的 Configuration 实例确保了组件之间的不会耦合;另外,各个组件持有 Configuration 实例,而不是单独解析 Configuration 实例,保证了当需要对 Configuration 拓展时,只需要改动少数组件即可,而非全量改动;最后,通过注入 Configuration 实例,使得每个组件大都是完整且独立的,这样也使得组件的单独测试成为可能。


尾图(无二维码).png