由 Mybatis 源碼暢談軟件設計(四):動態 SQL 執行流程
本節我們探究動態 SQL 的執行流程,由于在前一節我們已經對各個組件進行了詳細介紹,所以本節不再贅述相關內容,在本節中主要強調靜態 SQL 和動態 SQL 執行的不同之處。在這個過程中,SqlNode 相關實現值得關注,它為動態 SQL 標簽都定義了專用實現類,遵循單一職責的原則,并且應用了 裝飾器模式。最后,我們還會討論動態 SQL 避免注入的解決方案,它是在 Mybatis 中不可略過的一環。
動態 SQL 執行流程
以單測 org.apache.ibatis.session.SqlSessionTest#dynamicSqlParse 為例,動態 SQL 執行查詢時,第一個需要注意點是獲取 BoundSql 對象:
public final class MappedStatement {
// sqlSource 存儲 SQL 語句,區分靜態、動態SQL
private SqlSource sqlSource;
public BoundSql getBoundSql(Object parameterObject) {
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// ...
}
// ...
}
在講解 MappedStatement 時,我們提到了包含動態標簽和 $ 符號的 SQL 會被解析成 DynamicSqlSource,所以它在獲取 BoundSql 時會執行如下邏輯:
public class DynamicSqlSource implements SqlSource {
private final Configuration configuration;
private final SqlNode rootSqlNode;
public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) {
this.configuration = configuration;
this.rootSqlNode = rootSqlNode;
}
public BoundSql getBoundSql(Object parameterObject) {
// 創建動態 SQL 的上下文信息
DynamicContext context = new DynamicContext(configuration, parameterObject);
// 根據上下文信息拼接 SQL,處理 SQL 中的動態標簽
// 處理完成后 SQL 為不包含任何動態標簽,但可能包含 #{} 占位符的 SQL 信息,SQL 會被封裝到上下文的 sqlBuilder 對象中
rootSqlNode.apply(context);
// 處理拼接完成后 SQL 中的 #{} 占位符,將占位符替換為 ?
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
// 解析完成后的 SqlSource 均為 StaticSqlSource 類型,其中記錄解析完成后的完整 SQL
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
// StaticSqlSource 獲取 BoundSql SQL 的方法就非常簡單了:將 SQL 和參數信息記錄下來
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// 在 BoundSql 對象中 additionalParameters Map 中添加 key 為 _parameter,value 為入參 的附加參數信息
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}
}
首先它會創建動態 SQL 上下文信息 DynamicContext,這里并不復雜,所以不再追溯源碼信息。rootSqlNode 對象在講解映射配置時我們提到過,它會被解析成 MixedSqlNode 類型,其中包含著各個節點的信息,如下所示:

MixedSqlNode 會根據上下文信息完成 apply 操作,如注釋信息所述,最終會將帶有動態標簽的多個節點的 SQL 解析成一條 SQL 字符串記錄在上下文中。下面我們重點看一下 動態標簽 的處理邏輯,它使用到了 裝飾器模式 和 靜態代理模式,WhereSqlNode 實現了 TrimSqlNode,但是它幾乎并沒有承載任何功能,只是定義了 SQL 連接符信息,這個實現類起到更多的作用是增強代碼可讀性和遵守單一職責的原則:
public class WhereSqlNode extends TrimSqlNode {
private static final List<String> prefixList = Arrays.asList("AND ", "OR ", "AND\n", "OR\n", "AND\r", "OR\r", "AND\t",
"OR\t");
public WhereSqlNode(Configuration configuration, SqlNode contents) {
super(configuration, contents, "WHERE", prefixList, null, null);
}
}
處理邏輯均在 TrimSqlNode 中實現,它在其中定義了 SqlNode contents,其中最重要的是 apply 方法,裝飾器模式便體現在這里:它對組合進來的其他 SqlNode 的 apply 方法進行增強,添加處理前綴和后綴標識符信息的邏輯,如下所示:
public class TrimSqlNode implements SqlNode {
private final SqlNode contents;
@Override
public boolean apply(DynamicContext context) {
FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context);
boolean result = contents.apply(filteredDynamicContext);
// 處理前綴和后綴標識符信息
filteredDynamicContext.applyAll();
return result;
}
private class FilteredDynamicContext extends DynamicContext {
// ...
}
}

實現處理前綴和后綴表示邏輯的 FilteredDynamicContext 是定義在 TrimSqlNode 中的內部類,它使用到了靜態代理模式,在 Mybatis 框架中,出現 delegate 字段命名時,便需要對代理模式多留意了,而且這種命名也提醒我們,未來在使用到代理模式時,可以將被代理對象命名為 delegate。
DynamicContext delegate 對象被代理,由代理對象 FilteredDynamicContext 完成前后綴處理,最后將處理完的 SQL 拼接到原上下文中:
public class TrimSqlNode implements SqlNode {
// ...
private class FilteredDynamicContext extends DynamicContext {
private final DynamicContext delegate;
private boolean prefixApplied;
private boolean suffixApplied;
private StringBuilder sqlBuffer;
public void applyAll() {
sqlBuffer = new StringBuilder(sqlBuffer.toString().trim());
String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH);
if (trimmedUppercaseSql.length() > 0) {
// 處理前綴標識符比如,WHERE,SET
applyPrefix(sqlBuffer, trimmedUppercaseSql);
// 處理后綴標識符,一般用于自定義 TrimSqlNode
applySuffix(sqlBuffer, trimmedUppercaseSql);
}
delegate.appendSql(sqlBuffer.toString());
}
}
}
這段邏輯并不復雜,除此之外我們需要再關注下 IfSqlNode 的邏輯,探究 IF 標簽 中的內容是如何被拼接到 SQL 中的:
public class IfSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String test;
private final SqlNode contents;
@Override
public boolean apply(DynamicContext context) {
// 判斷表達式,如果 if 標簽中 test 判斷為 true 則將對應的 SQL 片段拼接到 SQL 上
if (evaluator.evaluateBoolean(test, context.getBindings())) {
contents.apply(context);
return true;
}
return false;
}
}

