掘金 后端 ( ) • 2024-05-14 10:02

title: 深入理解Mybatis(三):映射文件中sql的解析 date: 2021-07-11 tags: Mybatis categories: Mybatis

概述

根据上一篇文章,我们了解了 Mybatis 如何在加载配置文件后,根据指定配置方式寻找接口并且完成映射文件信息与接口的绑定的。在本篇文章,我们将延续上文,结合源码阐述映射文件中方法声明里的 sql 被解析为 java 对象的过程。

1.解析XML文件

1.1.XMLMapperBuilder

Sql 解析与 MappedStatement的生成都在 XMLMapperBuilder 进行。根据上文可知,在 XMLMapperBuilderparsePendingStatements()方法如下:

private void parsePendingStatements() {
    // 获取所有映射文件中的方法声明
    Collection<XMLStatementBuilder> incompleteStatements = configuration.getIncompleteStatements();
    synchronized (incompleteStatements) {
        Iterator<XMLStatementBuilder> iter = incompleteStatements.iterator();
        while (iter.hasNext()) {
            try {
                // 遍历并转换为Statement对象
                iter.next().parseStatementNode();
                iter.remove();
            } catch (IncompleteElementException e) {
                // Statement is still missing a resource...
            }
        }
    }
}

其中,Configuration类中的IncompleteStatements是在XMLMapperBuilder.parse()时添加进去的,我们可以理解他是一个刚从配置文件中根据<select><delete><update><insert>标签名拿到的 XML 节点,还没有做任何解析。

等到完成了接口与映射文件信息的绑定以后,再遍映射文件中的方法声明依次解析。

1.2.parseStatementNode

这个方法比较长,但是主要作用就是解析一个方法声明中的属性与子标签:

public void parseStatementNode() {
    // 获取id属性
    String id = context.getStringAttribute("id");
    String databaseId = context.getStringAttribute("databaseId");

    if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
        return;
    }

    // 获取标签节点名称
    String nodeName = context.getNode().getNodeName();
    // 判断sql类型
    SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
    // 如果是select就判断是否需要获取/清空缓存
    boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
    boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
    boolean useCache = context.getBooleanAttribute("useCache", isSelect);
    boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);

    // 将include节点转为相应的sql节点
    // Include Fragments before parsing
    XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
    includeParser.applyIncludes(context.getNode());

    // 获取入参类型
    String parameterType = context.getStringAttribute("parameterType");
    Class<?> parameterTypeClass = resolveClass(parameterType);

    // 获取lang
    String lang = context.getStringAttribute("lang");
    LanguageDriver langDriver = getLanguageDriver(lang);

    // 获取selectKey节点,解析完成后删除
    // Parse selectKey after includes and remove them.
    processSelectKeyNodes(id, parameterTypeClass, langDriver);

    // 解析sql
    // Parse the SQL (pre: <selectKey> and <include> were parsed and removed)
    KeyGenerator keyGenerator; // 生成主键
    String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;
    keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);
    // 如果设置了selectKey,则在插入获取主键生成器生成的主键
    if (configuration.hasKeyGenerator(keyStatementId)) {
        keyGenerator = configuration.getKeyGenerator(keyStatementId);
    } else {
        keyGenerator = context.getBooleanAttribute("useGeneratedKeys",
                                                   configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))
            ? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;
    }

    // 创建对应的SqlSource对象
    SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
    // 获取Statement类型,默认为prepared
    StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
    // 获取一些连接参数
    Integer fetchSize = context.getIntAttribute("fetchSize");
    Integer timeout = context.getIntAttribute("timeout");

    // 入参和返回值类型
    String parameterMap = context.getStringAttribute("parameterMap");
    String resultType = context.getStringAttribute("resultType");
    Class<?> resultTypeClass = resolveClass(resultType);
    String resultMap = context.getStringAttribute("resultMap");
    String resultSetType = context.getStringAttribute("resultSetType");
    ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);
    if (resultSetTypeEnum == null) {
        resultSetTypeEnum = configuration.getDefaultResultSetType();
    }
    String keyProperty = context.getStringAttribute("keyProperty");
    String keyColumn = context.getStringAttribute("keyColumn");
    String resultSets = context.getStringAttribute("resultSets");

    // 构建MappedStatement并添加到配置类
    builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
                                        fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
                                        resultSetTypeEnum, flushCache, useCache, resultOrdered,
                                        keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}

