MyBatis預編譯機制詳解
一. "#{}“和”${}"的區別
-
"#{}"是將傳入的值按照字符串的形式進行處理,如下面這條語句:
select user_id,user_name from t_user where user_id = #{user_id}
MyBaits會首先對其進行預編譯,將#{user_ids}替換成?佔位符,然後在執行時替換成實際傳入的user_id值,**並在兩邊加上單引號,以字符串方式處理。**下面是MyBatis執行日誌:
10:27:20.247 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==> Preparing: select id, user_name from t_user where id = ? 10:27:20.285 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==> Parameters: 1(Long)
因爲"#{}"會在傳入的值兩端加上單引號,所以可以很大程度上防止SQL注入。有關SQL注入的知識會在後文進行說明。因此在大多數情況下,建議使用"#{}"。
-
"${}"是做簡單的字符串替換,即將傳入的值直接拼接到SQL語句中,且不會自動加單引號。將上面的SQL語句改爲:
select user_id,user_name from t_user where user_id = ${user_id}
再觀察MyBatis的執行日誌:
10:41:32.242 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==> Preparing: select id, user_name, real_name, sex, mobile, email, note, position_id from t_user where id = 1 10:41:32.288 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==> Parameters:
可以看到,參數是直接替換的,且沒有單引號處理,這樣就有SQL注入的風險。
但是在一些特殊情況下,使用${}是更適合的方式,如表名、orderby等。見下面這個例子:
select user_id,user_name from ${table_name} where user_id = ${user_id}
這裏如果想要動態處理表名,就只能使用"${}",因爲如果使用"#{}",就會在表名字段兩邊加上單引號,變成下面這樣:
select user_id,user_name from 't_user' where user_id = ${user_id}
這樣SQL語句就會報錯。
二. MyBatis預編譯源碼分析
MyBatis對SQL語句解析的處理在XMLStatementBuilder類中,見源碼:
/**
* 解析mapper中的SQL語句
*/
public void parseStatementNode() {
//SQL語句id,對應着Mapper接口的方法
String id = context.getStringAttribute("id");
//校驗databaseId是否匹配
String databaseId = context.getStringAttribute("databaseId");
if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
return;
}
//SQL標籤屬性解析
Integer fetchSize = context.getIntAttribute("fetchSize");
Integer timeout = context.getIntAttribute("timeout");
String parameterMap = context.getStringAttribute("parameterMap");
String parameterType = context.getStringAttribute("parameterType");
Class<?> parameterTypeClass = resolveClass(parameterType); //參數類型
String resultMap = context.getStringAttribute("resultMap");
String resultType = context.getStringAttribute("resultType");
String lang = context.getStringAttribute("lang");
LanguageDriver langDriver = getLanguageDriver(lang);
Class<?> resultTypeClass = resolveClass(resultType); //結果類型
String resultSetType = context.getStringAttribute("resultSetType");
//Statement類型,默認PreparedStatement
StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);
String nodeName = context.getNode().getNodeName();
//SQL命令類型:增刪改查
SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
boolean useCache = context.getBooleanAttribute("useCache", isSelect);
boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);
// Include Fragments before parsing
XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
includeParser.applyIncludes(context.getNode());
// Parse selectKey after includes and remove them.
processSelectKeyNodes(id, parameterTypeClass, langDriver);
// Parse the SQL (pre: <selectKey> and <include> were parsed and removed)
//重要:解析SQL語句,封裝成一個SqlSource
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
String resultSets = context.getStringAttribute("resultSets");
String keyProperty = context.getStringAttribute("keyProperty");
String keyColumn = context.getStringAttribute("keyColumn");
KeyGenerator keyGenerator;
String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;
keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);
if (configuration.hasKeyGenerator(keyStatementId)) {
keyGenerator = configuration.getKeyGenerator(keyStatementId);
} else {
keyGenerator = context.getBooleanAttribute("useGeneratedKeys",
configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))
? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;
}
//解析完畢,最後通過MapperBuilderAssistant創建MappedStatement對象,統一保存到Configuration的mappedStatements屬性中
builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
resultSetTypeEnum, flushCache, useCache, resultOrdered,
keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}
前面是對SQL標籤的一些處理,如id、緩存、結果集映射等。我們這次主要分析預編譯機制,因此重點關注 SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass)這個方法。這該方法會通過LanguageDriver對SQL語句進行解析,生成一個SqlSource。SqlSource封裝了映射文件或者註解中定義的SQL語句,它不能直接交給數據庫執行,因爲裏面可能包含動態SQL或者佔位符等元素。而MyBatis在實際執行SQL語句時,會調用SqlSource的getBoundSql()方法獲取一個BoundSql對象,BoundSql是將SqlSource中的動態內容經過處理後,返回的實際可執行的SQL語句,其中包含?佔位符List封裝的有序的參數映射關係,此外還有一些額外信息標識每個參數的屬性名稱等。
LanguageDriver的默認實現類是XMLLanguageDriver,我們進入到這個方法裏面看下:
//創建SqlSource
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
//創建XMLScriptBuilder對象
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
//通過XMLScriptBuilder解析SQL腳本
return builder.parseScriptNode();
}
這裏通過XMLScriptBuilder對象的parseScriptNode()方法進行SQL腳本的解析,繼續跟進去:
/**
* 解析SQL腳本
*/
public SqlSource parseScriptNode() {
//解析動態標籤,包括動態SQL和${}。執行後動態SQL和${}已經被解析完畢。
//此時SQL語句中的#{}還沒有處理,#{}會在SQL執行時動態解析
MixedSqlNode rootSqlNode = parseDynamicTags(context);
//如果是dynamic的,則創建DynamicSqlSource,否則創建RawSqlSource
SqlSource sqlSource = null;
if (isDynamic) {
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}
parseScriptNode的功能就是判斷該SQL節點是否是動態的,然後根據是否動態返回DynamicSqlSource或
RawSqlSource。是否爲動態SQL的判斷在parseDynamicTags()方法中:
protected MixedSqlNode parseDynamicTags(XNode node) {
List<SqlNode> contents = new ArrayList<>();
NodeList children = node.getNode().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
//處理文本節點(SQL語句)
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
//把SQL封裝到TextSqlNode
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
//如果包含${},則是dynamic的
if (textSqlNode.isDynamic()) {
contents.add(textSqlNode);
isDynamic = true;
} else {
//除了${}外,其他的SQL都是靜態的
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
String nodeName = child.getNode().getNodeName();
NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
}
handler.handleNode(child, contents);
isDynamic = true;
}
}
return new MixedSqlNode(contents);
}
在這個方法中,會對SQL語句進行動態標籤的解析。以<select>標籤爲例,會獲取標籤中的文本節點(即具體的SQL語句),將其封裝成TextSqlNode,然後調用isDynamic()方法判斷是否爲動態標籤。那麼我們來看看這個方法:
public boolean isDynamic() {
DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
GenericTokenParser parser = createParser(checker);
parser.parse(text);
return checker.isDynamic();
}
這裏涉及一些底層的文本解析,這裏就不具體說明了,我們僅需看下createParser()這個方法:
private GenericTokenParser createParser(TokenHandler handler) {
return new GenericTokenParser("${", "}", handler);
}
這樣一來就明白了,該方法會創建一個以"${}“爲token的解析器GenericTokenParser,對指定的SQL語句進行解析,如果解析成功,說明語句中包含”${}",則將其標記爲動態SQL標籤。
如果是動態標籤,創建的SqlSource就是DynamicSqlSource,其獲取的BoundSql就是直接進行字符串的替換。對於非動態標籤,則創建RawSqlSource,對應?佔位符的SQL語句,如前文所述。
三. SQL注入問題
-
問題演示
前面說到了使用#{}可以有效防止SQL注入。那麼SQL注入到底是什麼呢?
考慮下面這個常見的場景:用戶登錄。根據前端傳過來的用戶名和密碼,去數據庫進行校驗,如果查到是有效用戶,則通知前端登錄成功。這個場景相信大家都經歷過。在數據庫會執行這樣一段SQL:
select * from users where username='admin' and password=md5('admin')
如果前端傳如正確的用戶名和密碼,可以登錄成功,這樣在正常情況下沒有問題。
那麼如果有人惡意攻擊,在用戶名框輸入了’or 1=1#,而密碼框隨意輸入,這個SQL語句就變爲:
select * from users where username='' or 1=1#' and password=md5('')
“#”在mysql中是註釋符,這樣"#"後面的內容將被mysql視爲註釋內容,就不會去執行了。換句話說,上面的SQL語句等價於:
select * from users where username='' or 1=1
由於1=1恆成立,因此SQL語句可以被進一步簡化爲:
select * from users
這樣一來,這段SQL語句可以執行成功,用戶就可以惡意登錄了。這樣就實現了簡單的SQL注入。
-
通過MyBatis預編譯防SQL注入
如前文所述,在MyBatis中,採用"${}“是簡單的字符串替換,肯定無法應對SQL注入。那麼”#{}"是怎樣解決SQL注入的呢?
將上面的查詢語句在MyBatis中實現爲:
select * from users where username=#{username} and password=md5(#{password})
這樣一來,當用戶再次輸入’or 1=1#,MyBatis執行SQL語句時會將其替換成:
select * from users where username=''or 1=1#' and password=md5('')
由於在兩端加了雙引號,因此輸入的內容就是一個普通字符串,其中的#註釋和or 1=1都不會生效,這樣就無法登陸成功了,從而有效防止了SQL注入。