Mybatis系列全解(八):Mybatis的9大動態SQL標籤你知道幾個?提前致女神!

潘潘和他的朋友們發表於2021-03-04

封面:洛小汐

作者:潘潘

2021年,仰望天空,腳踏實地。

這算是春節後首篇 Mybatis 文了~

跨了個年感覺寫了有半個世紀 ...

藉著女神節 ヾ(◍°∇°◍)ノ゙

提前祝男神女神們越靚越富越嗨森!

上圖儲存可做朋友圈封面圖 ~

前言

本節我們介紹 Mybatis 的強大特性之一:動態 SQL ,從動態 SQL 的誕生背景與基礎概念,到動態 SQL 的標籤成員及基本用法,我們徐徐道來,再結合框架原始碼,剖析動態 SQL (標籤)的底層原理,最終在文末吐槽一下:在無動態 SQL 特性(標籤)之前,我們會常常掉進哪些可惡的坑吧~

建議關注我們! Mybatis 全解系列一直在更新哦

Mybaits系列全解

  • Mybatis系列全解(一):手寫一套持久層框架
  • Mybatis系列全解(二):Mybatis簡介與環境搭建
  • Mybatis系列全解(三):Mybatis簡單CRUD使用介紹
  • Mybatis系列全解(四):全網最全!Mybatis配置檔案XML全貌詳解
  • Mybatis系列全解(五):全網最全!詳解Mybatis的Mapper對映檔案
  • Mybatis系列全解(六):Mybatis最硬核的API你知道幾個?
  • Mybatis系列全解(七):Dao層的兩種實現之傳統與代理
  • Mybatis系列全解(八):Mybatis的動態SQL
  • Mybatis系列全解(九):Mybatis的複雜對映
  • Mybatis系列全解(十):Mybatis註解開發
  • Mybatis系列全解(十一):Mybatis快取全解
  • Mybatis系列全解(十二):Mybatis外掛開發
  • Mybatis系列全解(十三):Mybatis程式碼生成器
  • Mybatis系列全解(十四):Spring整合Mybatis
  • Mybatis系列全解(十五):SpringBoot整合Mybatis
  • Mybatis系列全解(十六):Mybatis原始碼剖析

本文目錄

1、什麼是動態SQL

2、動態SQL的誕生記

3、動態SQL標籤的9大標籤

4、動態SQL的底層原理

1、什麼是動態SQL ?

關於動態 SQL ,允許我們理解為 “ 動態的 SQL ”,其中 “ 動態的 ” 是形容詞,“ SQL ” 是名詞,那顯然我們需要先理解名詞,畢竟形容詞僅僅代表它的某種形態或者某種狀態。

SQL 的全稱是:

Structured Query Language,結構化查詢語言。

SQL 本身好說,我們小學時候都學習過了,無非就是 CRUD 嘛,而且我們還知道它是一種 語言,語言是一種存在於物件之間用於交流表達的 能力,例如跟中國人交流用漢語、跟英國人交流用英語、跟火星人交流用火星語、跟小貓交流用喵喵語、跟計算機交流我們用機器語言、跟資料庫管理系統(DBMS)交流我們用 SQL。

想必大家立馬就能明白,想要與某個物件交流,必須擁有與此物件交流的語言能力才行!所以無論是技術人員、還是應用程式系統、或是某個高階語言環境,想要訪問/運算元據庫,都必須具備 SQL 這項能力;因此你能看到像 Java ,像 Python ,像 Go 等等這些高階語言環境中,都會嵌入(支援) SQL 能力,達到與資料庫互動的目的。

很顯然,能夠學習 Mybatis 這麼一門高精尖(ru-men)持久層框架的程式設計人群,對於 SQL 的編寫能力肯定已經掌握得 ss 的,平時各種 SQL 編寫那都是信手拈來的事, 只不過對於 動態SQL 到底是個什麼東西,似乎還有一些朋友似懂非懂!但是沒關係,我們百度一下。

動態 SQL:一般指根據使用者輸入或外部條件 動態組合 的 SQL 語句塊。

很容易理解,隨外部條件動態組合的 SQL 語句塊!我們先針對動態 SQL 這個詞來剖析,世間萬物,有動態那就相對應的有靜態,那麼他們的邊界在哪裡呢?又該怎麼區分呢?

其實,上面我們已經介紹過,在例如 Java 高階語言中,都會嵌入(支援)SQL 能力,一般我們可以直接在程式碼或配置檔案中編寫 SQL 語句,如果一個 SQL 語句在 “編譯階段” 就已經能確定 主體結構,那我們稱之為靜態 SQL,如果一個 SQL 語句在編譯階段無法確定主體結構,需要等到程式真正 “執行時” 才能最終確定,那麼我們稱之為動態 SQL,舉個例子:

<!-- 1、定義SQL -->
<mapper namespace="dao">
	<select id="selectAll" resultType="user">
    	select * from t_user
	</select>
</mapper>
// 2、執行SQL
sqlSession.select("dao.selectAll");

很明顯,以上這個 SQL ,在編譯階段我們都已經知道它的主體結構,即查詢 t_user 表的所有記錄,而無需等到程式執行時才確定這個主體結構,因此以上屬於 靜態 SQL。那我們再看看下面這個語句:

<!-- 1、定義SQL -->
<mapper namespace="dao">
	<select id="selectAll" parameterType="user">
    	select * from t_user 
        <if test="id != null">
            where id = #{id}
        </if>
	</select>
</mapper>
// 2、執行SQL
User user1 = new User();
user1.setId(1);
sqlSession.select("dao.selectAll",user1);  // 有 id

User user2 = new User(); 
sqlSession.select("dao.selectAll",user2);  // 無 id

認真觀察,以上這個 SQL 語句,額外新增了一塊 if 標籤 作為條件判斷,所以應用程式在編譯階段是無法確定 SQL 語句最終主體結構的,只有在執行時根據應用程式是否傳入 id 這個條件,來動態的拼接最終執行的 SQL 語句,因此屬於動態 SQL 。

另外,還有一種常見的情況,大家看看下面這個 SQL 語句算是動態 SQL 語句嗎?

<!-- 1、定義SQL -->
<mapper namespace="dao">
	<select id="selectAll" parameterType="user">
    	select * from t_user where id = #{id} 
	</select>
</mapper>
// 2、執行SQL
User user1 = new User();
user1.setId(1);
sqlSession.select("dao.selectAll",user1);  // 有 id

根據動態 SQL 的定義,大家是否能判斷以上的語句塊是否屬於動態 SQL?

答案:不屬於動態 SQL !

原因很簡單,這個 SQL 在編譯階段就已經明確主體結構了,雖然外部動態的傳入一個 id ,可能是1,可能是2,可能是100,但是因為它的主體結構已經確定,這個語句就是查詢一個指定 id 的使用者記錄,它最終執行的 SQL 語句不會有任何動態的變化,所以頂多算是一個支援動態傳參的靜態 SQL 。

至此,我們對於動態 SQL 和靜態 SQL 的區別已經有了一個基礎認知,但是有些好奇的朋友又會思考另一個問題:動態 SQL 是 Mybatis 獨有的嗎?

2、動態SQL的誕生記

我們都知道,SQL 是一種偉大的資料庫語言 標準,在資料庫管理系統紛爭的時代,它的出現統一規範了資料庫操作語言,而此時,市面上的資料庫管理軟體百花齊放,我最早使用的 SQL Server 資料庫,當時用的資料庫管理工具是 SQL Server Management Studio,後來接觸 Oracle 資料庫,用了 PL/SQL Developer,再後來直至今日就幾乎都在用 MySQL 資料庫(這個跟各種雲廠商崛起有關),所以基本使用 Navicat 作為資料庫管理工具,當然如今市面上還有許多許多,資料庫管理工具嘛,只要能便捷高效的管理我們的資料庫,那就是好工具,duck 不必糾結選擇哪一款!

那這麼多好工具,都提供什麼功能呢?相信我們平時接觸最多的就是接收執行 SQL 語句的輸入介面(也稱為查詢編輯器),這個輸入介面幾乎支援所有 SQL 語法,例如我們編寫一條語句查詢 id 等於15 的使用者資料記錄:

select * from user where id = 15 ;

我們來看一下這個查詢結果:

