"#{}"是将传入的值按照字符串的形式进行处理,如下面这条语句:
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对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: and 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语句进行动态标签的解析。以
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:
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注入。