1.3.SqlSource的构建

在上文中有一个 parseStatementNode()方法,暂且忽略缓存与各种返回值类型的处理,我们关注一下addMappedStatement()中传入的变量 sqlSource ,它本身是一个接口,跟DataSource的功能一样,通过SqlSource接口的实现类,可以获取 sql 对象:

public interface SqlSource {
    BoundSql getBoundSql(Object parameterObject);
}

他具有的唯一一个抽象方法即为getBoundSql(),这个方法获取的 BoundSql类就是实际上的我们认为的方法声明中的那个 sql 对象:

public class BoundSql {
    private final String sql;
    private final List<ParameterMapping> parameterMappings;
    private final Object parameterObject;
    private final Map<String, Object> additionalParameters;
    private final MetaObject metaParameters;
}

里面包含有带占位符和动态标签的原始 sql 语句,以及方法声明上相关的入参。

2.SQL脚本的解析

2.1.LanguageDriver

LanguageDriver 本身也是一个接口,他的实现类用于解析参数以及注解和映射文件中的 sql,并最终根据此生成 sql 数据源,我们可以简单的理解为针对指定格式 sql 语句的解析器:

public interface LanguageDriver {

  /**
   * 创建一个参数处理器,将处理完参数后得到实际参数传递给JDBC语句。
   */
    ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql);

  /**
   * 解析XML并创建SqlSource
   */
    SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType);

  /**
   * 解析注解并创建SqlSource
   */
    SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType);

}

SqlSource 通过 LanguageDriver.createSqlSource()创建:

String lang = context.getStringAttribute("lang");
LanguageDriver langDriver = getLanguageDriver(lang);
... ...
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);

而这里的getLanguageDriver()方法,最终会回到Configuration.getLanguageDriver()中:

if (langClass == null) {
    return languageRegistry.getDefaultDriver();
}
languageRegistry.register(langClass);
return languageRegistry.getDriver(langClass);

而映射文件里方法声明中 lang 属性我们一般不会刻意设置,按上述逻辑,获取的是默认的语言驱动,这里的语言驱动来自于 Configuration 初始化时候注册的 XMLLanguageDriverRawLanguageDriver

public Configuration() {
	... ...
    languageRegistry.setDefaultDriverClass(XMLLanguageDriver.class);
    languageRegistry.register(RawLanguageDriver.class);
}

2.2.动态SQL与静态SQL

XMLLanguageDriverLanguageDriver接口的实现,它用于解析 xml 格式的 sql 语句——或者更准确点说,是 mybatis 特定的方法声明语法。

RawLanguageDriver继承了XMLLanguageDriver,而XMLLanguageDriver又实现了LanguageDriver接口:

public class XMLLanguageDriver implements LanguageDriver {

    // 创建参数处理器
    @Override
    public ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
        return new DefaultParameterHandler(mappedStatement, parameterObject, boundSql);
    }

    // 解析映射文件的sql
    @Override
    public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
        XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
        return builder.parseScriptNode();
    }

    // 解析有复杂参数的sql
    @Override
    public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
        // issue #3
        // 解析使用@Select这类sql标签上的sql
        if (script.startsWith("<script>")) {
            XPathParser parser = new XPathParser(script, false, configuration.getVariables(), new XMLMapperEntityResolver());
            // 先转为xml再按照映射文件的方式解析
            return createSqlSource(configuration, parser.evalNode("/script"), parameterType);
        } else {

            // issue #127
			// 替换占位符中的变量,转为sql语法节点
            script = PropertyParser.parse(script, configuration.getVariables());
            TextSqlNode textSqlNode = new TextSqlNode(script);
            // 如果含有动态标签
            if (textSqlNode.isDynamic()) {
                return new DynamicSqlSource(configuration, textSqlNode);
            } else {
                // 不含动态标签
                return new RawSqlSource(configuration, script, parameterType);
            }
        }
    }

}

基于XMLLanguageDriverRawLanguageDriver进一步完善了方法:

public class RawLanguageDriver extends XMLLanguageDriver {

