關(guān)于Mybatis中SQL節(jié)點的深入解析
一、文章引出原因
某天在完成項目中的一個小功能后進行自測的時候,發(fā)現(xiàn)存在一個很奇怪的 bug --- 最終執(zhí)行的 SQL 與我所期望的 SQL 不一致,有一個 if 分支在我不傳特定參數(shù)的情況下被拼接在最終的 SQL 上。
①定義在 XML 文件中的 SQL 語句
<select id="balanceByUserIds" parameterType="xxx.BalanceReqVO"
resultType="xxx.Balance">
select * from balance
<where>
<if test="dataOrgCodes != null and dataOrgCodes.size > 0">
and data_org_code in
<foreach collection="dataOrgCodes" open="(" separator="," close=")" item="dataOrgCode">
#{dataOrgCode}
</foreach>
</if>
<if test="dataOrgCode != null and dataOrgCode != ''">
and data_org_code = #{dataOrgCode}
</if>
</where>
</select>②傳進來的參數(shù)
{
"dataOrgCodes":["6","2"]
}③Mybatis 打印執(zhí)行的 SQL
SELECT * FROM balance WHERE data_org_code IN (?, ?) AND data_org_code = ?
打印的執(zhí)行參數(shù)
{
"dataOrgCodes":["6","2"]
}二、存在的問題
學過 Mybatis 的人應(yīng)該一樣就看出來了,這個 SQL 不對勁,多了一些不該有的東西。按照我們的理解,最終的執(zhí)行的 SQL 應(yīng)該是
SELECT * FROM balance WHERE data_org_code IN (?, ?)
但 mybatis 執(zhí)行的 SQL 多了一點語句---AND data_org_code = ?
在出現(xiàn)這個問題后我反復(fù)進行 debug,確定了自己傳進來的參數(shù)沒有什么問題,也沒有什么攔截器添加多余的參數(shù)。
三、分析 SQL 生成過程
在確定編寫 XML 文件的 if 標簽的內(nèi)容以及傳進來的參數(shù)無誤后,排除了參數(shù)導(dǎo)致問題。那么除了這個可能外,問題就可能出現(xiàn)在 SQL 的解析上,也就是 SQL 的生成那里。那么我們定位到 SQL 的生成地方, DynamicSqlSource#getBoundSql(我們查詢的參數(shù)對象)方法
// Configuration是Mybatis核心類,rootSqlNode 根SQL節(jié)點是我們定義在XML中的SQL語句。
//(例如<select>rootSqlNode</sselect>, 標簽中間的內(nèi)容就是 rootSqlNode)
public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) {
this.configuration = configuration;
this.rootSqlNode = rootSqlNode;
}
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(configuration, parameterObject);
rootSqlNode.apply(context);
..............................
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}可以看到方法內(nèi)部顯示創(chuàng)建了一個 DynamicContext,這個對象就是用于存儲動態(tài)生成的 SQL。
(下面是省略了很多關(guān)于本次問題無關(guān)的代碼,只保留有關(guān)代碼)
public class DynamicContext {
public static final String PARAMETER_OBJECT_KEY = "_parameter";
public static final String DATABASE_ID_KEY = "_databaseId";
// 存儲動態(tài)生成的SQL,類似于 StringBuilder 的角色
private final StringJoiner sqlBuilder = new StringJoiner(" ");
// 唯一編號值,會在生成最終SQL和參數(shù)值映射關(guān)系的時候用到
private int uniqueNumber = 0;
// 拼接SQL
public void appendSql(String sql) {
sqlBuilder.add(sql);
}
// 獲取拼接好的SQL
public String getSql() {
return sqlBuilder.toString().trim();
}
// 獲取唯一編號,返回后進行加一
public int getUniqueNumber() {
return uniqueNumber++;
}
}而下一句就是解析我們編寫的 SQL,完成 SQL 的拼接
rootSqlNode.apply(context)
這里的 rootSqlNode 是我們編寫在標簽里的 SQL 內(nèi)容,包括<if>、<foreach>、<where>標簽等內(nèi)容。
rootSqlNode 對象是 SqlNode 類型。其實這里的 SQL 語句被解析成類似于 HTML 的 DOM 節(jié)點的樹級結(jié)構(gòu),在本節(jié)的測試例子中結(jié)構(gòu)類似如下(不完全正確,只做參考價值,表示 rootSqlNode 結(jié)構(gòu)類似于以下結(jié)構(gòu)):
<SqlNode>
select * from balance
<SqlNode>
where
<SqlNode>
and data_org_code in
<SqlNode>
#{dataOrgCode}
</SqlNode>
</SqlNode>
<SqlNode>
and data_org_code =
<SqlNode>
#{dataOrgCode}
</SqlNode>
</SqlNode>
</SqlNode>
</SqlNode>這個 SqlNode 定義如下所示:
public interface SqlNode {
boolean apply(DynamicContext context);
}里面的 apply 方法是用于評估是否把這個 SqlNode 的內(nèi)容拼接到最終返回的 SQL 上的,不同類型的 SqlNode 有不同的實現(xiàn),例如我們本節(jié)相關(guān)的 SqlNode 類型就是為 IfSqlNode,對應(yīng)這我們寫的 SQL 語句的 if 標簽,以及存儲最終的 sql 內(nèi)容的 StaticTextSqlNode 類型。
public class StaticTextSqlNode implements SqlNode {
// 存儲我們寫的 sql
// 類似于 and data_org_code in
private final String text;
public StaticTextSqlNode(String text) {
this.text = text;
}
@Override
public boolean apply(DynamicContext context) {
// 調(diào)用 DynamicContext 對象的 sqppendSql 方法拼接最終 sql
context.appendSql(text);
return true;
}
}public class IfSqlNode implements SqlNode {
// 評估器
private final ExpressionEvaluator evaluator;
// if標簽中用于判斷這個語句是否生效的 test 屬性值
// 這里對應(yīng)我們例子中的一個為 "dataOrgCodes != null and dataOrgCodes.size > 0"
private final String test;
// if標簽中的內(nèi)容,如果if標簽中不存在其他標簽,那么這里的值就是StaticTextSqlNode類型的節(jié)點
// StaticTextSqlNode 節(jié)點的 text 屬性就是我們最終需要拼接的 sql 語句
private final SqlNode contents;
// contents 是我們定義在 if 標簽里面的內(nèi)容, test 是 if 標簽的屬性 test 定義的內(nèi)容
public IfSqlNode(SqlNode contents, String test) {
this.test = test;
this.contents = contents;
this.evaluator = new ExpressionEvaluator();
}
@Override
public boolean apply(DynamicContext context) {
// 使用評估器評估 if 標簽中定義的 test 中的內(nèi)容是否為true
if (evaluator.evaluateBoolean(test, context.getBindings())) {
// 當contents為StaticTextSqlNode類型的節(jié)點時候,就把 if 標簽里的內(nèi)容拼接到 sql 上
// 否則繼續(xù)調(diào)用方法 apply(相當于遞歸調(diào)用,知道找到最下面的內(nèi)容節(jié)點)
contents.apply(context);
return true;
}
return false;
}
}我們可以看到這里的
evaluator.evaluateBoolean(test, context.getBindings())
這個評估方法是通過把 test 語句內(nèi)容和 我們傳進來的參數(shù)解析出來的 Map 進行比對,如果我們的參數(shù)中存在值,且值得內(nèi)容符合 test 語句的判斷,則進行 sql 語句的拼接。例如本次例子中的
<if test="userIds != null and userIds.size > 0">
and data_org_code in
<foreach collection="dataOrgCodes" open="(" separator="," close=")" item="dataOrgCode">
#{dataOrgCode}
</foreach>
</if>以及我們傳進來的參數(shù)進行比對
{
"dataOrgCodes":["6","2"]
}可以看得出來參數(shù)與 test 語句 "dataOrgCodes!= null and dataOrgCodes.size > 0" 比較是返回 true 的。
四、分析多余 SQL 的生成
根據(jù)上面的執(zhí)行步驟可以知道,我們的 bug 的產(chǎn)生是在
evaluator.evaluateBoolean(test, context.getBindings()) 這一步產(chǎn)生的。也就是在 context.getBindings() 中存在滿足 dataOrgCode != null and dataOrgCode != '' 的屬性。debug 驗證以下可知