很顯然,在這個輸入介面內輸入的任何 SQL 語句,對於資料庫管理工具來說,都是 動態 SQL!因為工具本身並不可能提前知道使用者會輸入什麼 SQL 語句,只有當使用者執行之後,工具才接收到使用者實際輸入的 SQL 語句,才能最終確定 SQL 語句的主體結構,當然!即使我們不通過視覺化的資料庫管理工具,也可以用資料庫本身自帶支援的命令列工具來執行 SQL 語句。但無論使用者使用哪類工具,輸入的語句都會被工具認為是 動態 SQL

這麼一說,動態 SQL 原來不是 Mybatis 獨有的特性!其實除了以上介紹的資料庫管理工具以外,在純 JDBC 時代,我們就經常通過字串來動態的拼接 SQL 語句,這也是在高階語言環境(例如 Java 語言程式設計環境)中早期常用的動態 SQL 構建方式!

// 外部條件id
Integer id = Integer.valueOf(15);

// 動態拼接SQL
StringBuilder sql = new StringBuilder();
sql.append(" select  *   ");
sql.append("   from user ");

// 根據外部條件id動態拼接SQL
if ( null != id ){
    sql.append(" where id = " + id);
}

// 執行語句
connection.prepareStatement(sql);

只不過,這種構建動態 SQL 的方式,存在很大的安全問題和異常風險(我們第5點會詳細介紹),所以不建議使用,後來 Mybatis 入世之後,在對待動態 SQL 這件事上,就格外上心,它默默發誓,一定要為使用 Mybatis 框架的使用者提供一套棒棒的方案(標籤)來靈活構建動態 SQL!

於是乎,Mybatis 藉助 OGNL 的表示式的偉大設計,可算在動態 SQL 構建方面提供了各類功能強大的輔助標籤,我們簡單列舉一下有:if、choose、when、otherwise、trim、where、set、foreach、bind等,我隨手翻了翻我電腦裡頭曾經儲存的學習筆記,我們一起在第3節中溫故知新,詳細的講一講吧~

另外,需要糾正一點,就是我們平日裡在 Mybatis 框架中常說的動態 SQL ,其實特指的也就是 Mybatis 框架中的這一套動態 SQL 標籤,或者說是這一 特性,而並不是在說動態 SQL 本身。

3、動態SQL標籤的9大標籤

很好,可算進入我們動態 SQL 標籤的主題,根據前面的鋪墊,其實我們都能發現,很多時候靜態 SQL 語句並不能滿足我們複雜的業務場景需求,所以我們需要有適當靈活的一套方式或者能力,來便捷高效的構建動態 SQL 語句,去匹配我們動態變化的業務需求。舉個例子,在下面此類多條件的場景需求之下,動態 SQL 語句就顯得尤為重要(先登場 if 標籤)。

當然,很多朋友會說這類需求,不能用 SQL 來查,得用搜尋引擎,確實如此。但是呢,在我們的實際業務需求當中,還是存在很多沒有引入搜尋引擎系統,或者有些根本無需引入搜尋引擎的應用程式或功能,它們也會涉及到多選項多條件或者多結果的業務需求,那此時也就確實需要使用動態 SQL 標籤來靈活構建執行語句。

那麼, Mybatis 目前都提供了哪些棒棒的動態 SQL 標籤呢 ?我們先引出一個類叫做 XMLScriptBuilder ,大家先簡單理解它是負責解析我們的動態 SQL 標籤的這麼一個構建器,在第4點底層原理中我們再詳細介紹。

// XML指令碼標籤構建器
public class XMLScriptBuilder{
    
    // 標籤節點處理器池
    private final Map<String, NodeHandler> nodeHandlerMap = new HashMap<>();
    
    // 構造器
    public XMLScriptBuilder() { 
        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());
    }
}

其實原始碼中很清晰得體現,一共有 9 大動態 SQL 標籤!Mybatis 在初始化解析配置檔案的時候,會例項化這麼一個標籤節點的構造器,那麼它本身就會提前把所有 Mybatis 支援的動態 SQL 標籤物件對應的處理器給進行一個例項化,然後放到一個 Map 池子裡頭,而這些處理器,都是該類 XMLScriptBuilder 的一個匿名內部類,而匿名內部類的功能也很簡單,就是解析處理對應型別的標籤節點,在後續應用程式使用動態標籤的時候,Mybatis 隨時到 Map 池子中匹配對應的標籤節點處理器,然後進解析即可。下面我們分別對這 9 大動態 SQL 標籤進行介紹,排(gen)名(ju)不(wo)分(de)先(xi)後(hao):


Top1、if 標籤

常用度:★★★★★

實用性:★★★★☆

if 標籤,絕對算得上是一個偉大的標籤,任何不支援流程控制(或語句控制)的應用程式,都是耍流氓,幾乎都不具備現實意義,實際的應用場景和流程必然存在條件的控制與流轉,而 if 標籤在 單條件分支判斷 應用場景中就起到了捨我其誰的作用,語法很簡單,如果滿足,則執行,不滿足,則忽略/跳過。

  • if 標籤 : 內嵌於 select / delete / update / insert 標籤,如果滿足 test 屬性的條件,則執行程式碼塊
  • test 屬性 :作為 if 標籤的屬性,用於條件判斷,使用 OGNL 表示式。

舉個例子:

