1

前言

Mybatis提供了强大的动态SQL语句生成功能,以应对复杂的业务场景,本篇文章将结合Mybatis解析SQL语句的过程对Mybatis中对<if><where><foreach>等动态SQL标签的支持进行分析。

正文

一. XML文档中的节点概念

在分析Mybatis如何支持SQL语句之前,本小节先分析XML文档中的节点概念。XML文档中的每个成分都是一个节点,DOMXML节点的规定如下所示。

  • 整个文档是一个文档节点
  • 每个XML标签是一个元素节点
  • 包含在元素节点中的文本是文本节点

以一个XML文档进行说明,如下所示。

<provinces>
    <province name="四川">
        <capital>成都</capital>
    </province>
    <province name="湖北">
        <capital>武汉</capital>
    </province>
</provinces>

如上所示,整个XML文档是一个文档节点,这个文档节点有一个子节点,就是<provinces>元素节点,<provinces>元素节点有五个子节点,分别是:文本节点,<province>元素节点,文本节点,<province>元素节点和文本节点,注意,在<provinces>元素节点的子节点中的文本节点的文本值均是\n,表示换行符。同样,<province>元素节点有三个子节点,分别是:文本节点,<capital>元素节点和文本节点,这里的文本节点的文本值也是\n,然后<capital>元素节点只有一个子节点,为一个文本节点。节点的子节点之间互为兄弟节点,例如<provinces>元素的五个子节点之间互为兄弟节点,name为“四川”的<province>元素节点的上一个兄弟节点为文本节点,下一个兄弟节点也为文本节点。

二. Mybatis支持动态SQL源码分析

Mybatis源码-加载映射文件与动态代理中已经知道,在XMLStatementBuilderparseStatementNode()方法中,会解析映射文件中的<select><insert><update><delete>标签(后续统一称为CURD标签),并生成MappedStatement然后缓存到Configuration中。CURD标签的解析由XMLLanguageDriver完成,每个标签解析之后会生成一个SqlSource,可以理解为SQL语句,本小节将对XMLLanguageDriver如何完成CURD标签的解析进行讨论。

XMLLanguageDriver创建SqlSourcecreateSqlSource()方法如下所示。

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

如上所示,createSqlSource()方法的入参中,XNode就是CURD标签对应的节点,在createSqlSource()方法中先是创建了一个XMLScriptBuilder,然后通过XMLScriptBuilder来生成SqlSource。先看一下XMLScriptBuilder的构造方法,如下所示。

public XMLScriptBuilder(Configuration configuration, XNode context, 
                    Class<?> parameterType) {
    super(configuration);
    this.context = context;
    this.parameterType = parameterType;
    initNodeHandlerMap();
}

XMLScriptBuilder的构造方法中,主要是将CURD标签对应的节点缓存起来,然后初始化nodeHandlerMapnodeHandlerMap中存放着处理Mybatis提供的支持动态SQL的标签的处理器,initNodeHandlerMap()方法如下所示。

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());
}

现在分析XMLScriptBuilderparseScriptNode()方法,该方法会创建SqlSource,如下所示。

public SqlSource parseScriptNode() {
    // 解析动态标签
    MixedSqlNode rootSqlNode = parseDynamicTags(context);
    SqlSource sqlSource;
    if (isDynamic) {
        // 创建DynamicSqlSource并返回
        sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
        // 创建RawSqlSource并返回
        sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
}

XMLScriptBuilderparseScriptNode()方法中,会根据XMLScriptBuilder中的isDynamic属性判断是创建DynamicSqlSource还是RawSqlSource,在这里暂时不分析DynamicSqlSourceRawSqlSource的区别,但是可以推测在parseDynamicTags()方法中会改变isDynamic属性的值,即在parseDynamicTags()方法中会根据CURD标签的节点生成一个MixedSqlNode,同时还会改变isDynamic属性的值以指示当前CURD标签中的SQL语句是否是动态的。MixedSqlNode是什么,isDynamic属性值在什么情况下会变为true,带着这些疑问,继续看parseDynamicTags()方法,如下所示。

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) {
            // 子节点为文本节点
            String data = child.getStringBody("");
            // 基于文本节点的值并创建TextSqlNode
            TextSqlNode textSqlNode = new TextSqlNode(data);
            // isDynamic()方法可以判断文本节点值是否有${}占位符
            if (textSqlNode.isDynamic()) {
                // 文本节点值有${}占位符
                // 添加TextSqlNode到集合中
                contents.add(textSqlNode);
                // 设置isDynamic为true
                isDynamic = true;
            } else {
                // 文本节点值没有占位符
                // 创建StaticTextSqlNode并添加到集合中
                contents.add(new StaticTextSqlNode(data));
            }
        } else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {
            // 子节点为元素节点
            // CURD节点的子节点中的元素节点只可能为<if>,<foreach>等动态Sql标签节点
            String nodeName = child.getNode().getNodeName();
            // 根据动态Sql标签节点的名称获取对应的处理器
            NodeHandler handler = nodeHandlerMap.get(nodeName);
            if (handler == null) {
                throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
            }
            // 处理动态Sql标签节点
            handler.handleNode(child, contents);
            // 设置isDynamic为true
            isDynamic = true;
        }
    }
    // 创建MixedSqlNode
    return new MixedSqlNode(contents);
}