可以看得出來,存儲參數(shù)映射的 Map 出現(xiàn)了 dataOrgCode 的屬性,但是我們傳遞進來的屬性只有 dataOrgCodes 數(shù)組,沒有 dataOrgCode 屬性,那這個 dataOrgCode 屬性是怎么來的?
再次從頭進行 debug 發(fā)現(xiàn)問題出現(xiàn)在 ForEachSqlNode 的 apply 方法里面

public boolean apply(DynamicContext context) {
// 獲取參數(shù)映射存儲Map
Map<String, Object> bindings = context.getBindings();
// 獲取bingdings中的parameter參數(shù),key為collectionExpression,也就是我們寫在標簽foreach 標簽的 collection 值里的內(nèi)容
// 根據(jù)collectionExpression從參數(shù)映射器中獲取到對應(yīng)的值, 本次的值為:["1","2"]
final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings,
Optional.ofNullable(nullable).orElseGet(configuration::isNullableOnForEach));
if (iterable == null || !iterable.iterator().hasNext()) {
return true;
}
// 第一個參數(shù)
boolean first = true;
// 再拼接sql里添加我們定義在 foreach 標簽的 open 值里的內(nèi)容
applyOpen(context);
// 遍歷的計數(shù)器
int i = 0;
// 遍歷我們傳進來的數(shù)組數(shù)據(jù) ["1","2"]
// o 表示我們本次遍歷數(shù)組中的值,例如 ”1“
for (Object o : iterable) {
DynamicContext oldContext = context;
if (first || separator == null) {
context = new PrefixedContext(context, "");
} else {
context = new PrefixedContext(context, separator);
}
int uniqueNumber = context.getUniqueNumber();
// 把 foreach 標簽的 index 值里的內(nèi)容作為 key,計數(shù)器的值 i 作為 value 存儲到 bingdings 中。
// 例如第一次循環(huán)就為("index",0)。注意:由于相同的key會被覆蓋住,所以最終存儲的為("index",userIds.length - 1)
// 同時生成一個 key 為 ITEM_PREFIX + index 值內(nèi)容 + "_" + uniqueNumber,value 為 uniqueNumber 存儲到 bingdings 中。
// 例如第一次循環(huán)就為("__frch_index_0",0)
applyIndex(context, i, uniqueNumber);
// 把 foreach 標簽的 item 值里的內(nèi)容作為 key,本次遍歷數(shù)組中的值作為 value 存儲到 bingdings 中。
// 例如第一次循環(huán)就為("userId","1")。注意:由于相同的key會被覆蓋住,所以最終存儲的為("index",userIds[userIds.length - 1])
// 同時生成一個 key 為 ITEM_PREFIX + item 值內(nèi)容 + "_" + uniqueNumber,value 為本次遍歷數(shù)組中的值存儲到 bingdings 中。
// 例如第一次循環(huán)就為("__frch_userId_0","1")
applyItem(context, o, uniqueNumber);
contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
if (first) {
first = !((PrefixedContext) context).isPrefixApplied();
}
context = oldContext;
// 計數(shù)器加一
i++;
}
// foreach 遍歷完,添加 foreach 標簽定義的 close 內(nèi)容
applyClose(context);
return true;
}從源碼可以知道,問題就出在遍歷 dataOrgCodes 這個數(shù)組上面。在執(zhí)行 apply 方法之中有
applyIndex(context, i, uniqueNumber);
applyItem(context, o, uniqueNumber);
#ForEachSqlNode
private void applyIndex(DynamicContext context, Object o, int i) {
if (index != null) {
context.bind(index, o);
context.bind(itemizeItem(index, i), o);
}
}
private void applyItem(DynamicContext context, Object o, int i) {
if (item != null) {
context.bind(item, o);
context.bind(itemizeItem(item, i), o);
}
}
#DynamicContext
public void bind(String name, Object value) {
bindings.put(name, value);
}從上面的邏輯中可以知道,在遍歷 dataOrgCodes 數(shù)組的時候,會把我們定義在 foreach 標簽中
item、index 屬性值作為 key 存儲在 DynamicContext 的 bingdings 中,也就是我們傳進來的查詢參數(shù)對象對應(yīng)的 Map 中,這就導(dǎo)致了雖然我們沒有傳進來 dataOrgCode 屬性,但是在執(zhí)行 dataOrgCodes 的 foreach 過程中產(chǎn)生了中間值 dataOrgCode,導(dǎo)致最終拼接的 SQL 出現(xiàn)了不該有的條件語句。

