文章目录
- 前言
- 8.5 动态SQL解析过程
- 8.5.1 SQL配置转换为SqlSource对象
- 8.5.2 SqlSource转换为静态SQL语句
- 8.6 #{}和${}的区别
- 8.7 小结
前言
在【MyBatis3源码深度解析(二十)动态SQL实现原理(一)动态SQL的核心组件】中研究了MyBatis动态SQL相关的组件,如SqlSource用于描述通过XML文件或Java注解配置的SQL信息,SqlNode用于描述动态SQL中的<if>、<where>等标签信息,LanguageDriver用于对SQL配置进行解析,将SQL配置转换为SqlSource对象。
研究了MyBatis动态SQL相关的组件,下面来研究一下动态SQL的解析过程。本文使用如下案例进行调试:
<!--UserMapper.xml-->
<select id="selectByCons" parameterType="User" resultType="User">
select * from user where id = '${id}'
<if test="name != null and name != ''">
and name = #{name}
</if>
<if test="age != null">
and age = #{age}
</if>
</select>
// ......
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
User user = new User();
user.setId(1);
user.setAge(18);
userMapper.selectByCons(user);
8.5 动态SQL解析过程
8.5.1 SQL配置转换为SqlSource对象
LanguageDriver用于对SQL配置进行解析,它其中一个实现类XMLLanguageDriver的createSqlSource()
方法就用于解析XML配置文件中的SQL配置。
源码1:org.apache.ibatis.scripting.xmltags.XMLLanguageDriver
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
return builder.parseScriptNode();
}
由 源码1 可知,在XMLLanguageDriver的createSqlSource()
方法中,XML配置文件中的SQL配置的解析实际上是委托给XMLScriptBuilder类来完成的,调用XMLScriptBuilder类的parseScriptNode()
方法来完成解析工作。
该方法的参数值,script参数即<select>标签对应的XNode对象:
源码2:org.apache.ibatis.scripting.xmltags.XMLScriptBuilder
private final XNode context;
private boolean isDynamic;
private final Class<?> parameterType;
public SqlSource parseScriptNode() {
// 将SQL信息对应的XNode对象转换为SqlNode对象
MixedSqlNode rootSqlNode = parseDynamicTags(context);
SqlSource sqlSource;
if (isDynamic) {
// 如果是动态SQL,则返回DynamicSqlSource实例
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
// 如果不是动态SQL,则返回RawSqlSource实例
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}
由 源码2 可知,在XMLScriptBuilder类的parseScriptNode()
方法中,首先调用parseDynamicTags()
方法将SQL信息对应的XNode对象转换为SqlNode对象,然后判定是否为动态SQL,如果是则返回DynamicSqlSource实例,否则返回RawSqlSource实例。
源码3:org.apache.ibatis.scripting.xmltags.XMLScriptBuilder
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));
// 如果子节点为文本节点,即非<if>、<where>等标签
// 则使用TextSqlNode描述子节点
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
if (textSqlNode.isDynamic()) {
// 如果是动态SQL,则将isDynamic属性设置为true
contents.add(textSqlNode);
isDynamic = true;
} else {
// 非动态SQL,只用StaticTextSqlNode描述子节点
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {
// 如果子节点为元素节点,即<if>、<where>等标签
// 则使用对应的NodeHandler进行处理
String nodeName = child.getNode().getNodeName();
NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
}
// 调用NodeHandler的handleNode()方法进行处理
handler.handleNode(child, contents);
// 元素节点一定是动态SQL
isDynamic = true;
}
}
return new MixedSqlNode(contents);
}
由 源码3 可知,在XMLScriptBuilder类的parseDynamicTags()
方法中,会对SQL配置对应的XNode对象的所有子节点进行遍历。
如果子节点为文本节点(即非标签元素),则使用TextSqlNode描述子节点,然后继续判断该SQL文本是否是动态SQL,若是则将isDynamic属性设置为true(源码2 中会根据该属性返回不同类型的SqlSource对象),若不是则转为使用StaticTextSqlNode描述子节点。
TextSqlNode对象的isDynamic()
方法用于判断SQL文本是否是动态SQL:
源码4:org.apache.ibatis.scripting.xmltags.TextSqlNode
public boolean isDynamic() {
DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
GenericTokenParser parser = createParser(checker);
parser.parse(text);
return checker.isDynamic();
}
private GenericTokenParser createParser(TokenHandler handler) {
return new GenericTokenParser("${", "}", handler);
}
由 源码4 可知,判断SQL文本是否是动态SQL需要借助GenericTokenParser类来完成。在createParser()
方法中,创建了一个GenericTokenParser对象并传递了"${"
和"}"
两个参数。
由此可以猜想,判断SQL文本是否是动态SQL的依据是SQL文本中是否包含"${"
和"}"
符号。(通过后文分析会发现这里猜想是对的)
借助Debug工具,可以查看到案例中TextSqlNode对象封装了select * from user where id = '${id}'
,并且它是动态SQL:
回到 源码3, 如果子节点为元素节点,即、等标签节点,则使用对应的NodeHandler进行处理。
在XMLScriptBuilder类内部,定义了一个私有的接口NodeHandler,并为每种动态SQL标签提供了一个NodeHandler接口的实现类,用于处理对应的动态SQL标签,转换为对应的SqlNode对象。
源码5:org.apache.ibatis.scripting.xmltags.XMLScriptBuilder
private interface NodeHandler {
void handleNode(XNode nodeToHandle, List<SqlNode> targetContents);
}
由 源码5 可知,NodeHandler接口只有一个handleNode()
方法,它接收两个参数:nodeToHandle参数是动态SQL标签对应的XNode对象,targetContents参数是存放SqlNode对象的List集合。该方法对XML标签进行解析后,把生成的SqlNode对象存放到该List集合中。
借助Debug,可以列出NodeHandler接口的8个实现类:
由图可知,每个实现类用于处理对应的动态SQL标签,例如IfHandler用于处理动态SQL配置中的<if>标签,将<if>标签的内容转换为IfSqlNode对象。
源码6:org.apache.ibatis.scripting.xmltags.XMLScriptBuilder
private class IfHandler implements NodeHandler {
public IfHandler() {
// Prevent Synthetic Access
}
@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
// 继续调用parseDynamicTags()方法处理<if>标签下的子节点
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
// 获取<if>标签的test属性
String test = nodeToHandle.getStringAttribute("test");
// 创建IfSqlNode对象并添加到List集合中
IfSqlNode ifSqlNode = new IfSqlNode(mixedSqlNode, test);
targetContents.add(ifSqlNode);
}
}
由 源码6 可知,在IfHandler的handleNode()
方法中,会继续调用XMLScriptBuilder类的parseDynamicTags()
方法完成<if>标签的子节点的解析,将子节点转换为MixedSqlNode对象,然后获取<if>标签的test属性,接着创建IfSqlNode对象并添加到List集合中。
这里使用了“递归”来完成动态SQL标签的解析。parseDynamicTags()
方法会获取当前节点的所有子节点,如果子节点为标签节点,则继续调用对应的NodeHandler进行处理,这就“递归”地完成了所有动态SQL标签的解析。
需要注意的是,在XMLScriptBuilder类的构造方法中,会调用initNodeHandlerMap()
方法将所有的NodeHandler实例注册到Map集合中:
源码7:org.apache.ibatis.scripting.xmltags.XMLScriptBuilder
public XMLScriptBuilder(Configuration configuration, XNode context, Class<?> parameterType) {
super(configuration);
this.context = context;
this.parameterType = parameterType;
initNodeHandlerMap();
}
private void initNodeHandlerMap() {
nodeHandlerMap.put("trim", new TrimHandler());
nodeHandlerMap.put("where", new WhereHandler());
nodeHandlerMap.put("set", new SetHandler());
nodeHandlerMap.put("foreach", new ForEachHandler());
nodeHandlerMap.put("if", new IfHandler());
nodeHandlerMap.put("choose", new ChooseHandler());
nodeHandlerMap.put("when", new IfHandler());
nodeHandlerMap.put("otherwise", new OtherwiseHandler());
nodeHandlerMap.put("bind", new BindHandler());
}
由 源码7 可知,提前注册好NodeHandler实例,在需要解析动态SQL标签时,只需要根据标签名获取对应的NodeHandler对象进行处理即可,而不需要每次都创建对应的NodeHandler实例,这是享元思想的应用。
经过以上逻辑,SQL配置已转换为对应的SqlSource对象。 案例中的SqlSource对象内容如下:
由图可知,由于是动态SQL,因此该SqlSource对象的类型是DynamicSqlSource。SQL配置被解析后,转换为5个SqlNode对象,包括1个TextSqlNode对象和2个IfSqlNode对象,剩余2个StaticSqlNode对象是换行符和空格。
8.5.2 SqlSource转换为静态SQL语句
SQL配置转换为SqlSource对象后,存放在MappedStatement对象的sqlSource属性中。
在【MyBatis3源码深度解析(十六)SqlSession的创建与执行(三)Mapper方法的调用过程】中提到,SELECT类型的Mapper方法的调用过程中,会调用BaseExecutor类的query()
方法,内部再转调MappedStatement对象的getBoundSql()
方法获取一个BoundSql对象。
源码8:org.apache.ibatis.mapping.MappedStatement
private SqlSource sqlSource;
public BoundSql getBoundSql(Object parameterObject) {
// 调用SqlSource对象的getBoundSql()方法获取BoundSql对象
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// ......
return boundSql;
}
由 源码8 可知,在MappedStatement对象的getBoundSql()
方法中,会调用SqlSource对象的getBoundSql()
方法获取一个BoundSql对象,而BoundSql对象内部封装了SQL语句及参数信息,这就完成了SqlSource对象到静态SQL语句的转换。
源码9:org.apache.ibatis.scripting.xmltags.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
public BoundSql getBoundSql(Object parameterObject) {
// 通过参数对象构建动态SQL上下文对象
DynamicContext context = new DynamicContext(configuration, parameterObject);
// 调用SqlNode对象的```apply()```方法
rootSqlNode.apply(context);
// 创建SqlSourceBuilder对象
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
// 调用SqlSourceBuilder对象的parse()方法对SQL内容做进一步的处理,返回一个StaticSqlSource对象
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
// 调用StaticSqlSource对象的getBoundSql()方法获取BoundSql对象
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// 将<bind>标签绑定的参数添加到BoundSql对象中
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}
}
由 源码9 可知,在DynamicSqlSource的getBoundSql()
方法中,会调用SqlNode对象的apply()
方法对动态SQL进行解析(解析过程详见【MyBatis3源码深度解析(二十)动态SQL实现原理(一)动态SQL的核心组件 8.4 SqlNode组件】)。
动态SQL解析完成后,接着调用SqlSourceBuilder对象的parse()
方法对SQL内容做进一步的处理,返回一个StaticSqlSource对象,StaticSqlSource对象用于描述动态SQL解析后的静态SQL信息。
最后,调用StaticSqlSource对象的getBoundSql()
方法获取BoundSql对象并返回。
源码10:org.apache.ibatis.builder.SqlSourceBuilder
public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
// 创建一个参数映射处理器,对SQL中的"#{}"参数占位符进行解析
ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType,
additionalParameters);
// 创建一个GenericTokenParser对象,对SQL中的"#{}"参数占位符进行解析
GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
String sql;
if (configuration.isShrinkWhitespacesInSql()) {
sql = parser.parse(removeExtraWhitespaces(originalSql));
} else {
sql = parser.parse(originalSql);
}
// 返回StaticSqlSource对象
return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
由 源码10 可知,在SqlSourceBuilder对象的parse()
方法中,创建了两个处理器:ParameterMappingTokenHandler对象是参数映射处理器,负责将SQL语句中的"#{}"
参数占位符进行转换;GenericTokenParser对象负责对SQL中的"#{}"参数占位符进行解析。
借助Debug工具,可以获取此时originalSql参数的值为:select * from user where id = '1' and age = #{age}
(去掉了一些不必要的空格和换行符)。
注意,到这一步会发现${id}
已经被替换成具体的参数值,是怎么替换的放到下文再解析。
下面就来分析一下GenericTokenParser对象的parse()
方法的原理:
源码11:org.apache.ibatis.parsing.GenericTokenParser
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 "";
}
// 获取第1个"#{"符号在SQL文本中的位置索引
int start = text.indexOf(openToken);
if (start == -1) {
// 位置索引为-1,说明不存在"#{"符号
return text;
}
// 将SQL文本转换为字符数组
char[] src = text.toCharArray();
// 用于记录已处理字符的偏移量
int offset = 0;
// 记录已确定的SQL文本
final StringBuilder builder = new StringBuilder();
// 记录"#{}"符号内的内容
StringBuilder expression = null;
do {
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] != '\\')) {
// 结束字符"}"的位置索引比当前偏移量大,且结束字符之前没有'\\'符号
// 说明确实是真的结束字符
// 将"#{}"符号内的内容记录到expression中
expression.append(src, offset, end - offset);
break;
}
// 结束字符之前有'\\'符号,说明已经注释掉了,则手动加一个结束字符
expression.append(src, offset, end - offset - 1).append(closeToken);
// 记录偏移量和结束字符的位置
offset = end + closeToken.length();
end = text.indexOf(closeToken, offset);
}
if (end == -1) {
// 没有找到结束字符,则把后面的字符全加入到builder
builder.append(src, start, src.length - start);
offset = src.length;
} else {
// 有结束字符,则调用TokenHandler的handleToken()方法处理"#{}"符号内的内容
builder.append(handler.handleToken(expression.toString()));
offset = end + closeToken.length();
}
}
// 将开始索引移动到新的位置
start = text.indexOf(openToken, offset);
} while (start > -1);
if (offset < src.length) {
builder.append(src, offset, src.length - offset);
}
return builder.toString();
}
}
由 源码11 可知,在GenericTokenParser对象的parse()
方法中,主要是对SQL语句中的所有#{}
参数占位符进行解析,获取参数占位符内的内容,并调用ParameterMappingTokenHandler对象的handleToken()
方法对参数占位符内容进行替换。
那#{}
参数占位符被替换成了什么呢?
源码12:org.apache.ibatis.builder.SqlSourceBuilder
private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {
@Override
public String handleToken(String content) {
parameterMappings.add(buildParameterMapping(content));
return "?";
}
}
由 源码12 可知,#{}
参数占位符最终被替换成了一个“?”。
借助Debug工具,确实发现originalSql参数的值由原来的select * from user where id = '1' and age = #{age}
转换为select * from user where id = '1' and age = ?
。
可想而知,#{}
参数占位符被替换成了一个“?”之后,就可以调用PreparedStatement对象的setXXX()
方法为参数占位符设置值了。
另外,在ParameterMappingTokenHandler对象的handleToken()
方法中,还调用了buildParameterMapping()
方法对占位符内容进行解析,即对javaType、jdbcType、property等参数进行解析。
至此,动态SQL的解析完成,最终获得的SQL语句是包含?
号的,后续会通过调用PreparedStatement对象的setXXX()
方法为?
号设置值。
8.6 #{}和${}的区别
在案例中,#{age}
最终被转换为?
,而${id}
直接被替换为具体的参数值。这两者的解析有什么区别呢?
前面已经详细研究了#{}
参数占位符的解析,下面重点看看${}
参数占位符的解析。
经过【8.5.1 SQL配置转换为SqlSource】的逻辑,SQL配置已转换为对应的SqlSource对象。 在该对象中,包括1个TextSqlNode对象,该对象描述的SQL文本是:select * from user where id = '${id}'
:
可见,对${}
参数占位符的解析在TextSqlNode对象的apply()
方法中完成。
源码13:org.apache.ibatis.scripting.xmltags.TextSqlNode
@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);
}
由 源码13 可知,在TextSqlNode对象的apply()
方法中,会转调GenericTokenParser对象的parse()
方法,但GenericTokenParser对象的openToken属性值为${
,closeToken属性值为}
,TokenHandler为BindingTokenParser对象。
而parse()
方法的逻辑与 源码11 的分析是一致的,不同的是openToken、closeToken属性值不一样,最终会调用BindingTokenParser对象的handleToken()
方法对${}
参数占位符进行处理。(这里也足以证明判断SQL文本是否是动态SQL的依据是SQL文本中是否包含"${"
和"}"
符号)
源码14:org.apache.ibatis.scripting.xmltags.TextSqlNode
private static class BindingTokenParser implements TokenHandler {
@Override
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);
}
// 通过OGNL表达式获取参数值
Object value = OgnlCache.getValue(content, context.getBindings());
String srtValue = value == null ? "" : String.valueOf(value);
checkInjection(srtValue);
// 返回参数值
return srtValue;
}
}
由 源码14 可知,与ParameterMappingTokenHandler对象的handleToken()
方法返回一个?
号不同,BindingTokenParser对象的handleToken()
方法返回了具体的参数值。这也就解释了${}
参数占位符会直接被替换为具体的参数值。
总结一下,使用#{}
参数占位符时,占位符内容会被替换成?
,然后通过PreparedStatement对象的setXXX()
方法为参数占位符设置值;而${}
参数占位符内容会被直接替换为具体的参数值。
使用#{}
参数占位符能够有效避免SQL注入问题,在实际开发中应优先考虑,当其无法满足要求时才考虑使用${}
参数占位符。
8.7 小结
第八章到此就梳理完毕了,本章的主题是:动态SQL实现原理。回顾一下本章的梳理的内容:
(二十)SqlSource、BoundSql、LanguageDriver、SqlNode组件
(二十一)动态SQL解析过程、#{}和${}的区别
更多内容请查阅分类专栏:MyBatis3源码深度解析
第九章主要学习:MyBatis插件原理及应用。主要内容包括:
- MyBatis插件实现原理;
- 自定义一个分页插件;
- 自定义慢SQL统计插件。