MyBatis 源码分析 - 映射文件解析过程

1.简介

在上一篇文章中,我详细分析了 MyBatis 配置文件的解析过程。由于上一篇文章的篇幅比较大,加之映射文件解析过程也比较复杂的原因。所以我将映射文件解析过程的分析内容从上一篇文章中抽取出来,独立成文,于是就有了本篇文章。在本篇文章中,我将分析映射文件中出现的一些及节点,比如 , 等。下面我们来看一个映射文件配置示例。



    

    
        
        
        
    

    
        author
    

    

    

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

private void configurationElement(XNode context) {
    try {
        // 获取 mapper 命名空间
        String namespace = context.getStringAttribute("namespace");
        if (namespace == null || namespace.equals("")) {
            throw new BuilderException("Mapper's namespace cannot be empty");
        }

        // 设置命名空间到 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"));

        // 解析 
    SELECT id, title FROM  WHERE id = #{id}



    UPDATE  SET title = #{title} WHERE id = #{id}

如上,上面配置中, 以及 等。这几个节点中存储的是相同的内容,都是 SQL 语句,所以这几个节点的解析过程也是相同的。在进行代码分析之前,这里需要特别说明一下:为了避免和 节点混淆,同时也为了描述方便,这里把 节点名称为 select String nodeName = context.getNode().getNodeName(); // 根据节点名称解析 SqlCommandType SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH)); boolean isSelect = sqlCommandType == SqlCommandType.SELECT; boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect); boolean useCache = context.getBooleanAttribute("useCache", isSelect); boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false); // 解析 节点 XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant); includeParser.applyIncludes(context.getNode()); // 解析 节点 processSelectKeyNodes(id, parameterTypeClass, langDriver); // 解析 SQL 语句 SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass); String resultSets = context.getStringAttribute("resultSets"); String keyProperty = context.getStringAttribute("keyProperty"); String keyColumn = context.getStringAttribute("keyColumn"); KeyGenerator keyGenerator; String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX; keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true); if (configuration.hasKeyGenerator(keyStatementId)) { // 获取 KeyGenerator 实例 keyGenerator = configuration.getKeyGenerator(keyStatementId); } else { // 创建 KeyGenerator 实例 keyGenerator = context.getBooleanAttribute("useGeneratedKeys", configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType)) ? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE; } /* * 构建 MappedStatement 对象,并将该对象存储到 * Configuration 的 mappedStatements 集合中 */ builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum, flushCache, useCache, resultOrdered, keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets); }

上面的代码比较长,看起来有点复杂。不过如果大家耐心看一下源码,会发现,上面的代码中起码有一般的代码都是用来获取节点属性,以及解析部分属性等。抛去这部分代码,以上代码做的事情如下。

  1. 解析 节点
  2. 解析 节点
  3. 解析 SQL,获取 SqlSource
  4. 构建 MappedStatement 实例

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

2.1.5.1 解析 节点

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

public void applyIncludes(Node source) {
    Properties variablesContext = new Properties();
    Properties configurationVariables = configuration.getVariables();
    if (configurationVariables != null) {
        // 将 configurationVariables 中的数据添加到 variablesContext 中
        variablesContext.putAll(configurationVariables);
    }

    // 调用重载方法处理  节点
    applyIncludes(source, variablesContext, false);
}

上面代码创建了一个新的 Properties 对象,并将全局 Properties 添加到其中。这样做的原因是 applyIncludes 的重载方法会向 Properties 中添加新的元素,如果直接将全局 Properties 传给重载方法,会造成全局 Properties 被污染。这是个小细节,一般容易被忽视掉。其他没什么需要注意的了,我们继续往下看。

private void applyIncludes(Node source, final Properties variablesContext, boolean included) {

    // ⭐️ 第一个条件分支
    if (source.getNodeName().equals("include")) {

        /*
         * 获取  节点。若 refid 中包含属性占位符 ${},
         * 则需先将属性占位符替换为对应的属性值
         */
        Node toInclude = findSqlFragment(getStringAttribute(source, "refid"), variablesContext);

        /*
         * 解析  的子节点 ,并将解析结果与 variablesContext 融合,
         * 然后返回融合后的 Properties。若  节点的 value 属性中存在占位符 ${},
         * 则将占位符替换为对应的属性值
         */
        Properties toIncludeContext = getVariablesContext(source, variablesContext);

        /*
         * 这里是一个递归调用,用于将  节点内容中出现的属性占位符 ${} 替换为对应的
         * 属性值。这里要注意一下递归调用的参数:
         * 
         *  - toInclude: 节点对象
         *  - toIncludeContext: 子节点  的解析结果与
         *                      全局变量融合后的结果 
         */
        applyIncludes(toInclude, toIncludeContext, true);

        /*
         * 如果  节点不在一个文档中,
         * 则从其他文档中将  节点引入到  所在文档中
         */
        if (toInclude.getOwnerDocument() != source.getOwnerDocument()) {
            toInclude = source.getOwnerDocument().importNode(toInclude, true);
        }
        // 将  节点替换为  节点
        source.getParentNode().replaceChild(toInclude, source);
        while (toInclude.hasChildNodes()) {
            // 将  中的内容插入到  节点之前
            toInclude.getParentNode().insertBefore(toInclude.getFirstChild(), toInclude);
        }

        /*
         * 前面已经将  节点的内容插入到 dom 中了,
         * 现在不需要  节点了,这里将该节点从 dom 中移除
         */
        toInclude.getParentNode().removeChild(toInclude);

    // ⭐️ 第二个条件分支
    } else if (source.getNodeType() == Node.ELEMENT_NODE) {
        if (included && !variablesContext.isEmpty()) {
            NamedNodeMap attributes = source.getAttributes();
            for (int i = 0; i < attributes.getLength(); i++) {
                Node attr = attributes.item(i);
                // 将 source 节点属性中的占位符 ${} 替换成具体的属性值
                attr.setNodeValue(PropertyParser.parse(attr.getNodeValue(), variablesContext));
            }
        }
        
        NodeList children = source.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            // 递归调用
            applyIncludes(children.item(i), variablesContext, included);
        }
        
    // ⭐️ 第三个条件分支
    } else if (included && source.getNodeType() == Node.TEXT_NODE && !variablesContext.isEmpty()) {
        // 将文本(text)节点中的属性占位符 ${} 替换成具体的属性值
        source.setNodeValue(PropertyParser.parse(source.getNodeValue(), variablesContext));
    }
}

上面的代码如果从上往下读,不太容易看懂。因为上面的方法由三个条件分支,外加两个递归调用组成,代码的执行顺序并不是由上而下。要理解上面的代码,我们需要定义一些配置,并将配置带入到具体代码中,逐行进行演绎。不过,更推荐的方式是使用 IDE 进行单步调试。为了便于讲解,我把上面代码中的三个分支都用 ⭐️ 标记了出来,这个大家注意一下。好了,必要的准备工作做好了,下面开始演绎代码的执行过程。演绎所用的测试配置如下:


    
        ${table_name}
    

    

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

参数值:
source =  子节点列表
3. 遍历子节点列表,将子节点作为参数,进行递归调用

第一次调用 applyIncludes 方法,source = 节点的子节点列表。可获取到的子节点如下:

编号 子节点 类型 描述
1 SELECT id, title FROM TEXT_NODE 文本节点
2 ELEMENT_NODE 普通节点
3 WHERE id = #{id} TEXT_NODE 文本节点

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

MyBatis 源码分析 - 映射文件解析过程_第3张图片

节点1的调用过程比较简单,只有两层调用。然后我们在看一下子节点2的调用过程,如下:

MyBatis 源码分析 - 映射文件解析过程_第4张图片

上面是子节点2的调用过程,共有四层调用,略为复杂。大家自己也对着配置,把源码走一遍,然后记录每一次调用的一些状态,这样才能更好的理解 applyIncludes 方法的逻辑。

好了,本节内容先到这里,继续往下分析。

2.1.5.2 解析 节点

对于一些不支持自增主键的数据库来说,我们在插入数据时,需要明确指定主键数据。以 Oracle 数据库为例,Oracle 数据库不支持自增主键,但它提供了自增序列工具。我们每次向数据库中插入数据时,可以先通过自增序列获取主键数据,然后再进行插入。这里涉及到两次数据库查询操作,我们不能在一个