<select id="findUser">
    select * from User where 1=1
    <if test=" age != null ">
        and age > #{age}
    </if>
    <if test=" name != null ">
        and name like concat(#{name},'%')
    </if>
</select>

很明顯,if 標籤元素常用於包含 where 子句的條件拼接,它相當於 Java 中的 if 語句,和 test 屬性搭配使用,通過判斷引數值來決定是否使用某個查詢條件,也可用於 Update 語句中判斷是否更新某個欄位,或用於 Insert 語句中判斷是否插入某個欄位的值。

每一個 if 標籤在進行單條件判斷時,需要把判斷條件設定在 test 屬性中,這是一個常見的應用場景,我們常用的使用者查詢系統功能中,在前端一般提供很多可選的查詢項,支援性別篩選、年齡區間篩查、姓名模糊匹配等,那麼我們程式中接收使用者輸入之後,Mybatis 的動態 SQL 節省我們很多工作,允許我們在程式碼層面不進行引數邏輯處理和 SQL 拼接,而是把引數傳入到 SQL 中進行條件判斷動態處理,我們只需要把精力集中在 XML 的維護上,既靈活也方便維護,可讀性還強。

有些心細的朋友可能就發現一個問題,為什麼 where 語句會新增一個 1=1 呢?其實我們是為了方便拼接後面符合條件的 if 標籤語句塊,否則沒有 1=1 的話我們拼接的 SQL 就會變成 select * from user where and age > 0 , 顯然這不是我們期望的結果,當然也不符合 SQL 的語法,資料庫也不可能執行成功,所以我們投機取巧新增了 1=1 這個語句,但是始終覺得多餘且沒必要,Mybatis 也考慮到了,所以等會我們講 where 標籤,它是如何完美解決這個問題的。

注意:if 標籤作為單條件分支判斷,只能控制與非此即彼的流程,例如以上的例子,如果年齡 age 和姓名 name 都不存在,那麼系統會把所有結果都查詢出來,但有些時候,我們希望系統更加靈活,能有更多的流程分支,例如像我們 Java 當中的 if else 或 switch case default,不僅僅只有一個條件分支,所以接下來我們介紹 choose 標籤,它就能滿足多分支判斷的應用場景。


Top2、choose 標籤、when 標籤、otherwise 標籤

常用度:★★★★☆

實用性:★★★★☆

有些時候,我們並不希望條件控制是非此即彼的,而是希望能提供多個條件並從中選擇一個,所以貼心的 Mybatis 提供了 choose 標籤元素,類似我們 Java 當中的 if else 或 switch case default,choose 標籤必須搭配 when 標籤和 otherwise 標籤使用,驗證條件依然是使用 test 屬性進行驗證。

  • choose 標籤:頂層的多分支標籤,單獨使用無意義
  • when 標籤:內嵌於 choose 標籤之中,當滿足某個 when 條件時,執行對應的程式碼塊,並終止跳出 choose 標籤,choose 中必須至少存在一個 when 標籤,否則無意義
  • otherwise 標籤:內嵌於 choose 標籤之中,當不滿足所有 when 條件時,則執行 otherwise 程式碼塊,choose 中 至多 存在一個 otherwise 標籤,可以不存在該標籤
  • test 屬性 :作為 when 與 otherwise 標籤的屬性,作為條件判斷,使用 OGNL 表示式

依據下面的例子,當應用程式輸入年齡 age 或者姓名 name 時,會執行對應的 when 標籤內的程式碼塊,如果 when 標籤的年齡 age 和姓名 name 都不滿足,則會拼接 otherwise 標籤內的程式碼塊。

<select id="findUser">
    select * from User where 1=1 
    <choose>
        <when test=" age != null ">
        	and age > #{age}
        </when>
        <when test=" name != null ">
        	and name like concat(#{name},'%')
        </when>
        <otherwise>
            and sex = '男'
        </otherwise>
    </choose>
</select>

很明顯,choose 標籤作為多分支條件判斷,提供了更多靈活的流程控制,同時 otherwise 的出現也為程式流程控制兜底,有時能夠避免部分系統風險、過濾部分條件、避免當程式沒有匹配到條件時,把整個資料庫資源全部查詢或更新。

至於為何 choose 標籤這麼棒棒,而常用度還是比 if 標籤少了一顆星呢?原因也簡單,因為 choose 標籤的很多使用場景可以直接用 if 標籤代替。另外據我統計,if 標籤在實際業務應用當中,也要多於 choose 標籤,大家也可以具體核查自己的應用程式中動態 SQL 標籤的佔比情況,統計分析一下。


Top3、foreach 標籤

常用度:★★★☆☆

實用性:★★★★☆

有些場景,可能需要查詢 id 在 1 ~ 100 的使用者記錄

有些場景,可能需要批量插入 100 條使用者記錄

有些場景,可能需要更新 500 個使用者的姓名

有些場景,可能需要你刪除 10 條使用者記錄

請問大家

很多增刪改查場景,操作物件都是集合/列表

如果是你來設計支援 Mybatis 的這一類集合/列表遍歷場景,你會提供什麼能力的標籤來輔助構建你的 SQL 語句從而去滿足此類業務場景呢?

額(⊙o⊙)…

那如果一定要用 Mybatis 框架呢?

沒錯,確實 Mybatis 提供了 foreach 標籤來處理這幾類需要遍歷集合的場景,foreach 標籤作為一個迴圈語句,他能夠很好的支援陣列、Map、或實現了 Iterable 介面(List、Set)等,尤其是在構建 in 條件語句的時候,我們常規的用法都是 id in (1,2,3,4,5 ... 100) ,理論上我們可以在程式程式碼中拼接字串然後通過 ${ ids } 方式來傳值獲取,但是這種方式不能防止 SQL 注入風險,同時也特別容易拼接錯誤,所以我們此時就需要使用 #{} + foreach 標籤來配合使用,以滿足我們實際的業務需求。譬如我們傳入一個 List 列表查詢 id 在 1 ~ 100 的使用者記錄:

<select id="findAll">
    select  * from user where ids in 
    <foreach collection="list"
        item="item" index="index" 
        open="(" separator="," close=")">
            #{item}
    </foreach>
</select>

最終拼接完整的語句就變成:


select  * from user where ids in (1,2,3,...,100);

當然你也可以這樣編寫:

<select id="findAll">
    select  * from user where 
    <foreach collection="list"
        item="item" index="index" 
        open=" " separator=" or " close=" ">
            id = #{item}
    </foreach>
</select>

最終拼接完整的語句就變成:


select  * from user where id =1 or id =2 or id =3  ... or id = 100;

在資料量大的情況下這個效能會比較尷尬,這裡僅僅做一個用法的舉例。所以經過上面的舉慄,相信大家也基本能猜出 foreach 標籤元素的基本用法:

  • foreach 標籤:頂層的遍歷標籤,單獨使用無意義
  • collection 屬性:必填,Map 或者陣列或者列表的屬性名(不同型別的值獲取下面會講解)
  • item 屬性:變數名,值為遍歷的每一個值(可以是物件或基礎型別),如果是物件那麼依舊是 OGNL 表示式取值即可,例如 #{item.id} 、#{ user.name } 等
  • index 屬性:索引的屬性名,在遍歷列表或陣列時為當前索引值,當迭代的物件時 Map 型別時,該值為 Map 的鍵值(key)
  • open 屬性:迴圈內容開頭拼接的字串,可以是空字串
  • close 屬性:迴圈內容結尾拼接的字串,可以是空字串
  • separator 屬性:每次迴圈的分隔符

第一,當傳入的引數為 List 物件時,系統會預設新增一個 key 為 'list' 的值,把列表內容放到這個 key 為 list 的集合當中,在 foreach 標籤中可以直接通過 collection="list" 獲取到 List 物件,無論你傳入時使用 kkk 或者 aaa ,都無所謂,系統都會預設新增一個 key 為 list 的值,並且 item 指定遍歷的物件值,index 指定遍歷索引值。

// java 程式碼
List kkk = new ArrayList();
kkk.add(1);
kkk.add(2);
...
kkk.add(100);
sqlSession.selectList("findAll",kkk);
<!-- xml 配置 -->
<select id="findAll">
    select  * from user where ids in 
    <foreach collection="list"
        item="item" index="index" 
        open="(" separator="," close=")">
            #{item}
    </foreach>
</select>

第二,當傳入的引數為陣列時,系統會預設新增一個 key 為 'array' 的值,把列表內容放到這個 key 為 array 的集合當中,在 foreach 標籤中可以直接通過 collection="array" 獲取到陣列物件,無論你傳入時使用 ids 或者 aaa ,都無所謂,系統都會預設新增一個 key 為 array 的值,並且 item 指定遍歷的物件值,index 指定遍歷索引值。

// java 程式碼
String [] ids = new String[3];
ids[0] = "1";
ids[1] = "2";
ids[2] = "3";
sqlSession.selectList("findAll",ids);
<!-- xml 配置 -->
<select id="findAll">
    select  * from user where ids in 
    <foreach collection="array"
        item="item" index="index" 
        open="(" separator="," close=")">
            #{item}
    </foreach>
</select>

第三,當傳入的引數為 Map 物件時,系統並 不會 預設新增一個 key 值,需要手工傳入,例如傳入 key 值為 map2 的集合物件,在 foreach 標籤中可以直接通過 collection="map2" 獲取到 Map 物件,並且 item 代表每次迭代的的 value 值,index 代表每次迭代的 key 值。其中 item 和 index 的值名詞可以隨意定義,例如 item = "value111",index ="key111"。

// java 程式碼
Map map2 = new HashMap<>();
map2.put("k1",1);
map2.put("k2",2);
map2.put("k3",3);

Map map1 = new HashMap<>();
map1.put("map2",map2);
sqlSession.selectList("findAll",map1);

挺鬧心,map1 套著 map2,才能在 foreach 的 collection 屬性中獲取到。

<!-- xml 配置 -->
<select id="findAll">
    select  * from user where
    <foreach collection="map2"
        item="value111" index="key111" 
        open=" " separator=" or " close=" ">
        id = #{value111}
    </foreach>
</select>

可能你會覺得 Map 受到不公平對待,為何 map 不能像 List 或者 Array 一樣,在框架預設設定一個 'map' 的 key 值呢?但其實不是不公平,而是我們在 Mybatis 框架中,所有傳入的任何引數都會供上下文使用,於是引數會被統一放到一個內建引數池子裡面,這個內建引數池子的資料結構是一個 map 集合,而這個 map 集合可以通過使用 “_parameter” 來獲取,所有 key 都會儲存在 _parameter 集合中,因此:

  • 當你傳入的引數是一個 list 型別時,那麼這個引數池子需要有一個 key 值,以供上下文獲取這個 list 型別的物件,所以預設設定了一個 'list' 字串作為 key 值,獲取時通過使用 _parameter.list 來獲取,一般使用 list 即可。
  • 同樣的,當你傳入的引數是一個 array 陣列時,那麼這個引數池子也會預設設定了一個 'array' 字串作為 key 值,以供上下文獲取這個 array 陣列的物件值,獲取時通過使用 _parameter.array 來獲取,一般使用 array 即可。
  • 但是!當你傳入的引數是一個 map 集合型別時,那麼這個引數池就沒必要為你新增預設 key 值了,因為 map 集合型別本身就會有很多 key 值,例如你想獲取 map 引數的某個 key 值,你可以直接使用 _parameter.name 或者 _parameter.age 即可,就沒必要還用 _parameter.map.name 或者 _parameter.map.age ,所以這就是 map 引數型別無需再構建一個 'map' 字串作為 key 的原因,物件型別也是如此,例如你傳入一個 User 物件。

因此,如果是 Map 集合,你可以這麼使用:

// java 程式碼
Map map2 = new HashMap<>();
map2.put("k1",1);
map2.put("k2",2);
map2.put("k3",3); 
sqlSession.selectList("findAll",map2);

直接使用 collection="_parameter",你會發現神奇的 key 和 value 都能通過 _parameter 遍歷在 index 與 item 之中。

<!-- xml 配置 -->
<select id="findAll">
    select  * from user where
    <foreach collection="_parameter"
         item="value111" index="key111"
         open=" " separator=" or " close=" ">
        id = #{value111}
    </foreach>
</select>

延伸:當傳入引數為多個物件時,例如傳入 User 和 Room 等,那麼通過內建引數獲取物件可以使用 _parameter.get(0).username,或者 _parameter.get(1).roomname 。假如你傳入的引數是一個簡單資料型別,例如傳入 int =1 或者 String = '你好',那麼都可以直接使用 _parameter 代替獲取值即可,這就是很多人會在動態 SQL 中直接使用 # { _parameter } 來獲取簡單資料型別的值。

那到這裡,我們基本把 foreach 基本用法介紹完成,不過以上只是針對查詢的使用場景,對於刪除、更新、插入的用法,也是大同小異,我們簡單說一下,如果你希望批量插入 100 條使用者記錄:

<insert id="insertUser" parameterType="java.util.List">
    insert into user(id,username) values
    <foreach collection="list" 
         item="user" index="index"
         separator="," close=";" >
        (#{user.id},#{user.username})
    </foreach>
</insert>

如果你希望更新 500 個使用者的姓名:

<update id="updateUser" parameterType="java.util.List">
    update user 
       set username = '潘潘' 
     where id in 
    <foreach collection="list"
        item="user" index="index" 
        separator="," open="(" close=")" >
        #{user.id}    
    </foreach>
</update>

如果你希望你刪除 10 條使用者記錄:

<delete id="deleteUser" parameterType="java.util.List">
    delete from user  
          where id in 
    <foreach collection="list"
         item="user" index="index" 
         separator="," open="(" close=")" >
        #{user.id}    
    </foreach>
</delete>

更多玩法,期待你自己去挖掘!

注意:使用 foreach 標籤時,需要對傳入的 collection 引數(List/Map/Set等)進行為空性判斷,否則動態 SQL 會出現語法異常,例如你的查詢語句可能是 select * from user where ids in () ,導致以上語法異常就是傳入引數為空,解決方案可以用 if 標籤或 choose 標籤進行為空性判斷處理,或者直接在 Java 程式碼中進行邏輯處理即可,例如判斷為空則不執行 SQL 。


Top4、where 標籤、set 標籤

常用度:★★☆☆☆

實用性:★★★★☆

我們把 where 標籤和 set 標籤放置一起講解,一是這兩個標籤在實際應用開發中常用度確實不分伯仲,二是這兩個標籤出自一家,都繼承了 trim 標籤,放置一起方便我們比對追根。(其中底層原理會在第4部分詳細講解)

之前我們介紹 if 標籤的時候,相信大家都已經看到,我們在 where 子句後面拼接了 1=1 的條件語句塊,目的是為了保證後續條件能夠正確拼接,以前在程式程式碼中使用字串拼接 SQL 條件語句常常如此使用,但是確實此種方式不夠體面,也顯得我們不高階。

<select id="findUser">
    select * from User where 1=1
    <if test=" age != null ">
        and age > #{age}
    </if>
    <if test=" name != null ">
        and name like concat(#{name},'%')
    </if>
</select>

以上是我們使用 1=1 的寫法,那 where 標籤誕生之後,是怎麼巧妙處理後續的條件語句的呢?

<select id="findUser">
    select * from User 
    <where>
        <if test=" age != null ">
            and age > #{age}
        </if>
        <if test=" name != null ">
            and name like concat(#{name},'%')
        </if>
    </where>
</select>

我們只需把 where 關鍵詞以及 1=1 改為 < where > 標籤即可,另外還有一個特殊的處理能力,就是 where 標籤能夠智慧的去除(忽略)首個滿足條件語句的字首,例如以上條件如果 age 和 name 都滿足,那麼 age 字首 and 會被智慧去除掉,無論你是使用 and 運算子或是 or 運算子,Mybatis 框架都會幫你智慧處理。

用法特別簡單,我們用官術總結一下

  • where 標籤:頂層的遍歷標籤,需要配合 if 標籤使用,單獨使用無意義,並且只會在子元素(如 if 標籤)返回任何內容的情況下才插入 WHERE 子句。另外,若子句的開頭為 “AND” 或 “OR”,where 標籤也會將它替換去除。

瞭解了基本用法之後,我們再看看剛剛我們的例子中:

<select id="findUser">
    select * from User 
    <where>
        <if test=" age != null ">
            and age > #{age}
        </if>
        <if test=" name != null ">
            and name like concat(#{name},'%')
        </if>
    </where>
</select>

如果 age 傳入有效值 10 ,滿足 age != null 的條件之後,那麼就會返回 where 標籤並去除首個子句運算子 and,最終的 SQL 語句會變成:

select * from User where age > 10; 
-- and 巧妙的不見了

值得注意的是,where 標籤 只會 智慧的去除(忽略)首個滿足條件語句的字首,所以就建議我們在使用 where 標籤的時候,每個語句都最好寫上 and 字首或者 or 字首,否則像以下寫法就很有可能出大事:

<select id="findUser">
    select * from User 
    <where>
        <if test=" age != null ">
             age > #{age} 
             <!-- age 字首沒有運算子-->
        </if>
        <if test=" name != null ">
             name like concat(#{name},'%')
             <!-- name 字首也沒有運算子-->
        </if>
    </where>
</select>

當 age 傳入 10,name 傳入 ‘潘潘’ 時,最終的 SQL 語句是:

select * from User 
where 
age > 10 
name like concat('潘%')
-- 所有條件都沒有and或or運算子
-- 這讓age和name顯得很尷尬~

由於 name 字首沒有寫 and 或 or 連線符,而 where 標籤又不會智慧的去除(忽略)非首個 滿足條件語句的字首,所以當 age 條件語句與 name 條件語句同時成立時,就會導致語法錯誤,這個需要謹慎使用,格外注意!原則上每個條件子句都建議在句首新增運算子 and 或 or ,首個條件語句可新增可不加。

另外還有一個值得注意的點,我們使用 XML 方式配置 SQL 時,如果在 where 標籤之後新增了註釋,那麼當有子元素滿足條件時,除了 < !-- --> 註釋會被 where 忽略解析以外,其它註釋例如 // 或 /**/ 或 -- 等都會被 where 當成首個子句元素處理,導致後續真正的首個 AND 子句元素或 OR 子句元素沒能被成功替換掉字首,從而引起語法錯誤!

基於 where 標籤元素的講解,有助於我們快速理解 set 標籤元素,畢竟它倆是如此相像。我們回憶一下以往我們的更新 SQL 語句:

<update id="updateUser">
    update user 
       set age = #{age},
           username = #{username},
           password = #{password} 
     where id =#{id}
</update> 

以上語句是我們日常用於更新指定 id 物件的 age 欄位、 username 欄位以及 password 欄位,但是很多時候,我們可能只希望更新物件的某些欄位,而不是每次都更新物件的所有欄位,這就使得我們在語句結構的構建上顯得慘白無力。於是有了 set 標籤元素。

用法與 where 標籤元素相似

  • set 標籤:頂層的遍歷標籤,需要配合 if 標籤使用,單獨使用無意義,並且只會在子元素(如 if 標籤)返回任何內容的情況下才插入 set 子句。另外,若子句的 開頭或結尾 都存在逗號 “,” 則 set 標籤都會將它替換去除。

根據此用法我們可以把以上的例子改為:

<update id="updateUser">
    update user 
        <set>
           <if test="age !=null">
               age = #{age},
           </if>
           <if test="username !=null">
           	   username = #{username},
           </if> 
           <if test="password !=null">
           	   password = #{password},
           </if>
        </set>    
     where id =#{id}
</update> 

很簡單易懂,set 標籤會智慧拼接更新欄位,以上例子如果傳入 age =10 和 username = '潘潘' ,則有兩個欄位滿足更新條件,於是 set 標籤會智慧拼接 " age = 10 ," 和 "username = '潘潘' ," 。其中由於後一個 username 屬於最後一個子句,所以末尾逗號會被智慧去除,最終的 SQL 語句是:

update user set age = 10,username =  '潘潘' 

另外需要注意,set 標籤下需要保證至少有一個條件滿足,否則依然會產生語法錯誤,例如在無子句條件滿足的場景下,最終的 SQL 語句會是這樣:

update user ;  ( oh~ no!)

既不會新增 set 標籤,也沒有子句更新欄位,於是語法出現了錯誤,所以類似這類情況,一般需要在應用程式中進行邏輯處理,判斷是否存在至少一個引數,否則不執行更新 SQL 。所以原則上要求 set 標籤下至少存在一個條件滿足,同時每個條件子句都建議在句末新增逗號 ,最後一個條件語句可加可不加。或者 每個條件子句都在句首新增逗號 ,第一個條件語句可加可不加,例如:

<update id="updateUser">
    update user 
        <set>
           <if test="age !=null">
               ,age = #{age}
           </if>
           <if test="username !=null">
           	   ,username = #{username}
           </if> 
           <if test="password !=null">
           	   ,password = #{password}
           </if>
        </set>    
     where id =#{id}
</update> 

與 where 標籤相同,我們使用 XML 方式配置 SQL 時,如果在 set 標籤子句末尾新增了註釋,那麼當有子元素滿足條件時,除了 < !-- --> 註釋會被 set 忽略解析以外,其它註釋例如 // 或 /**/ 或 -- 等都會被 set 標籤當成末尾子句元素處理,導致後續真正的末尾子句元素的逗號沒能被成功替換掉字尾,從而引起語法錯誤!

到此,我們的 where 標籤元素與 set 標籤就基本介紹完成,它倆確實極為相似,區別僅在於:

  • where 標籤插入字首 where
  • set 標籤插入字首 set
  • where 標籤僅智慧替換字首 AND 或 OR
  • set 標籤可以只能替換字首逗號,或字尾逗號,

而這兩者的前字尾去除策略,都源自於 trim 標籤的設計,我們一起看看到底 trim 標籤是有多靈活!


Top5、trim 標籤

常用度:★☆☆☆☆

實用性:★☆☆☆☆

上面我們介紹了 where 標籤與 set 標籤,它倆的共同點無非就是前置關鍵詞 where 或 set 的插入,以及前字尾符號(例如 AND | OR | ,)的智慧去除。基於 where 標籤和 set 標籤本身都繼承了 trim 標籤,所以 trim 標籤的大致實現我們也能猜出個一二三。

其實 where 標籤和 set 標籤都只是 trim 標籤的某種實現方案,trim 標籤底層是通過 TrimSqlNode 類來實現的,它有幾個關鍵屬性:

  • prefix :字首,當 trim 元素記憶體在內容時,會給內容插入指定字首
  • suffix :字尾,當 trim 元素記憶體在內容時,會給內容插入指定字尾
  • prefixesToOverride :字首去除,支援多個,當 trim 元素記憶體在內容時,會把內容中匹配的字首字串去除。
  • suffixesToOverride :字尾去除,支援多個,當 trim 元素記憶體在內容時,會把內容中匹配的字尾字串去除。

所以 where 標籤如果通過 trim 標籤實現的話可以這麼編寫:(

<!--
  注意在使用 trim 標籤實現 where 標籤能力時
  必須在 AND 和 OR 之後新增空格
  避免匹配到 android、order 等單詞 
-->
<trim prefix="WHERE" prefixOverrides="AND | OR" >
    ...
</trim>

而 set 標籤如果通過 trim 標籤實現的話可以這麼編寫:

<trim prefix="SET" prefixOverrides="," >
    ...
</trim>

或者

<trim prefix="SET" suffixesToOverride="," >
    ...
</trim>

所以可見 trim 是足夠靈活的,不過由於 where 標籤和 set 標籤這兩種 trim 標籤變種方案已經足以滿足我們實際開發需求,所以直接使用 trim 標籤的場景實際上不太很多(其實是我自己使用的不多,基本沒用過)。

注意,set 標籤之所以能夠支援去除字首逗號或者字尾逗號,是由於其在構造 trim 標籤的時候進行了字首字尾的去除設定,而 where 標籤在構造 trim 標籤的時候就僅僅設定了字首去除。

set 標籤元素之構造時:

// Set 標籤
public class SetSqlNode extends TrimSqlNode {

  private static final List<String> COMMA = Collections.singletonList(",");

  // 明顯使用了字首字尾去除,注意前字尾引數都傳入了 COMMA 
  public SetSqlNode(Configuration configuration,SqlNode contents) {
    super(configuration, contents, "SET", COMMA, null, COMMA);
  }

}

where 標籤元素之構造時:

// Where 標籤
public class WhereSqlNode extends TrimSqlNode {

  // 其實包含了很多種場景
  private static List<String> prefixList = Arrays.asList("AND ","OR ","AND\n", "OR\n", "AND\r", "OR\r", "AND\t", "OR\t");

  // 明顯只使用了字首去除,注意字首傳入 prefixList,字尾傳入 null 
  public WhereSqlNode(Configuration configuration, SqlNode contents) {
    super(configuration, contents, "WHERE", prefixList, null, null);
  }

}

Top6、bind 標籤

常用度:☆☆☆☆☆

實用性:★☆☆☆☆

簡單來說,這個標籤就是可以建立一個變數,並繫結到上下文,即供上下文使用,就是這樣,我把官網的例子直接拷貝過來:

<select id="selecUser">
  <bind name="myName" value="'%' + _parameter.getName() + '%'" />
  SELECT * FROM user
  WHERE name LIKE #{myName}
</select>

大家應該大致能知道以上例子的功效,其實就是輔助構建模糊查詢的語句拼接,那有人就好奇了,為啥不直接拼接語句就行了,為什麼還要搞出一個變數,繞一圈呢?

我先問一個問題:平時你使用 mysql 都是如何拼接模糊查詢 like 語句的?

select * from user where name like concat('%',#{name},'%')

確實如此,但如果有一天領導跟你說資料庫換成 oracle 了,怎麼辦?上面的語句還能用嗎?明顯用不了,不能這麼寫,因為 oracle 雖然也有 concat 函式,但是隻支援連線兩個字串,例如你最多這麼寫:

select * from user where name like concat('%',#{name})

但是少了右邊的井號符號,所以達不到你預期的效果,於是你改成這樣:

select * from user where name like '%'||#{name}||'%'

確實可以了,但是過幾天領導又跟你說,資料庫換回 mysql 了?額… 那不好意思,你又得把相關使用到模糊查詢的地方改回來。

select * from user where name like concat('%',#{name},'%')

很顯然,資料庫只要發生變更你的 sql 語句就得跟著改,特別麻煩,所以才有了一開始我們介紹 bind 標籤官網的這個例子,無論使用哪種資料庫,這個模糊查詢的 Like 語法都是支援的:

<select id="selecUser">
  <bind name="myName" value="'%' + _parameter.getName() + '%'" />
  SELECT * FROM user
  WHERE name LIKE #{myName}
</select>

這個 bind 的用法,實打實解決了資料庫重新選型後導致的一些問題,當然在實際工作中發生的概率不會太大,所以 bind 的使用我個人確實也使用的不多,可能還有其它一些應用場景,希望有人能發現之後來跟我們分享一下,總之我勉強給了一顆星(雖然沒太多實際用處,但畢竟要給點面子)。


擴充:sql標籤 + include 標籤

常用度:★★★☆☆

實用性:★★★☆☆

sql 標籤與 include 標籤組合使用,用於 SQL 語句的複用,日常高頻或公用使用的語句塊可以抽取出來進行復用,其實我們應該不陌生,早期我們學習 JSP 的時候,就有一個 include 標記可以引入一些公用可複用的頁面檔案,例如頁面頭部或尾部頁面程式碼元素,這種複用的設計很常見。

嚴格意義上 sql 、include 不算在動態 SQL 標籤成員之內,只因它確實是寶藏般的存在,所以我要簡單說說,sql 標籤用於定義一段可重用的 SQL 語句片段,以便在其它語句中使用,而 include 標籤則通過屬性 refid 來引用對應 id 匹配的 sql 標籤語句片段。

簡單的複用程式碼塊可以是:

<!-- 可複用的欄位語句塊 -->
<sql id="userColumns">
    id,username,password 
</sql>

查詢或插入時簡單複用:

<!-- 查詢時簡單複用 -->
<select id="selectUsers" resultType="map">
  select
    <include refid="userColumns"></include> 
  from user 
</select>

<!-- 插入時簡單複用 -->
<insert id="insertUser" resultType="map">
  insert into user(
    <include refid="userColumns"></include> 
  )values(
    #{id},#{username},#{password} 
  )  
</insert>

當然,複用語句還支援屬性傳遞,例如:

<!-- 可複用的欄位語句塊 -->
<sql id="userColumns">
    ${pojo}.id,${pojo}.username 
</sql>

這個 SQL 片段可以在其它語句中使用:

<!-- 查詢時複用 -->
<select id="selectUsers" resultType="map">
  select
    <include refid="userColumns">
        <property name="pojo" value="u1"/>
    </include>,
    <include refid="userColumns">
        <property name="pojo" value="u2"/>
    </include>
  from user u1 cross join user u2
</select>

也可以在 include 元素的 refid 屬性或多層內部語句中使用屬性值,屬性可以穿透傳遞,例如:

<!-- 簡單語句塊 -->
<sql id="sql1">
  ${prefix}_user
</sql>

<!-- 巢狀語句塊 -->
<sql id="sql2">
  from
    <include refid="${include_target}"/>
</sql>

<!-- 查詢時引用巢狀語句塊 -->
<select id="select" resultType="map">
  select
    id, username
  <include refid="sql2">
    <property name="prefix" value="t"/>
    <property name="include_target" value="sql1"/>
  </include>
</select>

至此,關於 9 大動態 SQL 標籤的基本用法我們已介紹完畢,另外我們還有一些疑問:Mybatis 底層是如何解析這些動態 SQL 標籤的呢?最終又是怎麼構建完整可執行的 SQL 語句的呢?帶著這些疑問,我們在第4節中詳細分析。

4、動態SQL的底層原理

想了解 Mybatis 究竟是如何解析與構建動態 SQL ?首先推薦的當然是讀原始碼,而讀原始碼,是一個技術鑽研問題,為了借鑑學習,為了工作儲備,為了解決問題,為了讓自己在程式設計的道路上跑得明白一些... 而希望通過讀原始碼,去了解底層實現原理,切記不能脫離了整體去讀區域性,否則你瞭解到的必然侷限且片面,從而輕忽了真核上的設計。如同我們讀史或者觀宇宙一樣,最好的辦法都是從整體到區域性,不斷放大,前後延展,會很舒服通透。所以我準備從 Mybatis 框架的核心主線上去逐步放大剖析。

通過前面幾篇文章的介紹(建議閱讀 Mybatis 系列全解之六:《Mybatis 最硬核的 API 你知道幾個?》),其實我們知道了 Mybatis 框架的核心部分在於構件的構建過程,從而支撐了外部應用程式的使用,從應用程式端建立配置並呼叫 API 開始,到框架端載入配置並初始化構件,再建立會話並接收請求,然後處理請求,最終返回處理結果等。

我們的動態 SQL 解析部分就發生在 SQL 語句物件 MappedStatement 構建時(上左高亮橘色部分,注意觀察其中 SQL 語句物件與 SqlSource 、 BoundSql 的關係,在動態 SQL 解析流程特別關鍵)。我們再拉近一點,可以看到無論是使用 XML 配置 SQL 語句或是使用註解方式配置 SQL 語句,框架最終都會把解析完成的 SQL 語句物件存放到 MappedStatement 語句集合池子。

而以上虛線高亮部分,即是 XML 配置方式解析過程與註解配置方式解析過程中涉及到動態 SQL 標籤解析的流程,我們分別講解:

  • 第一,XML 方式配置 SQL 語句,框架如何解析?

以上為 XML 配置方式的 SQL 語句解析過程,無論是單獨使用 Mybatis 框架還是整合 Spring 與 Mybatis 框架,程式啟動入口都會首先從 SqlSessionFactoryBuilder.build() 開始構建,依次通過 XMLConfigBuilder 構建全域性配置 Configuration 物件、通過 XMLMapperBuilder 構建每一個 Mapper 對映器、通過 XMLStatementBuilder 構建對映器中的每一個 SQL 語句物件(select/insert/update/delete)。而就在解析構建每一個 SQL 語句物件時,涉及到一個關鍵的方法 parseStatementNode(),即上圖橘紅色高亮部分,此方法內部就出現了一個處理動態 SQL 的核心節點。

// XML配置語句構建器
public class XMLStatementBuilder {
    
    // 實際解析每一個 SQL 語句
    // 例如 select|insert|update|delete
	public void parseStatementNode() {
        
        // [忽略]引數構建...
        // [忽略]快取構建..
        // [忽略]結果集構建等等.. 
        
        // 【重點】此處即是處理動態 SQL 的核心!!!
        String lang = context.getStringAttribute("lang");
    	LanguageDriver langDriver = getLanguageDriver(lang);
        SqlSource sqlSource = langDriver.createSqlSource(..);
        
        // [忽略]最後把解析完成的語句物件新增進語句集合池
        builderAssistant.addMappedStatement(語句物件)
	}
}

大家先重點關注一下這段程式碼,其中【重點】部分的 LanguageDriver 與 SqlSource 會是我們接下來講解動態 SQL 語句解析的核心類,我們不著急剖析,我們先把註解方式流程也梳理對比一下。

  • 第二,註解方式配置 SQL 語句,框架如何解析?

大家會發現註解配置方式的 SQL 語句解析過程,與 XML 方式極為相像,唯一不同點就在於解析註解 SQL 語句時,使用了 MapperAnnotationBuilder 構建器,其中關於每一個語句物件 (@Select,@Insert,@Update,@Delete等) 的解析,又都會通過一個關鍵解析方法 parseStatement(),即上圖橘紅色高亮部分,此方法內部同樣的出現了一個處理動態 SQL 的核心節點。

// 註解配置語句構建器
public class MapperAnnotationBuilder {
    
    // 實際解析每一個 SQL 語句
    // 例如 @Select,@Insert,@Update,@Delete
    void parseStatement(Method method) {  
        
        // [忽略]引數構建...
        // [忽略]快取構建..
        // [忽略]結果集構建等等.. 
        
        // 【重點】此處即是處理動態 SQL 的核心!!!
    	final LanguageDriver languageDriver = getLanguageDriver(method);  
    	final SqlSource sqlSource = buildSqlSource( languageDriver,... );
        
        // [忽略]最後把解析完成的語句物件新增進語句集合池
        builderAssistant.addMappedStatement(語句物件)

    }    
}

由此可見,不管是通過 XML 配置語句還是註解方式配置語句,構建流程都是 大致相同,並且依然出現了我們在 XML 配置方式中涉及到的語言驅動 LanguageDriver 與語句源 SqlSource ,那這兩個類/介面到底為何物,為何能讓 SQL 語句解析者都如此繞不開 ?

這一切,得從你編寫的 SQL 開始講起 ...

我們知道,無論 XML 還是註解,最終你的所有 SQL 語句物件都會被齊齊整整的解析完放置在 SQL 語句物件集合池中,以供執行器 Executor 具體執行增刪改查 ( CRUD ) 時使用。而我們知道每一個 SQL 語句物件的屬性,特別複雜繁多,例如超時設定、快取、語句型別、結果集對映關係等等。

// SQL 語句物件
public final class MappedStatement {

  private String resource;
  private Configuration configuration;
  private String id;
  private Integer fetchSize;
  private Integer timeout;
  private StatementType statementType;
  private ResultSetType resultSetType;
    
  // SQL 源
  private SqlSource sqlSource;
  private Cache cache;
  private ParameterMap parameterMap;
  private List<ResultMap> resultMaps;
  private boolean flushCacheRequired;
  private boolean useCache;
  private boolean resultOrdered;
  private SqlCommandType sqlCommandType;
  private KeyGenerator keyGenerator;
  private String[] keyProperties;
  private String[] keyColumns;
  private boolean hasNestedResultMaps;
  private String databaseId;
  private Log statementLog;
  private LanguageDriver lang;
  private String[] resultSets;
    
}

而其中有一個特別的屬性就是我們的語句源 SqlSource ,功能純粹也恰如其名 SQL 源。它是一個介面,它會結合使用者傳遞的引數物件 parameterObject 與動態 SQL,生成 SQL 語句,並最終封裝成 BoundSql 物件。SqlSource 介面有5個實現類,分別是:StaticSqlSource、DynamicSqlSource、RawSqlSource、ProviderSqlSource、VelocitySqlSource (而 velocitySqlSource 目前只是一個測試用例,還沒有用作實際的 Sql 源實現)。

  • StaticSqlSource:靜態 SQL 源實現類,所有的 SQL 源最終都會構建成 StaticSqlSource 例項,該實現類會生成最終可執行的 SQL 語句供 statement 或 prepareStatement 使用。
  • RawSqlSource:原生 SQL 源實現類,解析構建含有 ‘#{}’ 佔位符的 SQL 語句或原生 SQL 語句,解析完最終會構建 StaticSqlSource 例項。
  • DynamicSqlSource:動態 SQL 源實現類,解析構建含有 ‘${}’ 替換符的 SQL 語句或含有動態 SQL 的語句(例如 If/Where/Foreach等),解析完最終會構建 StaticSqlSource 例項。
  • ProviderSqlSource:註解方式的 SQL 源實現類,會根據 SQL 語句的內容分發給 RawSqlSource 或 DynamicSqlSource ,當然最終也會構建 StaticSqlSource 例項。
  • VelocitySqlSource:模板 SQL 源實現類,目前(V3.5.6)官方申明這只是一個測試用例,還沒有用作真正的模板 Sql 源實現類。

SqlSource 例項在配置類 Configuration 解析階段就被建立,Mybatis 框架會依據3個維度的資訊來選擇構建哪種資料來源例項:(純屬我個人理解的歸類梳理~)

  • 第一個維度:客戶端的 SQL 配置方式:XML 方式或者註解方式。
  • 第二個維度:SQL 語句中是否使用動態 SQL ( if/where/foreach 等 )。
  • 第三個維度:SQL 語句中是否含有替換符 ‘${}’ 或佔位符 ‘#{}’ 。

SqlSource 介面只有一個方法 getBoundSql ,就是建立 BoundSql 物件。

public interface SqlSource {

  BoundSql getBoundSql(Object parameterObject);

}

通過 SQL 源就能夠獲取 BoundSql 物件,從而獲取最終送往資料庫(通過JDBC)中執行的 SQL 字串。

JDBC 中執行的 SQL 字串,確實就在 BoundSql 物件中。BoundSql 物件儲存了動態(或靜態)生成的 SQL 語句以及相應的引數資訊,它是在執行器具體執行 CURD 時通過實際的 SqlSource 例項所構建的。

public class BoundSql { 

  //該欄位中記錄了SQL語句,該SQL語句中可能含有"?"佔位符
  private final String sql;
    
  //SQL中的引數屬性集合
  private final List<ParameterMapping> parameterMappings;
    
  //客戶端執行SQL時傳入的實際引數值
  private final Object parameterObject;
    
  //複製 DynamicContext.bindings 集合中的內容
  private final Map<String, Object> additionalParameters;
    
  //通過 additionalParameters 構建元引數物件
  private final MetaObject metaParameters;
    
}

在執行器 Executor 例項(例如BaseExecutor)執行增刪改查時,會通過 SqlSource 構建 BoundSql 例項,然後再通過 BoundSql 例項獲取最終輸送至資料庫執行的 SQL 語句,系統可根據 SQL 語句構建 Statement 或者 PrepareStatement ,從而送往資料庫執行,例如語句處理器 StatementHandler 的執行過程。

牆裂推薦閱讀之前第六文之 Mybatis 最硬核的 API 你知道幾個?這些執行流程都有細講。

到此我們介紹完 SQL 源 SqlSource 與 BoundSql 的關係,注意 SqlSource 與 BoundSql 不是同個階段產生的,而是分別在程式啟動階段與執行時。

  • 程式啟動初始構建時,框架會根據 SQL 語句型別構建對應的 SqlSource 源例項(靜態/動態).
  • 程式實際執行時,框架會根據傳入引數動態的構建 BoundSql 物件,輸送最終 SQL 到資料庫執行。

在上面我們知道了 SQL 源是語句物件 BoundSql 的屬性,同時還坐擁5大實現類,那究竟是誰建立了 SQL 源呢?其實就是我們接下來準備介紹的語言驅動 LanguageDriver !

public interface LanguageDriver {
    SqlSource createSqlSource(...);
}

語言驅動介面 LanguageDriver 也是極簡潔,內部定義了構建 SQL 源的方法,LanguageDriver 介面有2個實現類,分別是: XMLLanguageDriver 、 RawLanguageDriver。簡單介紹一下:

  • XMLLanguageDriver :是框架預設的語言驅動,能夠根據上面我們講解的 SQL 源的3個維度建立對應匹配的 SQL 源(DynamicSqlSource、RawSqlSource等)。下面這段程式碼是 Mybatis 在裝配全域性配置時的一些跟語言驅動相關的動作,我摘抄出來,分別有:內建了兩種語言驅動並設定了別名方便引用、註冊了兩種語言驅動至語言註冊工廠、把 XML 語言驅動設定為預設語言驅動。
// 全域性配置的構造方法
public Configuration() {
    // 內建/註冊了很多有意思的【別名】
    // ...
    
    // 其中就內建了上述的兩種語言驅動【別名】
    typeAliasRegistry.registerAlias("XML", XMLLanguageDriver.class);
    typeAliasRegistry.registerAlias("RAW", RawLanguageDriver.class);
    
    // 註冊了XML【語言驅動】 --> 並設定成預設!   
    languageRegistry.setDefaultDriverClass(XMLLanguageDriver.class);
    
    // 註冊了原生【語言驅動】
    languageRegistry.register(RawLanguageDriver.class);
}
  • RawLanguageDriver :看名字得知是原生語言驅動,事實也如此,它只能建立原生 SQL 源(RawSqlSource),另外它還繼承了 XMLLanguageDriver 。
/**
 * As of 3.2.4 the default XML language is able to identify static statements
 * and create a {@link RawSqlSource}. So there is no need to use RAW unless you
 * want to make sure that there is not any dynamic tag for any reason.
 *
 * @since 3.2.0
 * @author Eduardo Macarron
 */
public class RawLanguageDriver extends XMLLanguageDriver {
}

註釋的大致意思:自 Mybatis 3.2.4 之後的版本, XML 語言驅動就支援解析靜態語句(動態語句當然也支援)並建立對應的 SQL 源(例如靜態語句是原生 SQL 源),所以除非你十分確定你的 SQL 語句中沒有包含任何一款動態標籤,否則就不要使用 RawLanguageDriver !否則會報錯!!!先看個別名引用的例子:

<select id="findAll"  resultType="map" lang="RAW" >
     select * from user
</select>

<!-- 別名或全限定類名都允許 -->

<select id="findAll"  resultType="map" lang="org.apache.ibatis.scripting.xmltags.XMLLanguageDriver">
     select * from user
</select>

框架允許我們通過 lang 屬性手工指定語言驅動,不指定則系統預設是 lang = "XML",XML 代表 XMLLanguageDriver ,當然 lang 屬性可以是我們內建的別名也可以是我們的語言驅動全限定名,不過值得注意的是,當語句中含有動態 SQL 標籤時,就只能選擇使用 lang="XML",否則程式在初始化構件時就會報錯。

## Cause: org.apache.ibatis.builder.BuilderException: 
## Dynamic content is not allowed when using RAW language
## 動態語句內容不被原生語言驅動支援!

這段錯誤提示其實是發生在 RawLanguageDriver 檢查動態 SQL 源時:

public class RawLanguageDriver extends XMLLanguageDriver { 

  // RAW 不能包含動態內容
  private void checkIsNotDynamic(SqlSource source) {
    if (!RawSqlSource.class.equals(source.getClass())) {
      throw new BuilderException(
          "Dynamic content is not allowed when using RAW language"
      );
    }
  } 
}

至此,基本邏輯我們已經梳理清楚:程式啟動初始階段,語言驅動建立 SQL 源,而執行時, SQL 源動態解析構建出 BoundSql 。

那麼除了系統預設的兩種語言驅動,還有其它嗎?

答案是:有,例如 Mybatis 框架中目前使用了一個名為 VelocityLanguageDriver 的語言驅動。相信大家都學習過 JSP 模板引擎,同時還有很多人學習過其它一些(頁面)模板引擎,例如 freemark 和 velocity ,不同模板引擎有自己的一套模板語言語法,而其中 Mybatis 就嘗試使用了 Velocity 模板引擎作為語言驅動,目前雖然 Mybatis 只是在測試用例中使用到,但是它告訴了我們,框架允許自定義語言驅動,所以不只是 XML、RAW 兩種語言驅動中使用的 OGNL 語法,也可以是 Velocity (語法),或者你自己所能定義的一套模板語言(同時你得定義一套語法)。 例如以下就是 Mybatis 框架中使用到的 Velocity 語言驅動和對應的 SQL 源,它們使用 Velocity 語法/方式解析構建 BoundSql 物件。

/**
 * Just a test case. Not a real Velocity implementation.
 * 只是一個測試示例,還不是一個真正的 Velocity 方式實現
 */
public class VelocityLanguageDriver implements LanguageDriver {
     public SqlSource createSqlSource() {...}
}
public class VelocitySqlSource implements SqlSource {
     public BoundSql getBoundSql() {...}
}

好,語言驅動的基本概念大致如此。我們回過頭再詳細看看動態 SQL 源 SqlSource,作為語句物件 MappedStatement 的屬性,在 程式初始構建階段,語言驅動是怎麼建立它的呢?不妨我們先看看常用的動態 SQL 源物件是怎麼被建立的吧!

通過以上的程式初始構建階段,我們可以發現,最終語言驅動通過呼叫 XMLScriptBuilder 物件來建立 SQL 源。

// XML 語言驅動
public class XMLLanguageDriver implements LanguageDriver {  
  
    // 通過呼叫 XMLScriptBuilder 物件來建立 SQL 源
    @Override
  	public SqlSource createSqlSource() {
        // 例項
    	XMLScriptBuilder builder = new XMLScriptBuilder();
    	// 解析
        return builder.parseScriptNode();
	}
}

而在前面我們就已經介紹, XMLScriptBuilder 例項初始構造時,會初始構建所有動態標籤處理器:

// XML指令碼標籤構建器
public class XMLScriptBuilder{
    // 標籤節點處理器池
    private final Map<String, NodeHandler> nodeHandlerMap = new HashMap<>();

    // 構造器
    public XMLScriptBuilder() { 
        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());
    }
}

繼 XMLScriptBuilder 初始化流程之後,解析建立 SQL 源流程再分為兩步:

1、解析動態標籤,通過判斷每一塊動態標籤的型別,使用對應的標籤處理器進行解析屬性和語句處理,並最終放置到混合 SQL 節點池中(MixedSqlNode),以供程式執行時構建 BoundSql 時使用。

2、new SQL 源,根據 SQL 是否有動態標籤或萬用字元佔位符來確認產生物件的靜態或動態 SQL 源。

public SqlSource parseScriptNode() {
    
    // 1、解析動態標籤 ,並放到混合SQL節點池中
    MixedSqlNode rootSqlNode = parseDynamicTags(context);
    
    // 2、根據語句型別,new 出來最終的 SQL 源
    SqlSource sqlSource;
    if (isDynamic) {
      sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
      sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
}

原來解析動態標籤的工作交給了 parseDynamicTags() 方法,並且每一個語句物件的動態 SQL 標籤最終都會被放到一個混合 SQL 節點池中。

// 混合 SQL 節點池
public class MixedSqlNode implements SqlNode {
    
    // 所有動態 SQL 標籤:IF、WHERE、SET 等
    private final List<SqlNode> contents;
}

我們先看一下 SqlNode 介面的實現類,基本涵蓋了我們所有動態 SQL 標籤處理器所需要使用到的節點例項。而其中混合 SQL 節點 MixedSqlNode 作用僅是為了方便獲取每一個語句的所有動態標籤節點,於是應勢而生。

知道動態 SQL 標籤節點處理器及以上的節點實現類之後,其實就能很容易理解,到達程式執行時,執行器會呼叫 SQL 源來協助構建 BoundSql 物件,而 SQL 源的核心工作,就是根據每一小段標籤型別,匹配到對應的節點實現類以解析拼接每一小段 SQL 語句。

程式執行時,動態 SQL 源獲取 BoundSql 物件 :

// 動態 SQL 源
public class DynamicSqlSource implements SqlSource { 
   
    // 這裡的 rootSqlNode 屬性就是 MixedSqlNode 
    private final SqlNode rootSqlNode;
  
    @Override
    public BoundSql getBoundSql(Object parameterObject) {
 
        // 動態SQL核心解析流程  
        rootSqlNode.apply(...);  
        
        return boundSql;

    } 
}

很明顯,通過呼叫 MixedSqlNode 的 apply () 方法,迴圈遍歷每一個具體的標籤節點。

public class MixedSqlNode implements SqlNode {
    
      // 所有動態 SQL 標籤:IF、WHERE、SET 等
      private final List<SqlNode> contents; 

      @Override
      public boolean apply(...) {

        // 迴圈遍歷,把每一個節點的解析分派到具體的節點實現之上
        // 例如 <if> 節點的解析交給 IfSqlNode
        // 例如 純文字節點的解析交給 StaticTextSqlNode
        contents.forEach(node -> node.apply(...));
        return true;
      }
}

我們選擇一兩個標籤節點的解析過程進行說明,其它標籤節點實現類的處理也基本雷同。首先我們看一下 IF 標籤節點的處理:

// IF 標籤節點
public class IfSqlNode implements SqlNode { 
    
      private final ExpressionEvaluator evaluator;
    
      // 實現邏輯
      @Override
      public boolean apply(DynamicContext context) {
          
        // evaluator 是一個基於 OGNL 語法的解析校驗類
        if (evaluator.evaluateBoolean(test, context.getBindings())) {
          contents.apply(context);
          return true;
        }
        return false;
      } 
}

IF 標籤節點的解析過程非常簡單,通過解析校驗類 ExpressionEvaluator 來對 IF 標籤的 test 屬性內的表示式進行解析校驗,滿足則拼接,不滿足則跳過。我們再看看 Trim 標籤的節點解析過程,set 標籤與 where 標籤的底層處理都基於此:

public class TrimSqlNode implements SqlNode { 
    
    // 核心處理方法
    public void applyAll() {
        
        // 字首智慧補充與去除
        applyPrefix(..); 
        
        // 字首智慧補充與去除
        applySuffix(..); 
    } 
}

再來看一個純文字標籤節點實現類的解析處理流程:

// 純文字標籤節點實現類
public class StaticTextSqlNode implements SqlNode {
  
    private final String text;

    public StaticTextSqlNode(String text) {
        this.text = text;
    }
    
    // 節點處理,僅僅就是純粹的語句拼接
    @Override
    public boolean apply(DynamicContext context) {
        context.appendSql(text);
        return true;
  	}
}

到這裡,動態 SQL 的底層解析過程我們基本講解完,冗長了些,但流程上大致算完整,有遺漏的,我們回頭再補充。

總結

不知不覺中,我又是這麼巨篇幅的講解剖析,確實不太適合碎片化時間閱讀,不過話說回來,畢竟此文屬於 Mybatis 全解系列,作為學研者還是建議深諳其中,對往後眾多框架技術的學習必有幫助。本文中我們很多動態 SQL 的介紹基本都使用 XML 配置方式,當然註解方式配置動態 SQL 也是支援的,動態 SQL 的語法書寫同 XML 方式,但是需要在字串前後新增 script 標籤申明該語句為動態 SQL ,例如:

public class UserDao {
   
    /**
     * 更新使用者
     */
    @Select(
        "<script>"+
        "   UPDATE user   "+
        "   <trim prefix=\"SET\" prefixOverrides=\",\"> "+
        "       <if test=\"username != null and username != ''\"> "+
        "           , username = #{username} "+
        "       </if> "+
        "   </trim> "+
        "   where id = ${id}"
        "</script>"
    )
    void updateUser( User user);
    
}

此種動態 SQL 寫法可讀性較差,並且維護起來也挺硌手,所以我個人是青睞 xml 方式配置語句,一直追求解耦,大道也至簡。當然,也有很多團隊和專案都在使用註解方式開發,這些沒有絕對,還是得結合自己的實際專案情況與團隊等去做取捨。

本篇完,本系列下一篇我們講《 Mybatis系列全解(九):Mybatis的複雜對映 》。

文章持續更新,微信搜尋「潘潘和他的朋友們」第一時間閱讀,隨時有驚喜。本文會在 GitHub https://github.com/JavaWorld 收錄,關於熱騰騰的技術、框架、面經、解決方案、摸魚技巧、教程、視訊、漫畫等等等等,我們都會以最美的姿勢第一時間送達,歡迎 Star ~ 我們未來 不止文章!想進讀者群的朋友歡迎撩我個人號:panshenlian,備註「加群」我們群裡暢聊, BIU ~

相關文章