摘要:使用動態 SQL 並非一件易事,但藉助可用於任何 SQL 對映語句中的強大的動態 SQL 語言,MyBatis 顯著地提升了這一特性的易用性。
本文分享自華為雲社群《MyBatis詳解 - 動態SQL使用與原理》,作者:龍哥手記 。
動態 SQL 是 MyBatis 的強大特性之一。如果你使用過 JDBC 或其它類似的框架,你應該能理解根據不同條件拼接 SQL 語句有多痛苦,例如拼接時要確保不能忘記新增必要的空格,還要注意去掉列表最後一個列名的逗號。利用動態 SQL,可以徹底擺脫這種痛苦。
使用動態 SQL 並非一件易事,但藉助可用於任何 SQL 對映語句中的強大的動態 SQL 語言,MyBatis 顯著地提升了這一特性的易用性。
如果你之前用過 JSTL 或任何基於類 XML 語言的文書處理器,你對動態 SQL 元素可能會感覺似曾相識。在 MyBatis 之前的版本中,需要花時間瞭解大量的元素。藉助功能強大的基於 OGNL 的表示式,MyBatis 3 替換了之前的大部分元素,大大精簡了元素種類,現在要學習的元素種類比原來的一半還要少。
使用動態 SQL 最常見情景是根據條件包含 where 子句的一部分。比如:
<select id="findActiveBlogWithTitleLike" resultType="Blog"> SELECT * FROM BLOG WHERE state = ‘ACTIVE’ <if test="title != null"> AND title like #{title} </if> </select>
這條語句提供了可選的查詢文字功能。如果不傳入 「title」,那麼所有處於 「ACTIVE」 狀態的 BLOG 都會返回;如果傳入了 「title」 引數,那麼就會對 「title」 一列進行模糊查詢並返回對應的 BLOG 結果(細心的讀者可能會發現,「title」 的引數值需要包含查詢掩碼或萬用字元字元)。
如果希望通過 「title」 和 「author」 兩個引數進行可選搜尋該怎麼辦呢?首先,我想先將語句名稱修改成更名副其實的名稱;接下來,只需要加入另一個條件即可。
<select id="findActiveBlogLike" resultType="Blog"> SELECT * FROM BLOG WHERE state = ‘ACTIVE’ <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> </select>
有時候,我們不想使用所有的條件,而只是想從多個條件中選擇一個使用。針對這種情況,MyBatis 提供了 choose 元素,它有點像 Java 中的 switch 語句。
還是上面的例子,但是策略變為:傳入了 「title」 就按 「title」 查詢,傳入了 「author」 就按 「author」 查詢的情形。若兩者都沒有傳入,就返回標記為 featured 的 BLOG(這可能是管理員認為,與其返回大量的無意義隨機 Blog,還不如返回一些由管理員挑選的 Blog)。
<select id="findActiveBlogLike" resultType="Blog"> SELECT * FROM BLOG WHERE state = ‘ACTIVE’ <choose> <when test="title != null"> AND title like #{title} </when> <when test="author != null and author.name != null"> AND author_name like #{author.name} </when> <otherwise> AND featured = 1 </otherwise> </choose> </select>
前面幾個例子已經合宜地解決了一個臭名昭著的動態 SQL 問題。現在回到之前的 「if」 範例,這次我們將 「state = ‘ACTIVE’」 設定成動態條件,看看會發生什麼。
<select id="findActiveBlogLike" resultType="Blog"> SELECT * FROM BLOG WHERE <if test="state != null"> state = #{state} </if> <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> </select>
如果沒有匹配的條件會怎麼樣?最終這條 SQL 會變成這樣:
SELECT * FROM BLOG
WHERE
這會導致查詢失敗。如果匹配的只是第二個條件又會怎樣?這條 SQL 會是這樣:
SELECT * FROM BLOG
WHERE
AND title like ‘someTitle’
這個查詢也會失敗。這個問題不能簡單地用條件元素來解決。這個問題是如此的難以解決,以至於解決過的人不會再想碰到這種問題。
MyBatis 有一個簡單且適合大多數場景的解決辦法。而在其他場景中,可以對其進行自定義以符合需求。而這,只需要一處簡單的改動:
<select id="findActiveBlogLike" resultType="Blog"> SELECT * FROM BLOG <where> <if test="state != null"> state = #{state} </if> <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> </where> </select>
where 元素只會在子元素返回任何內容的情況下才插入 「WHERE」 子句。而且,若子句的開頭為 「AND」 或 「OR」,where 元素也會將它們去除。
如果 where 元素與你期望的不太一樣,你也可以通過自定義 trim 元素來客製化 where 元素的功能。比如,和 where 元素等價的自定義 trim 元素為:
<trim prefix="WHERE" prefixOverrides="AND |OR "> ... </trim>
prefixOverrides 屬性會忽略通過管道符分隔的文字序列(注意此例中的空格是必要的)。上述例子會移除所有 prefixOverrides 屬性中指定的內容,並且插入 prefix 屬性中指定的內容。
用於動態更新語句的類似解決方案叫做 set。set 元素可以用於動態包含需要更新的列,忽略其它不更新的列。比如:
<update id="updateAuthorIfNecessary"> update Author <set> <if test="username != null">username=#{username},</if> <if test="password != null">password=#{password},</if> <if test="email != null">email=#{email},</if> <if test="bio != null">bio=#{bio}</if> </set> where id=#{id} </update>
這個例子中,set 元素會動態地在行首插入 SET 關鍵字,並會刪掉額外的逗號(這些逗號是在使用條件語句給列賦值時引入的)。
來看看與 set 元素等價的自定義 trim 元素吧:
<trim prefix="SET" suffixOverrides=","> ... </trim>
注意,我們覆蓋了字尾值設定,並且自定義了字首值。
動態 SQL 的另一個常見使用場景是對集合進行遍歷(尤其是在構建 IN 條件語句的時候)。比如:
<select id="selectPostIn" resultType="domain.blog.Post"> SELECT * FROM POST P WHERE ID in <foreach item="item" index="index" collection="list" open="(" separator="," close=")"> #{item} </foreach> </select>
foreach 元素的功能非常強大,它允許你指定一個集合,宣告可以在元素體內使用的集合項(item)和索引(index)變數。它也允許你指定開頭與結尾的字串以及集合項迭代之間的分隔符。這個元素也不會錯誤地新增多餘的分隔符,看它多智慧!
提示:你可以將任何可迭代物件(如 List、Set 等)、Map 物件或者陣列物件作為集合引數傳遞給 foreach。當使用可迭代物件或者陣列時,index 是當前迭代的序號,item 的值是本次迭代獲取到的元素。當使用 Map 物件(或者 Map.Entry 物件的集合)時,index 是鍵,item 是值。
至此,我們已經完成了與 XML 設定及對映檔案相關的討論。下一章將詳細探討 Java API,以便你能充分利用已經建立的對映設定。
要在帶註解的對映器介面類中使用動態 SQL,可以使用 script 元素。比如:
@Update({"<script>", "update Author", " <set>", " <if test='username != null'>username=#{username},</if>", " <if test='password != null'>password=#{password},</if>", " <if test='email != null'>email=#{email},</if>", " <if test='bio != null'>bio=#{bio}</if>", " </set>", "where id=#{id}", "</script>"}) void updateAuthorValues(Author author);
bind 元素允許你在 OGNL 表示式以外建立一個變數,並將其繫結到當前的上下文。比如:
<select id="selectBlogsLike" resultType="Blog"> <bind name="pattern" value="'%' + _parameter.getTitle() + '%'" /> SELECT * FROM BLOG WHERE title LIKE #{pattern} </select>
如果設定了 databaseIdProvider,你就可以在動態程式碼中使用名為 「_databaseId」 的變數來為不同的資料庫構建特定的語句。比如下面的例子:
<insert id="insert"> <selectKey keyProperty="id" resultType="int" order="BEFORE"> <if test="_databaseId == 'oracle'"> select seq_users.nextval from dual </if> <if test="_databaseId == 'db2'"> select nextval for seq_users from sysibm.sysdummy1" </if> </selectKey> insert into users values (#{id}, #{name}) </insert>
MyBatis 從 3.2 版本開始支援插入指令碼語言,這允許你插入一種語言驅動,並基於這種語言來編寫動態 SQL 查詢語句。
可以通過實現以下介面來插入一種語言:
public interface LanguageDriver { ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql); SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType); SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType); }
實現自定義語言驅動後,你就可以在 mybatis-config.xml 檔案中將它設定為預設語言:
<typeAliases> <typeAlias type="org.sample.MyLanguageDriver" alias="myLanguage"/> </typeAliases> <settings> <setting name="defaultScriptingLanguage" value="myLanguage"/> </settings>
或者,你也可以使用 lang 屬性為特定的語句指定語言:
<select id="selectBlog" lang="myLanguage"> SELECT * FROM BLOG </select>
或者,在你的 mapper 介面上新增 @Lang 註解:
public interface Mapper { @Lang(MyLanguageDriver.class) @Select("SELECT * FROM BLOG") List<Blog> selectBlog(); }
提示:可以使用 Apache Velocity 作為動態語言,更多細節請參考 MyBatis-Velocity 專案。
你前面看到的所有 xml 標籤都由預設 MyBatis 語言提供,而它由語言驅動 org.apache.ibatis.scripting.xmltags.XmlLanguageDriver(別名為 xml)所提供。
我們在使用mybatis的時候,會在xml中編寫sql語句。比如這段動態sql程式碼:
<update id="update" parameterType="org.format.dynamicproxy.mybatis.bean.User"> UPDATE users <trim prefix="SET" prefixOverrides=","> <if test="name != null and name != ''"> name = #{name} </if> <if test="age != null and age != ''"> , age = #{age} </if> <if test="birthday != null and birthday != ''"> , birthday = #{birthday} </if> </trim> where id = ${id} </update>
mybatis底層是如何構造這段sql的?下面帶著這個疑問,我們一步一步分析。
SqlNode介面,簡單理解就是xml中的每個標籤,比如上述sql的update,trim,if標籤:
public interface SqlNode { boolean apply(DynamicContext context); }
SqlSource Sql源介面,代表從xml檔案或註解對映的sql內容,主要就是用於建立BoundSql,有實現類DynamicSqlSource(動態Sql源),StaticSqlSource(靜態Sql源)等:
public interface SqlSource { BoundSql getBoundSql(Object parameterObject); }
BoundSql類,封裝mybatis最終產生sql的類,包括sql語句,引數,引數源資料等引數:
XNode,一個Dom API中的Node介面的擴充套件類:
BaseBuilder介面及其實現類(屬性,方法省略了,大家有興趣的自己看),這些Builder的作用就是用於構造sql:
下面我們簡單分析下其中4個Builder:
LanguageDriver介面及其實現類(屬性,方法省略了,大家有興趣的自己看),該介面主要的作用就是構造sql:
簡單分析下XMLLanguageDriver(處理xml中的sql,RawLanguageDriver處理靜態sql):XMLLanguageDriver內部會使用XMLScriptBuilder解析xml中的sql部分。
Spring與Mybatis整合的時候需要設定SqlSessionFactoryBean,該設定會加入資料來源和mybatis xml組態檔路徑等資訊:
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatisConfig.xml"/> <property name="mapperLocations" value="classpath*:org/format/dao/*.xml"/> </bean>
我們就分析這一段設定背後的細節:
SqlSessionFactoryBean實現了Spring的InitializingBean介面,InitializingBean介面的afterPropertiesSet方法中會呼叫buildSqlSessionFactory方法 該方法內部會使用XMLConfigBuilder解析屬性configLocation中設定的路徑,還會使用XMLMapperBuilder屬性解析mapperLocations屬性中的各個xml檔案。部分原始碼如下:
由於XMLConfigBuilder內部也是使用XMLMapperBuilder,我們就看看XMLMapperBuilder的解析細節:
我們關注一下,增刪改查節點的解析:
XMLStatementBuilder的解析:
預設會使用XMLLanguageDriver建立SqlSource(Configuration建構函式中設定)。
XMLLanguageDriver建立SqlSource:
XMLScriptBuilder解析sql:
得到SqlSource之後,會放到Configuration中,有了SqlSource,就能拿BoundSql了,BoundSql可以得到最終的sql。
以下面的xml解析大概說下parseDynamicTags的解析過程:
<update id="update" parameterType="org.format.dynamicproxy.mybatis.bean.User"> UPDATE users <trim prefix="SET" prefixOverrides=","> <if test="name != null and name != ''"> name = #{name} </if> <if test="age != null and age != ''"> , age = #{age} </if> <if test="birthday != null and birthday != ''"> , birthday = #{birthday} </if> </trim> where id = ${id} </update>
parseDynamicTags方法的返回值是一個List,也就是一個Sql節點集合。SqlNode本文一開始已經介紹,分析完解析過程之後會說一下各個SqlNode型別的作用。
首先根據update節點(Node)得到所有的子節點,分別是3個子節點:
遍歷各個子節點:
遇到子節點是元素的話,重複以上步驟:
trim子節點內部有7個子節點,分別是文位元組點、if節點、是文位元組點、if節點、是文位元組點、if節點、文位元組點。文位元組點跟之前一樣處理,if節點使用IfHandler處理。遍歷步驟如上所示,下面我們看下幾個Handler的實現細節。
IfHandler處理方法也是使用parseDynamicTags方法,然後加上if標籤必要的屬性:
private class IfHandler implements NodeHandler { public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) { List<SqlNode> contents = parseDynamicTags(nodeToHandle); MixedSqlNode mixedSqlNode = new MixedSqlNode(contents); String test = nodeToHandle.getStringAttribute("test"); IfSqlNode ifSqlNode = new IfSqlNode(mixedSqlNode, test); targetContents.add(ifSqlNode); } }
TrimHandler處理方法也是使用parseDynamicTags方法,然後加上trim標籤必要的屬性:
private class TrimHandler implements NodeHandler { public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) { List<SqlNode> contents = parseDynamicTags(nodeToHandle); MixedSqlNode mixedSqlNode = new MixedSqlNode(contents); String prefix = nodeToHandle.getStringAttribute("prefix"); String prefixOverrides = nodeToHandle.getStringAttribute("prefixOverrides"); String suffix = nodeToHandle.getStringAttribute("suffix"); String suffixOverrides = nodeToHandle.getStringAttribute("suffixOverrides"); TrimSqlNode trim = new TrimSqlNode(configuration, mixedSqlNode, prefix, prefixOverrides, suffix, suffixOverrides); targetContents.add(trim); } }
以上update方法最終通過parseDynamicTags方法得到的SqlNode集合如下:
trim節點:
由於這個update方法是個動態節點,因此構造出了DynamicSqlSource。DynamicSqlSource內部就可以構造sql了:
DynamicSqlSource內部的SqlNode屬性是一個MixedSqlNode。然後我們看看各個SqlNode實現類的apply方法。下面分析一下各個SqlNode實現類的apply方法實現:
MixedSqlNode:MixedSqlNode會遍歷呼叫內部各個sqlNode的apply方法。
public boolean apply(DynamicContext context) { for (SqlNode sqlNode : contents) { sqlNode.apply(context); } return true; }
StaticTextSqlNode:直接append sql文字。
public boolean apply(DynamicContext context) { context.appendSql(text); return true; }
IfSqlNode:這裡的evaluator是一個ExpressionEvaluator型別的範例,內部使用了OGNL處理表示式邏輯。
public boolean apply(DynamicContext context) { if (evaluator.evaluateBoolean(test, context.getBindings())) { contents.apply(context); return true; } return false; }
TrimSqlNode:
public boolean apply(DynamicContext context) { FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context); boolean result = contents.apply(filteredDynamicContext); filteredDynamicContext.applyAll(); return result; } public void applyAll() { sqlBuffer = new StringBuilder(sqlBuffer.toString().trim()); String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH); if (trimmedUppercaseSql.length() > 0) { applyPrefix(sqlBuffer, trimmedUppercaseSql); applySuffix(sqlBuffer, trimmedUppercaseSql); } delegate.appendSql(sqlBuffer.toString()); } private void applyPrefix(StringBuilder sql, String trimmedUppercaseSql) { if (!prefixApplied) { prefixApplied = true; if (prefixesToOverride != null) { for (String toRemove : prefixesToOverride) { if (trimmedUppercaseSql.startsWith(toRemove)) { sql.delete(0, toRemove.trim().length()); break; } } } if (prefix != null) { sql.insert(0, " "); sql.insert(0, prefix); } } }
TrimSqlNode的apply方法也是呼叫屬性contents(一般都是MixedSqlNode)的apply方法,按照範例也就是7個SqlNode,都是StaticTextSqlNode和IfSqlNode。 最後會使用FilteredDynamicContext過濾掉prefix和suffix。