    @Override
    public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
        SqlSource source = super.createSqlSource(configuration, script, parameterType);
        checkIsNotDynamic(source);
        return source;
    }

    @Override
    public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
        SqlSource source = super.createSqlSource(configuration, script, parameterType);
        checkIsNotDynamic(source);
        return source;
    }

    private void checkIsNotDynamic(SqlSource source) {
        if (!RawSqlSource.class.equals(source.getClass())) {
            throw new BuilderException("Dynamic content is not allowed when using RAW language");
        }
    }

}

主要是再做检查,防止非静态的 sql 使用 RawSqlSource

对于 Myabtis 来说,sql 中带有 ${}<where>这类动态标签的都认为是动态 sql,反之则是静态 sql。

3.解析Mybatis标签

3.1.XMLScriptBuilder

XMLScriptBuilder这个类用于对 Mybatis 的映射文件中的方法声明里的动态标签做解析。它的内部有一个 NodeHandle接口实现类集合,用于处理专门的动态标签:

private void initNodeHandlerMap() {
    nodeHandlerMap.put("trim", new TrimHandler());
    nodeHandlerMap.put("where", new WhereHandler());
    nodeHandlerMap.put("set", new SetHandler());
    nodeHandlerMap.put("foreach", new ForEachHandler());
    nodeHandlerMap.put("if", new IfHandler());
    nodeHandlerMap.put("choose", new ChooseHandler());
    nodeHandlerMap.put("when", new IfHandler());
    nodeHandlerMap.put("otherwise", new OtherwiseHandler());
    nodeHandlerMap.put("bind", new BindHandler());
}

public SqlSource parseScriptNode() {
    // 解析动态标签
    MixedSqlNode rootSqlNode = parseDynamicTags(context);
    SqlSource sqlSource;
    if (isDynamic) {
        // 是否带有动态标签的非静态sql
        sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
        // 静态sql
        sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
}

protected MixedSqlNode parseDynamicTags(XNode node) {
    List<SqlNode> contents = new ArrayList<>();
    // 解析方法声明中的节点
    NodeList children = node.getNode().getChildNodes();
    for (int i = 0; i < children.getLength(); i++) {
        XNode child = node.newXNode(children.item(i));
        if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
            // 获取sql语句
            String data = child.getStringBody("");
            TextSqlNode textSqlNode = new TextSqlNode(data);
            // 是带有${}的动态sql
            if (textSqlNode.isDynamic()) {
                contents.add(textSqlNode);
                isDynamic = true;
            } else {
                // 是静态sql
                contents.add(new StaticTextSqlNode(data));
            }
            
        } else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
            // sql中带有动态标签
            String nodeName = child.getNode().getNodeName();
            // 获取对应的拦截器
            NodeHandler handler = nodeHandlerMap.get(nodeName);
            if (handler == null) {
                throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
            }
            handler.handleNode(child, contents);
            isDynamic = true;
        }
    }
    return new MixedSqlNode(contents);
}

理论上来说,我们可以通过在此处注册 NodeHandler 来支持自定义的 SQL 语法。

3.2.SqlNode

parseDynamicTags()最终返回的 MixedSqlNode 是一个 SqlNode 实现类,SqlNode 是一个表示节点的特殊接口:

public interface SqlNode {
  boolean apply(DynamicContext context);
}

他的方法及其简单,每个 SqlNode 实现类都会实现apply()方法,当调用以后,sql 节点会被解析为正常的 sql 语句放入 DynamicContext上下文中。

所有方法声明中的 sql 节点都实现类这个接口:

简而言之,他们的实现类很多,但是实际上就分四类:

  1. StaticTextSqlNode:顾名思义,普通的静态 sql 语句部分,只带有#{}表达式而不含有动态标签和${}赋值表达式;
  2. TextSqlNode:带有${}赋值表达式的 sql 语句;
  3. 动态标签节点:除了TextSqlNodeMixedSqlNodeStaticTextSqlNode的所有其他实现类,即处理 Mybatis 动态标签的特殊节点;
  4. MixedSqlNode:如parseDynamicTags()所示,这是一个混合了所有类型标签的节点,也是实际上的根节点;

我们以 StaticTextSqlNode为例:

public class StaticTextSqlNode implements SqlNode {
    private final String text;

    public StaticTextSqlNode(String text) {
        this.text = text;
    }

