由 Mybatis 源码畅谈软件设计(四):动态 SQL 执行流程

作者:京东保险 王奕龙

本节我们探究动态 SQL 的执行流程,由于在前一节我们已经对各个组件进行了详细介绍,所以本节不再赘述相关内容,在本节中主要强调静态 SQL 和动态 SQL 执行的不同之处。在这个过程中,SqlNode 相关实现值得关注,它为动态 SQL 标签都定义了专用实现类,遵循单一职责的原则,并且应用了 装饰器模式。最后,我们还会讨论动态 SQL 避免注入的解决方案,它是在 Mybatis 中不可略过的一环。

动态 SQL 执行流程

以单测 org.apache.ibatis.session.SqlSessionTest#dynamicSqlParse 为例,动态 SQL 执行查询时,第一个需要注意点是获取 BoundSql 对象:

1public final class MappedStatement { 2 3 // sqlSource 存储 SQL 语句,区分静态、动态SQL 4 private SqlSource sqlSource; 5 6 public BoundSql getBoundSql(Object parameterObject) { 7 BoundSql boundSql = sqlSource.getBoundSql(parameterObject); 8 // ... 9 } 10 11 // ... 12}

在讲解 MappedStatement 时,我们提到了包含动态标签和 $ 符号的 SQL 会被解析成 DynamicSqlSource,所以它在获取 BoundSql 时会执行如下逻辑:

1public class DynamicSqlSource implements SqlSource { 2 3 private final Configuration configuration; 4 private final SqlNode rootSqlNode; 5 6 public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) { 7 this.configuration = configuration; 8 this.rootSqlNode = rootSqlNode; 9 } 10 11 public BoundSql getBoundSql(Object parameterObject) { 12 // 创建动态 SQL 的上下文信息 13 DynamicContext context = new DynamicContext(configuration, parameterObject); 14 // 根据上下文信息拼接 SQL,处理 SQL 中的动态标签 15 // 处理完成后 SQL 为不包含任何动态标签,为可能包含 #{} 占位符的 SQL 信息,SQL 会被封装到上下文的 sqlBuilder 对象中 16 rootSqlNode.apply(context); 17 18 // 处理拼接完成后 SQL 中的 #{} 占位符,将占位符替换为 ? 19 SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration); 20 Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass(); 21 // 解析完成后的 SqlSource 均为 StaticSqlSource 类型,其中记录解析完成后的完整 SQL 22 SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings()); 23 // StaticSqlSource 获取 BoundSql SQL 的方法就非常简单了:将 SQL 和参数信息记录下来 24 BoundSql boundSql = sqlSource.getBoundSql(parameterObject); 25 // 在 BoundSql 对象中 additionalParameters Map 中添加 key 为 _parameter,value 为入参 的附加参数信息 26 context.getBindings().forEach(boundSql::setAdditionalParameter); 27 return boundSql; 28 } 29}

首先它会创建动态 SQL 上下文信息 DynamicContext,这里并不复杂,所以不再追溯源码信息。rootSqlNode 对象在讲解映射配置时我们提到过,它会被解析成 MixedSqlNode 类型,其中包含着各个节点的信息,如下所示:

在这里插入图片描述

MixedSqlNode 会根据上下文信息完成 apply 操作,如注释信息所述,最终会将带有动态标签的多个节点的 SQL 解析成一条 SQL 字符串记录在上下文中。下面我们重点看一下 动态标签 的处理逻辑,它使用到了 装饰器模式静态代理模式WhereSqlNode 实现了 TrimSqlNode,但是它几乎并没有承载任何功能,只是定义了 SQL 连接符信息,这个实现类起到更多的作用是增强代码可读性和遵守单一职责的原则:

1public class WhereSqlNode extends TrimSqlNode { 2 3 private static final List<String> prefixList = Arrays.asList("AND ", "OR ", "AND\n", "OR\n", "AND\r", "OR\r", "AND\t", 4 "OR\t"); 5 6 public WhereSqlNode(Configuration configuration, SqlNode contents) { 7 super(configuration, contents, "WHERE", prefixList, null, null); 8 } 9 10}

处理逻辑均在 TrimSqlNode 中实现,它在其中定义了 SqlNode contents,其中最重要的是 apply 方法,装饰器模式便体现在这里:它对组合进来的其他 SqlNodeapply 方法进行增强,添加处理前缀和后缀标识符信息的逻辑,如下所示:

1public class TrimSqlNode implements SqlNode { 2 3 private final SqlNode contents; 4 5 @Override 6 public boolean apply(DynamicContext context) { 7 FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context); 8 boolean result = contents.apply(filteredDynamicContext); 9 // 处理前缀和后缀标识符信息 10 filteredDynamicContext.applyAll(); 11 return result; 12 } 13 14 private class FilteredDynamicContext extends DynamicContext { 15 // ... 16 } 17}

在这里插入图片描述

实现处理前缀和后缀表示逻辑的 FilteredDynamicContext 是定义在 TrimSqlNode 中的内部类,它使用到了静态代理模式,在 Mybatis 框架中,出现 delegate 字段命名时,便需要对代理模式多留意了,而且这种命名也提醒我们,未来在使用到代理模式时,可以将被代理对象命名为 delegate

DynamicContext delegate 对象被代理,由代理对象 FilteredDynamicContext 完成前后缀处理,最后将处理完的 SQL 拼接到原上下文中:

1public class TrimSqlNode implements SqlNode { 2 // ... 3 4 private class FilteredDynamicContext extends DynamicContext { 5 private final DynamicContext delegate; 6 private boolean prefixApplied; 7 private boolean suffixApplied; 8 private StringBuilder sqlBuffer; 9 10 public void applyAll() { 11 sqlBuffer = new StringBuilder(sqlBuffer.toString().trim()); 12 String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH); 13 if (trimmedUppercaseSql.length() > 0) { 14 // 处理前缀标识符比如,WHERE,SET 15 applyPrefix(sqlBuffer, trimmedUppercaseSql); 16 // 处理后缀标识符,一般用于自定义 TrimSqlNode 17 applySuffix(sqlBuffer, trimmedUppercaseSql); 18 } 19 delegate.appendSql(sqlBuffer.toString()); 20 } 21 } 22 23}

这段逻辑并不复杂,除此之外我们需要再关注下 IfSqlNode 的逻辑,探究 IF 标签 中的内容是如何被拼接到 SQL 中的:

1public class IfSqlNode implements SqlNode { 2 private final ExpressionEvaluator evaluator; 3 private final String test; 4 private final SqlNode contents; 5 6 @Override 7 public boolean apply(DynamicContext context) { 8 // 判断表达式,如果 if 标签中 test 判断为 true 则将对应的 SQL 片段拼接到 SQL 上 9 if (evaluator.evaluateBoolean(test, context.getBindings())) { 10 contents.apply(context); 11 return true; 12 } 13 return false; 14 } 15 16}

在这里插入图片描述

它会借助 OGNL 完成 test 表达式内容的判断,为 True 则会追加对应 SQL 信息。

接下来继续回到 DynamicSqlSource#getBoundSql 方法,将 #{} 占位符替换为 ? 的逻辑在讲解映射配置时已讲过,不清楚的小伙伴可以再去了解一下,这部分内容没有特别需要关注的,了解下该方法的作用即可:

1public class DynamicSqlSource implements SqlSource { 2 // ... 3 4 @Override 5 public BoundSql getBoundSql(Object parameterObject) { 6 // ... 7 8 // 处理拼接完成后 SQL 中的 #{} 占位符,将占位符替换为 ? 9 SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration); 10 Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass(); 11 // 解析完成后的 SqlSource 均为 StaticSqlSource 类型,其中记录解析完成后的完整 SQL 12 SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings()); 13 // StaticSqlSource 获取 BoundSql SQL 的方法就非常简单了:将 SQL 和参数信息记录下来 14 BoundSql boundSql = sqlSource.getBoundSql(parameterObject); 15 // 在 BoundSql 对象中 additionalParameters Map 中添加 key 为 _parameter,value 为入参 的附加参数信息 16 context.getBindings().forEach(boundSql::setAdditionalParameter); 17 return boundSql; 18 } 19}

到这里,带有动态标签的 SQL 已被处理成可能带有 ? 占位符的 SQL 字符串了,后续逻辑与上一节中介绍 SQL 的执行流程没有区别,便不再赘述了。接下来我们讨论下 #{} 占位符是如何避免 SQL 注入的问题。

#{} 是如何解决 SQL 注入的?

我们已经了解到 #{} 占位符会被解析成 ?,在 SQL 被执行时,由 JDBC 的 PreparedStatement 将对应的参数会绑定到对应的位置上,它并 不是直接将内容拼接到 SQL 上,注入的 SQL 内容将会 被看作字符串处理,它便是通过这种方式来避免 SQL 注入的。

org.apache.ibatis.session.SqlSessionTest#dynamicTableName 单测为例:

1class SqlSessionTest extends BaseDataTest { 2 @Test 3 void dynamicTableName() { 4 try (SqlSession session = sqlMapper.openSession()) { 5 AuthorMapper mapper = session.getMapper(AuthorMapper.class); 6 List<Author> author = mapper.selectDynamicTableName("author"); 7 assertEquals(2, author.size()); 8 } 9 } 10}
<!---->
1 <select id="selectDynamicTableName" parameterType="string" resultMap="selectAuthor"> 2 select id, username, password, email, bio, favourite_section 3 from #{tableName} 4 </select>

我们想使用 #{} 占位符动态替换表名,试验下能不能成功,结果控制台打印以下内容:

1### SQL: select id, username, password, email, bio, favourite_section from ? 2### 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 修改一下:

1 <select id="selectDynamicTableName" parameterType="string" resultMap="selectAuthor"> 2 select id, username, password, email, bio, favourite_section 3 from ${tableName} 4 </select>

先前我们提到过,包含 $ 占位符的 SQL 也会被识别为动态 SQL(SqlSource 类型为 DynamicSqlSource),同样我们需要看一下它获取 BoundSql 的逻辑 org.apache.ibatis.scripting.xmltags.DynamicSqlSource#getBoundSql。在执行该方法时,可以发现整条 SQL 语句被解析为字符串保存在 TextSqlNode 中:

在这里插入图片描述

我们继续看一下 apply 方法的逻辑,发现它会创建一个专门替换 ${} 占位符 GenericTokenParser 解析器:

1public class TextSqlNode implements SqlNode { 2 // eg: select id, username, password, email, bio, favourite_section from ${tableName} 3 private final String text; 4 5 @Override 6 public boolean apply(DynamicContext context) { 7 GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter)); 8 context.appendSql(parser.parse(text)); 9 return true; 10 } 11 12 private GenericTokenParser createParser(TokenHandler handler) { 13 return new GenericTokenParser("${", "}", handler); 14 } 15 16}

这样它在执行 GenericTokenParser#parser 方法时,便会根据上下文信息 ${} 替换成参数直接拼接到 SQL 上,最终 SQL 为:

select id, username, password, email, bio, favourite_section from author

它会直接 原 SQL 上进行拼接,所以会有 SQL 注入的风险,而且我们也能理解包含 ${} 的 SQL 节点被命名为 TextSqlNode 的原因了,Test 便表示 SQL 会被解析为一段 SQL 的文本表达式。

巨人的肩膀

点赞
收藏

评论区

加载中...

相关推荐

深入理解MySQL索引底层数据结构

在日常工作中,我们会遇见一些慢SQL,在分析这些慢SQL时,我们通常会看下SQL的执行计划,验证SQL执行过程中有没有走索引。通常我们会调整一些查询条件,增加必要的索引,SQL执行效率就会提升几个数量级。我们有没有思考过,为什么加了索引就会能提高SQL的查询效率,为什么有时候加了索引SQL执行反而会没有变化,本文就从MySQL索引的底层数据结构和算法来进行详细分析。

一文带你搞懂如何优化慢SQL

最近通过SGM监控发现有两个SQL的执行时间占该任务总执行时间的90%,通过对该SQL进行分析和优化的过程中,又重新对SQL语句的执行顺序和SQL语句的执行计划进行了系统性的学习,整理的相关学习和总结如下;

Mabatis中#{}和${}的区别

动态sql是mybatis的主要特性之一,在mapper中定义的参数传到xml中之后,在查询之前mybatis会对其进行动态解析。mybatis为我们提供了两种支持动态sql的语法:{}以及${}。  在下面的语句中,如果username的值为zhangsan,则两种方式无任何区别:selectfr

MyBatis学习总结(11)——MyBatis动态Sql语句

MyBatis中对数据库的操作,有时要带一些条件,因此动态SQL语句非常有必要,下面就主要来讲讲几个常用的动态SQL语句的语法MyBatis中用于实现动态SQL的元素主要有:ifchoose(when,otherwise)trimwhereset

MyBatis动态SQL(认真看看, 以后写SQL就爽多了)

作者:阿进的写字台cnblogs.com/homejim/p/9909657.html温馨提示:文中代码看不全可左右滑动MyBatis令人喜欢的一大特性就是动态SQL。在使用JDBC的过程中,根据条件进行SQL的拼接是很麻烦且很容易出错的。MyBatis动态SQL的出现,解决了这个麻烦。MyBatis通过OGNL来进

Hibernate

J2EE开发中,特别是使用了Hibernate的项目,在开发阶段,有时候开发人员想看看程序执行的时候实际执行的SQL和动态SQL传入的参数情况,以调试和判断程序逻辑。本文总结下怎么实现,希望对你有用。~hibernate打开SQL显示这个比较简单,大多说人都知道,呵呵,配置如下:hibernate.show\_sqltruehibe