封面:洛小汐
作者:潘潘
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 ~