    @Override
    public boolean apply(DynamicContext context) {
        // 拼接sql
        context.appendSql(text);
        return true;
    }

}

普通静态 sql 节点 apply()以后就把当前的节点内的 sql 拼接到上下文的 sql 中,同理,其他的实现类也差不多。由于节点之间还有嵌套关系,因此有些节点还会自己维护一个独立的上下文,内部处理的时候先把 sql 拼到独立上下文里面,等自己的节点处理完再把独立上下文中的 sql 拼接到父目录的上下文中。

根据各自的逻辑处理后,最终都会把节点代表的 sql 拼接到上下文里,最终所有节点逻辑处理完,就会在上下文中拼接处一条完整的 sql。

而起到这个作用的,就是根节点 MixedSqlNode

public class MixedSqlNode implements SqlNode {
    private final List<SqlNode> contents;

    public MixedSqlNode(List<SqlNode> contents) {
        this.contents = contents;
    }

    @Override
    public boolean apply(DynamicContext context) {
        contents.forEach(node -> node.apply(context));
        return true;
    }
}

MixedSqlNode内部有所有的 SqlNode,它的 apply()方法就是遍历节点然后把每个节点的 apply()方法都执行一遍。

这里另外提到一点,对于动态标签里的语法,比如 <if test="...">里的 test,这里的表达式实际上是 Ognl 表达式,这在 jsp 中也有使用,我们可以简单理解为一个脚本语言,通过表达式,我们可以实现一些简单的功能,比如取值或者比较等等。

4.获得可执行SQL

4.1.RawSqlSource与DynamicSqlSource

XMLLanguageDriver.createSqlSource()最终会根据textSqlNode.isDynamic()的结果——也就是方法声明的 sql 中是否含有动态标签——区分要创建哪一种 SqlSource,带${}和动态标签的用DynamicSqlSource,不带的用 RawSqlSource

我们以比较简单的 RawSqlSource 为例:

public class RawSqlSource implements SqlSource {

    private final SqlSource sqlSource;

    public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class<?> parameterType) {
        this(configuration, getSql(configuration, rootSqlNode), parameterType);
    }

    public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
        SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
        Class<?> clazz = parameterType == null ? Object.class : parameterType;
        // 将#{}表达式替换为?占位符
        sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<>());
    }

    // 从上下文中获取根标签,也就是之前提到的MixedSqlNode
    private static String getSql(Configuration configuration, SqlNode rootSqlNode) {
        DynamicContext context = new DynamicContext(configuration, null);
        // 解析处理所有的SqlNode,并拼接到context里
        rootSqlNode.apply(context);
        // 获取拼接好的sql
        return context.getSql();
    }

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        // 获取最终的BoundSql
        return sqlSource.getBoundSql(parameterObject);
    }

}

可以看到,getSql()本质上就是拿到处理好的 sql 节点,然后调用他们的 apply()方法,最终拼接到上下文中,然后再返回这个拼好的 sql。

public class DynamicSqlSource implements SqlSource {

    private final Configuration configuration;
    private final SqlNode rootSqlNode;

    public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) {
        this.configuration = configuration;
        this.rootSqlNode = rootSqlNode;
    }

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        // 获取上下文并解析根标签
        DynamicContext context = new DynamicContext(configuration, parameterObject);
        rootSqlNode.apply(context);
        SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
        Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
        // 将#{}表达式替换为?占位符,获取最终的BoundSql
        SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
        BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
        // 添加参数到boundSql中
        context.getBindings().forEach(boundSql::setAdditionalParameter);
        return boundSql;
    }

}

DynamicSqlSourceRawSqlSource没有什么区别,只不过由于存在动态标签与${},相比简单的静态 sql,需要先将这两者解析为 sql。

4.2.替换占位符

SqlSourceBuilder.getBoundSql()的主要作用就是将#{}占位符替换为 jdbc 中的?占位符:

public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
    // 参数处理器
    ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
    // #{}赋值表达式处理器
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    String sql;
    // 如果有额外的空格就先删除
    if (configuration.isShrinkWhitespacesInSql()) {
        // 将#{}表达式替换为具体的参数值
        sql = parser.parse(removeExtraWhitespaces(originalSql));
    } else {
        sql = parser.parse(originalSql);
    }
    // 转为静态StaticSqlSource
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}