按照正常执行流程调用parseDynamicTags()时,入参是CURD标签节点,此时会遍历CURD标签节点的所有子节点,基于每个子节点都会创建一个SqlNode然后添加到SqlNode集合contents中,最后将contents作为入参创建MixedSqlNode并返回。SqlNode是一个接口,在parseDynamicTags()方法中,可以知道,TextSqlNode实现了SqlNode接口,StaticTextSqlNode实现了SqlNode接口,所以当节点的子节点是文本节点时,如果文本值包含有${}占位符,则创建TextSqlNode添加到contents中并设置isDynamictrue,如果文本值不包含${}占位符,则创建StaticTextSqlNode并添加到contents中。如果CURD标签节点的子节点是元素节点时,由于CURD标签节点的元素节点只可能为<if><foreach>等动态SQL标签节点,所以直接会设置isDynamictrue,同时还会调用动态SQL标签节点对应的处理器来生成SqlNode并添加到contents中。这里以<if>标签节点对应的处理器的handleNode()方法为例进行说明,如下所示。

public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
    // 递归调用parseDynamicTags()解析<if>标签节点
    MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
    String test = nodeToHandle.getStringAttribute("test");
    // 创建IfSqlNode
    IfSqlNode ifSqlNode = new IfSqlNode(mixedSqlNode, test);
    // 将IfSqlNode添加到contents中
    targetContents.add(ifSqlNode);
}

<if>标签节点对应的处理器的handleNode()方法中,递归的调用了parseDynamicTags()方法来解析<if>标签节点,例如<where><foreach>等标签节点对应的处理器的handleNode()方法中也会递归调用parseDynamicTags()方法,这是因为这些动态SQL标签是可以嵌套使用的,比如<where>标签节点的子节点可以为<if>标签节点。通过上面的handleNode()方法,大致可以知道MixedSqlNodeIfSqlNode也实现了SqlNode接口,下面看一下MixedSqlNodeIfSqlNode的实现,如下所示。

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;
    }
    
}

public class IfSqlNode implements SqlNode {

    private final ExpressionEvaluator evaluator;
    private final String test;
    private final SqlNode contents;

    public IfSqlNode(SqlNode contents, String test) {
        this.test = test;
        this.contents = contents;
        this.evaluator = new ExpressionEvaluator();
    }

    @Override
    public boolean apply(DynamicContext context) {
        if (evaluator.evaluateBoolean(test, context.getBindings())) {
            contents.apply(context);
            return true;
        }
        return false;
    }

}

其实到这里已经逐渐清晰明了了,按照正常执行流程调用parseDynamicTags()方法时,是为了将CURD标签节点的所有子节点根据子节点类型生成不同的SqlNode并放在MixedSqlNode中,然后将MixedSqlNode返回,但是CURD标签节点的子节点中如果存在动态SQL标签节点,因为这些动态SQL标签节点也会有子节点,所以此时会递归的调用parseDynamicTags()方法,以解析动态SQL标签节点的子节点,同样会将这些子节点生成SqlNode并放在MixedSqlNode中然后将MixedSqlNode返回,递归调用parseDynamicTags()方法时得到的MixedSqlNode会保存在动态SQL标签节点对应的SqlNode中,比如IfSqlNode中就会将递归调用parseDynamicTags()生成的MixedSqlNode赋值给IfSqlNodecontents

不同的SqlNode都是可以包含彼此的,这是组合设计模式的应用,SqlNode之间的关系如下所示。

SqlNode接口定义了一个方法,如下所示。

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

每个SqlNodeapply()方法中,除了实现自己本身的逻辑外,还会调用自己所持有的所有SqlNodeapply()方法,最终逐层调用下去,所有SqlNodeapply()方法均会被执行。

现在回到XMLScriptBuilderparseScriptNode()方法,该方法中会调用parseDynamicTags()方法以解析CURD标签节点并得到MixedSqlNodeMixedSqlNode中含有被解析的CURD标签节点的所有子节点对应的SqlNode,最后会基于MixedSqlNode创建DynamicSqlSource或者RawSqlSource,如果CURD标签中含有动态SQL标签或者SQL语句中含有${}占位符,则创建DynamicSqlSource,否则创建RawSqlSource。下面分别对DynamicSqlSourceRawSqlSource的实现进行分析。

