前言
看到这个标题读者们会觉得作者在老生常谈。这个面试中问烂了的问题,在百度一搜一大把答案,然而作者发现这些答案无非是如下:
- #{}是预编译处理,${}是字符串替换
- Mybatis在处理#{}时,会将sql中的#{}替换为?号,使用sql预编译处理
- Mybatis在处理时,就是把${}替换成变量的值
- 使用#{}可以有效的防止SQL注入,提高系统安全性
- 等等…
当然这些答案言简意赅,通俗易懂,但是作者在看到这些答案时,并没有一种满足感,作者想知道这些区别产生的原因(这应该是一篇技术文章的核心)。在解答这个问题时,脱离了原理的讲解,如同填鸭式一般。能给读者什么收益呢?应付面试或许勉强过关,对技术增益或许一点帮助也没有,因为读完这些博客你只得到了一个问题的答案而已,换种提问方式读者还会继续懵逼的。不信可以思考这个问题
statementType类型的选择,对带有#{}和${}的sql语句的执行有何影响?(这也算是两者的区别吧)
注:statementType类型有STATEMENT、CALLABLE、PREPARED
讲解
对于这个问题的答案作者不再累述。作者在此通过分析mybatis源码,让读者找到自己心目中的最佳答案。首先我们要知道mybatis是如何解析sql的,这就需要讲解下以下几个类,同时讲解所使用的样例如下:
<select id="getStudentBydId" resultMap="baseResultMao">
SELECT * FROM ${tableName} WHERE std_id = #{studentId}
</select>
<!-- 调用 getStudentById("ms_student",1); -->
1. XMLStatementBuilder
顾名思义,该类是从XML中构建statement,该类仅有一个公有方法,该方法是用来解析mapper映射文件中的<select>、<insert>、<update>、<delete>等标签的,代码如下:
public void parseStatementNode() {
// 获取Id
String id = context.getStringAttribute("id");
// 获得databaseId
String databaseId = context.getStringAttribute("databaseId");
if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
return;
}
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");
// 这个属性重点关注一下,在不配置时,默认值为PREPARED
StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);
String nodeName = context.getNode().getNodeName();
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 = 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;
}
builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
resultSetTypeEnum, flushCache, useCache, resultOrdered,
keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}
显而易见,<select>、<insert>、<update>、<delete>标签中含有sql语句,sql的解析调用的是下面这句代码:
SqlSource sqlSource =
langDriver.createSqlSource(configuration, context, parameterTypeClass);
我们是解析XML中sql,当然langDriver应该是XMLLanguageDriver
2. XMLLanguageDriver
XMLLanguageDriver中有两个createSqlSource方法,属于方法重载,现在看一下该类中的createSqlSource方法源码
/** createSqlSource方法1 **/
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
// issue #3
if (script.startsWith("<script>")) {
XPathParser parser = new XPathParser(script, false, configuration.getVariables(), new XMLMapperEntityResolver());
// 调用了createSqlSource方法
return createSqlSource(configuration, parser.evalNode("/script"), parameterType);
} else {
// issue #127
script = PropertyParser.parse(script, configuration.getVariables());
TextSqlNode textSqlNode = new TextSqlNode(script);
if (textSqlNode.isDynamic()) {
return new DynamicSqlSource(configuration, textSqlNode);
} else {
return new RawSqlSource(configuration, script, parameterType);
}
}
}
/** createSqlSource方法2和方法1的区别在于参数列表的不同 **/
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
return builder.parseScriptNode();
}
经上述代码分析,XMLLanguageDriver#createSqlSource调用的是XMLLanguageDriver中第二个createSqlSource方法,而此法中将SqlSource的创建委托给了XMLScriptBuilder
,看下XMLScriptBuilder.
3. XMLScriptBuilder
XMLScriptBuilder#parseScriptNode方法的源码如下:
public SqlSource parseScriptNode() {
// parseDynamicTags(context):
// 此处的context是由<select>、<insert>、<update>、<delete>
// 标签解析成的XNode对象
// 而parseDynamicTags方法则是处理该XNode对象
// 把其中包含的sql片段解析出来封装成SqlNode对象
MixedSqlNode rootSqlNode = parseDynamicTags(context);//见下文截图一
SqlSource sqlSource = null;
// 根据sql语句是否为动态sql,创建不同的SqlSource对象
if (isDynamic) {
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}
/** parseDynamicTags方法(见截图二) **/
protected MixedSqlNode parseDynamicTags(XNode node) {
// 创建一个空列表用于保存SqlNode
List<SqlNode> contents = new ArrayList<SqlNode>();
/* 此处使用DOM解析获得<select>标签的子节点
<select id="getStudentBydId" resultMap="baseResultMao">
SELECT * FROM ${tableName} WHERE std_id = #{studentId}
</select>(<select> 标签)
*/
NodeList children = node.getNode().getChildNodes();
// 循环解析<select>标签的子节点
// 由于我们的<select>标签比较简单,只有一个子节点
// <#text SELECT * FROM ${tableName} WHERE std_id = #{studentId} >
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
// 判断子节点是否为文本类型或者CDATA类型
// 此处子节点为文本类型
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE
|| child.getNode().getNodeType() == Node.TEXT_NODE) {
// data="\n SELECT * FROM ${tableName} WHERE std_id = #{studentId} \n"
String data = child.getStringBody("");
// 将data传入TextSqlNode,据此作为判读sql语句是否为
// 动态sql的一个标准(并不是唯一标准)
TextSqlNode textSqlNode = new TextSqlNode(data);
// TextSqlNode#isDynamic()方法解析见下文TextSqlNode类
if (textSqlNode.isDynamic()) {
// TextSqlNode是SqlNode的一个实现类,
// 此处将解析好的TextSqlNode放进方法开头的列表中
// 同时还告诉我们TextSqlNode与动态SQL有关
contents.add(textSqlNode);
isDynamic = true;
} else {
// 如果不是动态sql,则创建StaticTextSqlNode,
// 同时也放进方法开头的列表中
contents.add(new StaticTextSqlNode(data));
}
// 如果子节点是节点类型:如<if>、<where>、<when>等标签则
// 封装成对应的NodeHandler对象,做进一步处理,
// 在NodeHandler中handleNode方法中大都调用parseDynamicTags方法
// 由此可见此处形成一个递归调用,且递归结束的条件是
// 解析到子节点为text或CDATA类型,此时都会走上面的逻辑
// 先判读是否是动态sql,然后创建TextSqlNode或StaticSqlNode
} 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;
}
}
// 解析完成将SqlNode列表传入MixedSqlNode对象
return new MixedSqlNode(contents);
}
截图一
截图二
4. TextSqlNode
由于我们的sql比较简单,没有进入NodeHandler去解析,从上节中我们获知信息如下
- data="\n SELECT * FROM ${tableName} WHERE std_id = #{studentId} \n"
- TextSqlNode textSqlNode = new TextSqlNode(data);
- textSqlNode#isDynamic()
// 变量 text
private final String text;
// 构造方法1
public TextSqlNode(String text) {
this(text, null);
}
// 构造方法2
public TextSqlNode(String text, Pattern injectionFilter) {
this.text = text;
this.injectionFilter = injectionFilter;
}
// 此时text = "\n SELECT * FROM ${tableName} WHERE std_id = #{studentId} \n"
public boolean isDynamic() {
// DynamicCheckerTokenParser是TextSqlNode的内部类
DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
// 实例化一个GenericTokenParser的对象
GenericTokenParser parser = createParser(checker);
// 调用GenericTokenParser#parse方法,处理text
parser.parse(text);
return checker.isDynamic();
}
// createParser方法,创建了GenericTokenParser对象
private GenericTokenParser createParser(TokenHandler handler) {
// openToken = "${"
// closeToken = "}"
// handler = DynamicCheckerTokenParser
return new GenericTokenParser("${", "}", handler);
}
// 这个内部类比价简单
private static class DynamicCheckerTokenParser implements TokenHandler {
// 初始化后isDynamic = false
private boolean isDynamic;
public DynamicCheckerTokenParser() {
// Prevent Synthetic Access
}
// 返回是否为动态
public boolean isDynamic() {
return isDynamic;
}
// 这个方法没关于content的操作,只是用来给isDynamic赋值
// 只要调用过此方法 isDynamic就会被赋值为true
@Override
public String handleToken(String content) {
this.isDynamic = true;
return null;
}
}
经上述源码分析,获知isDynamic的判断是由DynamicCheckerTokenParser和GenericTokenParser共同作用的结果,DynamicCheckerTokenParser已经分析,只要调用过其handleToken方法,就会判断isDynamic为true,那么我们需要看一下在GenericTokenParser类中何时何地调用handleToken方法。
5. GenericTokenParser
这个类比较重要,mybatis中使用的比较多,解析#{param}、${param}都用到此类
public class GenericTokenParser {
// 开始标记符
private final String openToken;
// 结束标记符
private final String closeToken;
// 标记处理接口
// 具体的处理操作取决于它的实现类中的具体方法
private final TokenHandler handler;
// 构造函数
public GenericTokenParser(String openToken,
String closeToken,
TokenHandler handler) {
this.openToken = openToken;
this.closeToken = closeToken;
this.handler = handler;
}
public String parse(String text) {
// 文本空值判断
if (text == null || text.isEmpty()) {
return "";
}
// 获取开始标记符在文本中的位置
int start = text.indexOf(openToken, 0);
// //位置索引值为-1,说明不存在该开始标记符,直接返回文本
if (start == -1) {
return text;
}
// 将text转为char数组,便于后面通过”位置“操作
char[] src = text.toCharArray();
// 偏移量
int offset = 0;
// 用于存储解析后的text
final StringBuilder builder = new StringBuilder();
// 用于存储openToken和closeToken之间的字符串
StringBuilder expression = null;
while (start > -1) {
// 判断开始标记符前是否有转移字符,如果存在转义字符则移除转义字符
if (start > 0 && src[start - 1] == '\\') {
// 移除转义字符
builder.append(src, offset, start - offset - 1).append(openToken);
// 重新给偏移量赋值赋值
offset = start + openToken.length();
} else {
// 开始查找结束标记符
if (expression == null) {
expression = new StringBuilder();
} else {
expression.setLength(0);
}
builder.append(src, offset, start - offset);
offset = start + openToken.length();
// 结束标记符索引值
int end = text.indexOf(closeToken, offset);
while (end > -1) {
//同样判断标识符前是否有转义字符,有就移除
if (end > offset && src[end - 1] == '\\') {
// this close token is escaped. remove the backslash and continue.
expression.append(src, offset, end - offset - 1).append(closeToken);
// 重新计算偏移量
offset = end + closeToken.length();
// 重新计算结束标识符的索引值
end = text.indexOf(closeToken, offset);
} else {
expression.append(src, offset, end - offset);
offset = end + closeToken.length();
break;
}
}
// 没有找到结束标记符
if (end == -1) {
// close token was not found.
builder.append(src, start, src.length - start);
offset = src.length;
} else {
// 调用了TokenHandler#handleToken方法
// 找到了一组标记符,对该标记符进行值替换,替换的值由
// TokenHandler#handleToken方法产生
builder.append(handler.handleToken(expression.toString()));
offset = end + closeToken.length();
}
}
// 接着查找下一组标记符
start = text.indexOf(openToken, offset);
}
if (offset < src.length) {
builder.append(src, offset, src.length - offset);
}
// 返回处理后的字符串
return builder.toString();
}
}
// 整个过程可以理解为,根据给定的"开始标记符"和"结束标识符",遍历整个字符串,
// 寻找位于"开始标识符"和"结束标识符"之间的字符串("expression"),
// 调用TokenHandler#handleToken处理这些字符串("expression")
// 将处理后的字符串替换原来的("开始标识符""expression""结束标识符")
// 处理"expression"的细节由TokenHandler的具体的实现方法去做
以上节中数据为例,数据如下,分析GenericTokenParser#parse方法
- text = “SELECT * FROM ${tableName} WHERE std_id = #{studentId}”(为了方便叙述去除了空格和换行符)
- openToken = “${”
- closeToken = “}”
- token = DynamicCheckerTokenParser
按上述解析流程,由于text存在${tableName}匹配串,则处理位于"${“和”}“之间的"tableName”
调用DynamicCheckerTokenParser的handleToken方法
// content = "tableName"
public String handleToken(String content) {
this.isDynamic = true;
return null;
}
该方法将expression替换成了null(这不是重点),同时isDynamic被赋值为true,我们可以理解为只要sql中含有"${param}",该sql就属于动态sql,同时还有一个重要结论:含有"${param}"的sql片段将被解析成TextSqlNode!!
经过以上分析,我们也没找到任何关于#{}和${}的区别。别急,上面的铺垫只是让我们了解下mapper映射文件的解析流程和sql的解析的部分流程,以及GenericTokenParser的解析过程。通过判断是否为动态sql,决定我们创建DynamicSqlSource对象或者RawSqlSource对象。经分析我们的样例会被解析成动态sql,所以我们接着分析DynamicSqlSource
6. DynamicSqlSource
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;
}
@Override
// parameterObject对象为参数集合,见图三截图
public BoundSql getBoundSql(Object parameterObject) {
// 创建DynamicContext对象,此处不展开说该对象,
// 仅需知道该对象中有一个StringBuilder sqlBuilder变量
// sqlBuilder用于sql拼装
DynamicContext context = new DynamicContext(configuration, parameterObject);
// 调用SqlNode#apply()方法,
// 此时的rootSqlNode为XMLScriptBuilder#parseScriptNode解析出的MixedSqlNode
// MixedSqlNode#apply循环遍历内部List<SqlNode>并调用相应的apply方法
// 此时List<SqlNode>仅有一个元素TextSqlNode,分析过程见下文
rootSqlNode.apply(context);
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType =
parameterObject == null ? Object.class : parameterObject.getClass();
// 此处完成#{param}的替换,见下文源码分析SqlSourceBuilder#parse
// context.getSql()返回的是DynamicContext中sqlBuilder.toString().trim()
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(),
parameterType, context.getBindings());
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
}
return boundSql;
}
}
图三
图三是这个过程的debug信息,我们发现此时的${tableName}已被sm_student替换,替换的规则在TextSqlNode#apply中实现。
TextSqlNode#apply与${}
重点来了,下面就是${param}的替换过程!
public boolean apply(DynamicContext context) {
// 上文已经把GenericTokenParser的功能讲清楚
// 此时只需讲解BindingTokenParser#parse方法
GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
// 将解析完成的sql片段拼装到DynamicContext的sqlBuilder中
// 在此处完成${param}的替换
context.appendSql(parser.parse(text));
return true;
}
private GenericTokenParser createParser(TokenHandler handler) {
// 这里还是创建处理${param}类型的GenericTokenParser
return new GenericTokenParser("${", "}", handler);
}
private static class BindingTokenParser implements TokenHandler {
private DynamicContext context;
private Pattern injectionFilter;
public BindingTokenParser(DynamicContext context, Pattern injectionFilter) {
this.context = context;
this.injectionFilter = injectionFilter;
}
// 此处的content即${param}中的param,
// 此方法操作就是从参数集合里获得key=param对应的value
// 并返回该value
// 按照样例:content是tableName,对应的value就是sm_student
// 见图四截图
public String handleToken(String content) {
// 获得参数集合
Object parameter = context.getBindings().get("_parameter");
if (parameter == null) {
context.getBindings().put("value", null);
} else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
context.getBindings().put("value", parameter);
}
Object value = OgnlCache.getValue(content, context.getBindings());
// issue #274 return "" instead of "null"
String srtValue = (value == null ? "" : String.valueOf(value));
checkInjection(srtValue);
// 返回value,用于替换原来的key
return srtValue;
}
}
进过此步骤,${tableName}被替换成了"sm_student",由于是字符串拼接,在sql语句中直接拼接的是 sm_student ,是不含引号的。
图四
SqlSourceBuilder#parse 与 #{}
// originalSql="SELECT * FROM sm_students WHERE std_id = #{studentId}"
public SqlSource parse(String originalSql, Class<?> parameterType,
Map<String, Object> additionalParameters) {
ParameterMappingTokenHandler handler =
new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
// 同样的这里还是使用GenericTokenParser的parse方法,通过前文我们已经知道
// 此处功能是将#{param}替换成TokenHandler#handleToken
// 我们只需看一下此处的ParameterMappingTokenHandler#handleToken
GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
String sql = parser.parse(originalSql);
return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
// ParameterMappingTokenHandler是SqlSourceBuilder内部类
private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {
...省略
public String handleToken(String content) {
parameterMappings.add(buildParameterMapping(content));
// 到此就明白了了吧,#{param}直接被替换成了"?"
return "?";
}
...省略
}
到此#{}和${}替换原理作者已经从源码中做了解释。
预防sql注入
关于两者在预防sql注入的讨论:
- #{param}不仅仅涉及参数替换,还涉及参数类型的处理,这是${}不能代替的,也就是说使用${}来替换#{}本身就不符合mybatis的使用原则,所以两者并没有安全性比较的意义!
- #{param}只能用于statementType=“PREPARED"情况,因为#{param}在mybatis内部肯定会被替换成”?"的,这就要求必须使用PreparedStatement来处理,这是mybatis内部原理实现的,并不是很多博文所说的#{param}会加上"引号"云云…如果#{param}代表的是数字,mybatis断然不会给该数字加"引号"的。所以说#{}能有效预防sql注入是因为底层使用了PreparedStatement,而不是其他任何原因。
结语
作者通过源码分析了#{}和${}的处理过程,让读者从mybatis设计理念上理解两者的区别,同时纠正一些不准确的表达,让读者从真正意义上了解两者,而不是断章取义的只知道一个结果,不清楚其中过程。本文使用的sql相对简单,所以sql的生成过程也是比较简单,其实mybatis的sql生成所涉及的SqlNode解析处理是特别巧妙的。关注作者,作者将会在后续文章中讲解SqlNode相关知识。