所有的 sql 最后都会完成解析,变为最终的自带?占位符的静态 sql 字符串,sql 与对应的入参、返回值类型等信息最终被封装为一个对象,这个对象就是 BoundSql

public class BoundSql {
    // sql字符串
    private final String sql;
    // 与占位符对应的入参信息
    private final List<ParameterMapping> parameterMappings;
    // 参数
    private final Object parameterObject;
    // 附加参数
    private final Map<String, Object> additionalParameters;
    private final MetaObject metaParameters;
}

里面包含有带占位符和动态标签的原始 sql 语句,以及方法声明上相关的入参。

然后我们再回头看看DynamicSqlSource,我们知道由于存在动态标签,因此DynamicSqlSource会在创建了StaticSqlSource之后,通过StaticSqlSource拿到仍然带有动态标签数据的 BoundSql,接着在根据入参处理动态标签,最终再生成一个 BoundSql

总结

我们总结一下 Mapper 中方法声明被变成 sql 加载进 Mybatis 的过程:

SQL 的加载

  1. 方法声明的解析发生在一个 Mapper 文件被加载——也就是 XMLMapperBuilder构建——的时候,此时各个 statement 都还只是未被解析的 XML 节点;
  2. XMLMapperBuilder中,先通过parsePendingStatements()遍历节点,然后依次调用节点的 parseStatementNode()方法,在这一步主要一下几件事:
  • 获取 id、超时时间与缓存等相关的配置信息;
  • 获取方法入参与返回值类型;
  • 解析 <include>节点,并替换为相应的内容;
  • 解析 <selectKey>节点,根据配置设置相应的主键生成策略,完成后然后删除节点;
  • 解析sql,根据配置的LanguageDriver将 sql 解析为对应的 SqlSource
  • 获取 StatementTyoe,默认都为prepared

然后根据以上的信息构建一个 MappedStatement对象,这个 MappedStatement即是后续方法实现的基础。

SqlSource 的构建

然后回头再看看步骤二,步骤二干了很多事,但是最重要的在于解析 statement 语法生成 SqlSource

  1. 获取 LanguageDriver 接口实现类,该实现类为来在配置文件加载时 Mybatis 默认提供的 XMLLanguageDriver,主要用来解析 Myabtis 特有的带有动态标签与参数标签的语法;
  2. LanguageDriver中创建XMLScriptBuilder用于解析 statement 中的语法,这里如果是类似 @Select这样注解形式的sql,就先解析为 xml 再用XMLScriptBuilder
  3. XMLScriptBuilder中将 statement 语法也解析为一串的 XML 节点,并根据节点对应的语法做了区分:
  • 如果是带有 ${}表达式的字符串节点被认为是动态 sql 节点,转为TextSqlNode
  • 如果是只带有#{}表达式的字符串节点或者干脆就是纯字符串的字符串节点,转为StaticTextSqlNode
  • 如果是动态标签,就通过节点解析器NodeHandler处理,最后转为类似 WhereSqlNode这样的动态标签节点;
  1. 还是在XMLScriptBuilder中,解析出来的各种 Node 都是 SqlNode 接口的实现类,他们都有 apply()方法,当调用的时候,会把自身对应的数据解析为 sql 并拼接到一个上下文对象的 sql 字符串中。 最终这些节点都被添加到一个集合中,最终放入一个根节点 MixedSqlNode里,当调用MixedSqlNodeapply()方法时,就会调用所有节点的apply()方法民,最终就会得到一个完整的 sql。
  2. 回到LanguageDriver中,在它的createSqlSource()方法中,根据是否带有${}与动态标签区分为DynamicSqlSourceRawSqlSource两种 DataSource
  • RawSqlSource:调用MixedSqlNode.apply()拿到完整的 sql,然后把 #{}表达式替换为 ?占位符,接着根据 sql 创建一个StaticSqlSource,然后通过StaticSqlSource获取boundSql
  • DynamicSqlSource:跟RawSqlSource一样,先获得完整的 sql,并生成一个StaticSqlSource对象,然后通过StaticSqlSource拿到boundSql,接着再根据传入的参数处理 BoundSql 中的动态标签,最终再生成一个 BoundSql