您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
由 Mybatis 源码畅谈软件设计(四):动态 SQL 执行流程
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
由 Mybatis 源码畅谈软件设计(四):动态 SQL 执行流程
wy****
2024-12-25
IP归属:北京
5浏览
本节我们探究动态 SQL 的执行流程,由于在前一节我们已经对各个组件进行了详细介绍,所以本节不再赘述相关内容,在本节中主要强调静态 SQL 和动态 SQL 执行的不同之处。在这个过程中,`SqlNode` 相关实现值得关注,它为动态 SQL 标签都定义了专用实现类,遵循单一职责的原则,并且应用了 **装饰器模式**。最后,我们还会讨论动态 SQL 避免注入的解决方案,它是在 Mybatis 中不可略过的一环。 ### 动态 SQL 执行流程 以单测 `org.apache.ibatis.session.SqlSessionTest#dynamicSqlParse` 为例,动态 SQL 执行查询时,第一个需要注意点是获取 `BoundSql` 对象: ```java public final class MappedStatement { // sqlSource 存储 SQL 语句,区分静态、动态SQL private SqlSource sqlSource; public BoundSql getBoundSql(Object parameterObject) { BoundSql boundSql = sqlSource.getBoundSql(parameterObject); // ... } // ... } ``` 在讲解 `MappedStatement` 时,我们提到了包含动态标签和 `$` 符号的 SQL 会被解析成 `DynamicSqlSource`,所以它在获取 `BoundSql` 时会执行如下逻辑: ```java 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; } public BoundSql getBoundSql(Object parameterObject) { // 创建动态 SQL 的上下文信息 DynamicContext context = new DynamicContext(configuration, parameterObject); // 根据上下文信息拼接 SQL,处理 SQL 中的动态标签 // 处理完成后 SQL 为不包含任何动态标签,为可能包含 #{} 占位符的 SQL 信息,SQL 会被封装到上下文的 sqlBuilder 对象中 rootSqlNode.apply(context); // 处理拼接完成后 SQL 中的 #{} 占位符,将占位符替换为 ? SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration); Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass(); // 解析完成后的 SqlSource 均为 StaticSqlSource 类型,其中记录解析完成后的完整 SQL SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings()); // StaticSqlSource 获取 BoundSql SQL 的方法就非常简单了:将 SQL 和参数信息记录下来 BoundSql boundSql = sqlSource.getBoundSql(parameterObject); // 在 BoundSql 对象中 additionalParameters Map 中添加 key 为 _parameter,value 为入参 的附加参数信息 context.getBindings().forEach(boundSql::setAdditionalParameter); return boundSql; } } ``` 首先它会创建动态 SQL 上下文信息 `DynamicContext`,这里并不复杂,所以不再追溯源码信息。`rootSqlNode` 对象在讲解映射配置时我们提到过,它会被解析成 `MixedSqlNode` 类型,其中包含着各个节点的信息,如下所示: ![sqlNode2.png](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-12-15-14-38UhT12l15on15cqarv2.png) `MixedSqlNode` 会根据上下文信息完成 `apply` 操作,如注释信息所述,最终会将带有动态标签的多个节点的 SQL 解析成一条 SQL 字符串记录在上下文中。下面我们重点看一下 **动态标签 <WHERE>** 的处理逻辑,它使用到了 **装饰器模式** 和 **静态代理模式**,`WhereSqlNode` 实现了 `TrimSqlNode`,但是它几乎并没有承载任何功能,只是定义了 SQL 连接符信息,这个实现类起到更多的作用是增强代码可读性和遵守单一职责的原则: ```java public class WhereSqlNode extends TrimSqlNode { private static final List<String> prefixList = Arrays.asList("AND ", "OR ", "AND\n", "OR\n", "AND\r", "OR\r", "AND\t", "OR\t"); public WhereSqlNode(Configuration configuration, SqlNode contents) { super(configuration, contents, "WHERE", prefixList, null, null); } } ``` 处理逻辑均在 `TrimSqlNode` 中实现,它在其中定义了 `SqlNode contents`,其中最重要的是 `apply` 方法,装饰器模式便体现在这里:它对组合进来的其他 `SqlNode` 的 `apply` 方法进行增强,添加处理前缀和后缀标识符信息的逻辑,如下所示: ```java public class TrimSqlNode implements SqlNode { private final SqlNode contents; @Override public boolean apply(DynamicContext context) { FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context); boolean result = contents.apply(filteredDynamicContext); // 处理前缀和后缀标识符信息 filteredDynamicContext.applyAll(); return result; } private class FilteredDynamicContext extends DynamicContext { // ... } } ``` ![WhereSqlNode.drawio.png](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-12-15-14-39I39WL7GAB152z0sNw.png) 实现处理前缀和后缀表示逻辑的 `FilteredDynamicContext` 是定义在 `TrimSqlNode` 中的内部类,它使用到了静态代理模式,在 Mybatis 框架中,出现 `delegate` 字段命名时,便需要对代理模式多留意了,而且这种命名也提醒我们,未来在使用到代理模式时,可以将被代理对象命名为 `delegate`。 `DynamicContext delegate` 对象被代理,由代理对象 `FilteredDynamicContext` 完成前后缀处理,最后将处理完的 SQL 拼接到原上下文中: ```java public class TrimSqlNode implements SqlNode { // ... private class FilteredDynamicContext extends DynamicContext { private final DynamicContext delegate; private boolean prefixApplied; private boolean suffixApplied; private StringBuilder sqlBuffer; public void applyAll() { sqlBuffer = new StringBuilder(sqlBuffer.toString().trim()); String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH); if (trimmedUppercaseSql.length() > 0) { // 处理前缀标识符比如,WHERE,SET applyPrefix(sqlBuffer, trimmedUppercaseSql); // 处理后缀标识符,一般用于自定义 TrimSqlNode applySuffix(sqlBuffer, trimmedUppercaseSql); } delegate.appendSql(sqlBuffer.toString()); } } } ``` 这段逻辑并不复杂,除此之外我们需要再关注下 `IfSqlNode` 的逻辑,探究 **IF 标签** 中的内容是如何被拼接到 SQL 中的: ```java public class IfSqlNode implements SqlNode { private final ExpressionEvaluator evaluator; private final String test; private final SqlNode contents; @Override public boolean apply(DynamicContext context) { // 判断表达式,如果 if 标签中 test 判断为 true 则将对应的 SQL 片段拼接到 SQL 上 if (evaluator.evaluateBoolean(test, context.getBindings())) { contents.apply(context); return true; } return false; } } ``` ![IfSqlNode.png](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-12-15-14-39rvdEtJfDiHz0mOR.png) 它会借助 **OGNL** 完成 test 表达式内容的判断,为 True 则会追加对应 SQL 信息。 接下来继续回到 `DynamicSqlSource#getBoundSql` 方法,将 `#{}` 占位符替换为 `?` 的逻辑在讲解映射配置时已讲过,不清楚的小伙伴可以再去了解一下,这部分内容没有特别需要关注的,了解下该方法的作用即可: ```java public class DynamicSqlSource implements SqlSource { // ... @Override public BoundSql getBoundSql(Object parameterObject) { // ... // 处理拼接完成后 SQL 中的 #{} 占位符,将占位符替换为 ? SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration); Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass(); // 解析完成后的 SqlSource 均为 StaticSqlSource 类型,其中记录解析完成后的完整 SQL SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings()); // StaticSqlSource 获取 BoundSql SQL 的方法就非常简单了:将 SQL 和参数信息记录下来 BoundSql boundSql = sqlSource.getBoundSql(parameterObject); // 在 BoundSql 对象中 additionalParameters Map 中添加 key 为 _parameter,value 为入参 的附加参数信息 context.getBindings().forEach(boundSql::setAdditionalParameter); return boundSql; } } ``` 到这里,带有动态标签的 SQL 已被处理成可能带有 `?` 占位符的 SQL 字符串了,后续逻辑与上一节中介绍 SQL 的执行流程没有区别,便不再赘述了。接下来我们讨论下 `#{}` 占位符是如何避免 SQL 注入的问题。 ### #{} 是如何解决 SQL 注入的? 我们已经了解到 `#{}` 占位符会被解析成 `?`,在 SQL 被执行时,由 JDBC 的 `PreparedStatement` 将对应的参数会绑定到对应的位置上,它并 **不是直接将内容拼接到 SQL 上**,注入的 SQL 内容将会 **被看作字符串处理**,它便是通过这种方式来避免 SQL 注入的。 以 `org.apache.ibatis.session.SqlSessionTest#dynamicTableName` 单测为例: ```java class SqlSessionTest extends BaseDataTest { @Test void dynamicTableName() { try (SqlSession session = sqlMapper.openSession()) { AuthorMapper mapper = session.getMapper(AuthorMapper.class); List<Author> author = mapper.selectDynamicTableName("author"); assertEquals(2, author.size()); } } } ``` ```sql <select id="selectDynamicTableName" parameterType="string" resultMap="selectAuthor"> select id, username, password, email, bio, favourite_section from #{tableName} </select> ``` 我们想使用 `#{}` 占位符动态替换表名,试验下能不能成功,结果控制台打印以下内容: ```text ### SQL: select id, username, password, email, bio, favourite_section from ? ### 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 为: ```sql select id, username, password, email, bio, favourite_section from 'author' ``` 所以任何要注入的 SQL 内容是不能影响到 SQL 语句的,保证了安全性。那么 `$` 占位符是如何实现动态 SQL 拼接的呢?我们将 SQL 修改一下: ```sql <select id="selectDynamicTableName" parameterType="string" resultMap="selectAuthor"> select id, username, password, email, bio, favourite_section from ${tableName} </select> ``` 先前我们提到过,包含 `$` 占位符的 SQL 也会被识别为动态 SQL(`SqlSource` 类型为 `DynamicSqlSource`),同样我们需要看一下它获取 `BoundSql` 的逻辑 `org.apache.ibatis.scripting.xmltags.DynamicSqlSource#getBoundSql`。在执行该方法时,可以发现整条 SQL 语句被解析为字符串保存在 `TextSqlNode` 中: ![$占位符的解析.png](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-12-15-14-40VDinO40bup9VEWnH.png) 我们继续看一下 `apply` 方法的逻辑,发现它会创建一个专门替换 `${}` 占位符 `GenericTokenParser` 解析器: ```java public class TextSqlNode implements SqlNode { // eg: select id, username, password, email, bio, favourite_section from ${tableName} private final String text; @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); } } ``` 这样它在执行 `GenericTokenParser#parser` 方法时,便会根据上下文信息 **将 `${}` 替换成参数直接拼接到 SQL 上**,最终 SQL 为: ```sql select id, username, password, email, bio, favourite_section from author ``` 它会直接 原 SQL 上进行拼接,所以会有 SQL 注入的风险,而且我们也能理解包含 `${}` 的 SQL 节点被命名为 `TextSqlNode` 的原因了,`Test` 便表示 SQL 会被解析为一段 SQL 的文本表达式。 ### 巨人的肩膀 - [百度百科 - OGNL](https://baike.baidu.com/item/OGNL/10365326)
上一篇:由 Mybatis 源码畅谈软件设计(七):从根上理解 Mybatis 一级缓存
下一篇:纯配时效服务-双Redis集群设计
wy****
文章数
32
阅读量
2571
作者其他文章
01
高性能MySQL实战(一):表结构
最近因需求改动新增了一些数据库表,但是在定义表结构时,具体列属性的选择有些不知其所以然,索引的添加也有遗漏和不规范的地方,所以我打算为创建一个高性能表的过程以实战的形式写一个专题,以此来学习和巩固这些知识。1. 实战我使用的 MySQL 版本是 5.7,建表 DDL 语句如下所示:根据需求创建 接口调用日志 数据库表,请大家浏览具体字段的属性信息,它们有不少能够优化的点。CREATE TABLE
01
分布式服务高可用实现:复制
1. 为什么需要复制我们可以考虑如下问题:当数据量、读取或写入负载已经超过了当前服务器的处理能力,如何实现负载均衡?希望在单台服务器出现故障时仍能继续工作,这该如何实现?当服务的用户遍布全球,并希望他们访问服务时不会有较大的延迟,怎么才能统一用户的交互体验?这些问题其实都能通过 “复制” 来解决:复制,即在不同的节点上保存相同的副本,提供数据冗余。如果一些节点不可用,剩余的节点仍然可以提供数据服务
01
高性能MySQL实战(三):性能优化
这篇主要介绍对慢 SQL 优化的一些手段,而在讲解具体的优化措施之前,我想先对 EXPLAIN 进行介绍,它是我们在分析查询时必要的操作,理解了它输出结果的内容更有利于我们优化 SQL。为了方便大家的阅读,在下文中规定类似 key1 的表示二级索引,key_part1 表示联合索引的第一部分,unique_key1 则表示唯一二级索引,primary_key 表示主键索引。高性能MySQL实战(一
01
从2PC和容错共识算法讨论zookeeper中的Create请求
最近在读《数据密集型应用系统设计》,其中谈到了zookeeper对容错共识算法的应用。这让我想到之前参考的zookeeper学习资料中,误将容错共识算法写成了2PC(两阶段提交协议),所以准备以此文对共识算法和2PC做梳理和区分,也希望它能帮助像我一样对这两者有误解的同学。1. 2PC(两阶段提交协议)两阶段提交 (two-phase commit) 协议是一种用于实现 跨多个节点的原子事务(分布
wy****
文章数
32
阅读量
2571
作者其他文章
01
高性能MySQL实战(一):表结构
01
分布式服务高可用实现:复制
01
高性能MySQL实战(三):性能优化
01
从2PC和容错共识算法讨论zookeeper中的Create请求
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号