五、解決辦法
按道理我們使用的框架是 Mybatis 二次開發(fā)的(基本是 Mybatis),應(yīng)該不會有這么大的問題。所以在發(fā)現(xiàn)問題后在本地寫了一個 demo 進行復(fù)現(xiàn),發(fā)現(xiàn)本地的不會出現(xiàn)這個問題,頓時疑惑了。然后就去了 github 把 Mybatis 的源碼拉下來進行比較,最終發(fā)現(xiàn)了一些問題。


Mybatis 在 2017 年發(fā)現(xiàn)了問題并進行了修復(fù),在方法結(jié)尾處添加了移除本次 foreach 遍歷產(chǎn)生的中間值,也就是從參數(shù)映射 Map 中刪除了我們定義在 <foreach> 標簽的 item、index 定義的 key,這樣就不會產(chǎn)生本節(jié)的問題。
然而我所用的框架依然是沒有更新,用的還是 2012 年版本的代碼。所以為了解決這個問題,只能修改 foreach 標簽中的 item 的屬性值名稱,避免和 if 標簽的 test 中的屬性名稱沖突。也就是修改為以下的 SQL 代碼。

六、總結(jié)
使用二次開發(fā)的框架可能存在坑,需要注意引用的版本存在未解決問題。
到此這篇關(guān)于Mybatis中SQL節(jié)點的文章就介紹到這了,更多相關(guān)Mybatis中SQL節(jié)點解析內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
SpringSecurity詳解整合JWT實現(xiàn)全過程
JWT作為一個開放的標準(?RFC?7519?),定義了一種簡潔的,自包含的方法用于通信雙方之間以Json對象的形式安全的傳遞信息。接下來通過本文給大家介紹springSecurity+jwt實現(xiàn)互踢功能,需要的朋友可以參考下2022-07-07
Java 中的FileReader和FileWriter源碼分析_動力節(jié)點Java學院整理
本文給大家分享一段示例程序,通過示例代碼可以看出FileReader是基于InputStreamReader實現(xiàn)的,FileWriter是基于OutputStreamWriter實現(xiàn)的,具體程序代碼大家通過本文了解下吧2017-05-05
Spring中如何獲取request的方法匯總及其線程安全性分析
這篇文章主要給大家介紹了關(guān)于Spring中如何獲取request的方法匯總及其線程安全性分析的相關(guān)資料,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧。2018-04-04