DynamicSqlSource的实现如下所示。

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);
        // 调用SqlNode的apply()方法完成Sql语句的生成
        rootSqlNode.apply(context);
        // SqlSourceBuilder可以将Sql语句中的#{}占位符替换为?
        SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
        Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
        // 将Sql语句中的#{}占位符替换为?,并生成一个StaticSqlSource
        SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
        // StaticSqlSource中保存有动态生成好的Sql语句,并且#{}占位符全部替换成了?
        BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
        // 生成有序参数映射列表
        context.getBindings().forEach(boundSql::setAdditionalParameter);
        return boundSql;
    }

}

DynamicSqlSource的构造函数只是进行了简单的赋值操作,重点在于其getBoundSql()方法,在getBoundSql()方法中,先是调用DynamicSqlSource中的SqlNodeapply()方法以完成动态SQL语句的生成,此时生成的SQL语句中的占位符(如果有的话)为#{},然后再调用SqlSourceBuilderparse()方法将SQL语句中的占位符从#{}替换为?并基于替换占位符后的SQL语句生成一个StaticSqlSource并返回,这里可以看一下StaticSqlSource的实现,如下所示。

public class StaticSqlSource implements SqlSource {

    private final String sql;
    private final List<ParameterMapping> parameterMappings;
    private final Configuration configuration;

    public StaticSqlSource(Configuration configuration, String sql) {
        this(configuration, sql, null);
    }

    public StaticSqlSource(Configuration configuration, String sql, 
                           List<ParameterMapping> parameterMappings) {
        // 构造函数只是进行简单的赋值操作
        this.sql = sql;
        this.parameterMappings = parameterMappings;
        this.configuration = configuration;
    }

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        // 基于Sql语句创建一个BoundSql并返回
        return new BoundSql(configuration, sql, parameterMappings, parameterObject);
    }

}

所以分析到这里,可以知道DynamicSqlSourcegetBoundSql()方法实际上会完成动态SQL语句的生成和#{}占位符替换,然后基于生成好的SQL语句创建BoundSql并返回。BoundSql对象的类图如下所示。

实际上,Mybatis中执行SQL语句时,如果映射文件中的SQL使用到了动态SQL标签,那么Mybatis中的Executor(执行器,后续文章中会进行介绍)会调用MappedStatementgetBoundSql()方法,然后在MappedStatementgetBoundSql()方法中又会调用DynamicSqlSourcegetBoundSql()方法,所以Mybatis中的动态SQL语句会在这条语句实际要执行时才会生成。

现在看一下RawSqlSource的实现,如下所示。

public class RawSqlSource implements SqlSource {

    private final SqlSource sqlSource;

    public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class<?> parameterType) {
        // 先调用getSql()方法获取Sql语句
        // 然后再执行构造函数
        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;
        // 将Sql语句中的#{}占位符替换为?,生成一个StaticSqlSource并赋值给sqlSource
        sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<>());
    }

    private static String getSql(Configuration configuration, SqlNode rootSqlNode) {
        DynamicContext context = new DynamicContext(configuration, null);
        rootSqlNode.apply(context);
        return context.getSql();
    }

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        // 实际是调用StaticSqlSource的getBoundSql()方法
        return sqlSource.getBoundSql(parameterObject);
    }

}

RawSqlSource会在构造函数中就将SQL语句生成好并替换#{}占位符,在SQL语句实际要执行时,就直接将生成好的SQL语句返回。所以Mybatis中,静态SQL语句的执行通常要快于动态SQL语句的执行,这在RawSqlSource类的注释中也有提及,如下所示。

Static SqlSource. It is faster than {@link DynamicSqlSource} because mappings are calculated during startup.

总结

Mybatis会为映射文件中的每个CURD标签节点里的SQL语句生成一个SqlSource,如果是静态SQL语句,那么会生成RawSqlSource,如果是动态SQL语句,则会生成DynamicSqlSourceMybatis在生成SqlSource时,会为CURD标签节点的每个子节点都生成一个SqlNode,无论子节点是文本值节点还是动态SQL元素节点,最终所有子节点对应的SqlNode都会放在SqlSource中以供生成SQL语句使用。如果是静态SQL语句,那么在创建RawSqlSource时就会使用SqlNode完成SQL语句的生成以及将SQL语句中的#{}占位符替换为?,然后保存在RawSqlSource中,等到这条静态SQL语句要被执行时,就直接返回这条静态SQL语句。如果是动态SQL语句,在创建DynamicSqlSource时只会简单的将SqlNode保存下来,等到这条动态SQL语句要被执行时,才会使用SqlNode完成SQL语句的生成以及将SQL语句中的#{}占位符替换为?,最后返回SQL语句,所以Mybatis中,静态SQL语句执行要快于动态SQL语句。


半夏之沫
65 声望32 粉丝