它會借助 OGNL 完成 test 表達式內容的判斷,為 True 則會追加對應 SQL 信息。
接下來繼續回到 DynamicSqlSource#getBoundSql 方法,將 #{} 占位符替換為 ? 的邏輯在講解映射配置時已講過,不清楚的小伙伴可以再去了解一下,這部分內容沒有特別需要關注的,了解下該方法的作用即可:
public class DynamicSqlSource implements SqlSource {
// ...
@Override
public BoundSql getBoundSql(Object parameterObject) {
// ...
// 處理拼接完成后 SQL 中的 #{} 占位符,將占位符替換為 ?
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
// 解析完成后的 SqlSource 均為 StaticSqlSource 類型,其中記錄解析完成后的完整 SQL
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
// StaticSqlSource 獲取 BoundSql SQL 的方法就非常簡單了:將 SQL 和參數信息記錄下來
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// 在 BoundSql 對象中 additionalParameters Map 中添加 key 為 _parameter,value 為入參 的附加參數信息
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}
}
到這里,帶有動態標簽的 SQL 已被處理成可能帶有 ? 占位符的 SQL 字符串了,后續邏輯與上一節中介紹 SQL 的執行流程沒有區別,便不再贅述了。接下來我們討論下 #{} 占位符是如何避免 SQL 注入的問題。
#{} 是如何解決 SQL 注入的?
我們已經了解到 #{} 占位符會被解析成 ?,在 SQL 被執行時,由 JDBC 的 PreparedStatement 將對應的參數會綁定到對應的位置上,它并 不是直接將內容拼接到 SQL 上,注入的 SQL 內容將會 被看作字符串處理,它便是通過這種方式來避免 SQL 注入的。
以 org.apache.ibatis.session.SqlSessionTest#dynamicTableName 單測為例:
class SqlSessionTest extends BaseDataTest {
@Test
void dynamicTableName() {
try (SqlSession session = sqlMapper.openSession()) {
AuthorMapper mapper = session.getMapper(AuthorMapper.class);
List<Author> author = mapper.selectDynamicTableName("author");
assertEquals(2, author.size());
}
}
}
<select id="selectDynamicTableName" parameterType="string" resultMap="selectAuthor">
select id, username, password, email, bio, favourite_section
from #{tableName}
</select>
我們想使用 #{} 占位符動態替換表名,試驗下能不能成功,結果控制臺打印以下內容:
### SQL: select id, username, password, email, bio, favourite_section from ?
### Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''author'' at line 2
發現它將表名參數作為字符串處理,實際執行的 SQL 為:
select id, username, password, email, bio, favourite_section from 'author'
所以任何要注入的 SQL 內容是不能影響到 SQL 語句的,保證了安全性。那么 $ 占位符是如何實現動態 SQL 拼接的呢?我們將 SQL 修改一下:
<select id="selectDynamicTableName" parameterType="string" resultMap="selectAuthor">
select id, username, password, email, bio, favourite_section
from ${tableName}
</select>
先前我們提到過,包含 $ 占位符的 SQL 也會被識別為動態 SQL(SqlSource 類型為 DynamicSqlSource),同樣我們需要看一下它獲取 BoundSql 的邏輯 org.apache.ibatis.scripting.xmltags.DynamicSqlSource#getBoundSql。在執行該方法時,可以發現整條 SQL 語句被解析為字符串保存在 TextSqlNode 中:

我們繼續看一下 apply 方法的邏輯,發現它會創建一個專門替換 ${} 占位符 GenericTokenParser 解析器:
public class TextSqlNode implements SqlNode {
// eg: select id, username, password, email, bio, favourite_section from ${tableName}
private final String text;
@Override
public boolean apply(DynamicContext context) {
GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
context.appendSql(parser.parse(text));
return true;
}
private GenericTokenParser createParser(TokenHandler handler) {
return new GenericTokenParser("${", "}", handler);
}
}
這樣它在執行 GenericTokenParser#parser 方法時,便會根據上下文信息 將 ${} 替換成參數直接拼接到 SQL 上,最終 SQL 為:
select id, username, password, email, bio, favourite_section from author
它會直接在原 SQL 上進行拼接,所以會有 SQL 注入的風險,而且我們也能理解包含 ${} 的 SQL 節點被命名為 TextSqlNode 的原因了,Text 便表示 SQL 會被解析為一段 SQL 的文本表達式。
浙公網安備 33010602011771號