博客 / 詳情

返回

Mybatis源碼-動態SQL的實現原理

大家好,我是半夏之沫 😁😁 一名金融科技領域的JAVA系統研發😊😊
我希望將自己工作和學習中的經驗以最樸實最嚴謹的方式分享給大家,共同進步👉💓👈
👉👉👉👉👉👉👉👉💓寫作不易,期待大家的關注和點贊💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓關注微信公眾號【技術探界】 💓👈👈👈👈👈👈👈👈

前言

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語句。


大家好,我是半夏之沫 😁😁 一名金融科技領域的JAVA系統研發😊😊
我希望將自己工作和學習中的經驗以最樸實最嚴謹的方式分享給大家,共同進步👉💓👈
👉👉👉👉👉👉👉👉💓寫作不易,期待大家的關注和點贊💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓關注微信公眾號【技術探界】 💓👈👈👈👈👈👈👈👈
user avatar eisuto 頭像 dm2box 頭像 an_653b347d1d3da 頭像
3 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.