百度360必应搜狗淘宝本站头条
当前位置:网站首页 > IT技术 > 正文

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

wptr33 2025-03-03 20:30 23 浏览

作者:京东保险 王奕龙

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

动态 SQL 执行流程

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

public final class MappedStatement {

    // sqlSource 存储 SQL 语句,区分静态、动态SQL
    private SqlSource sqlSource;
    
    public BoundSql getBoundSql(Object parameterObject) {
        BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
        // ...
    }

    // ...
}

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

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 类型,其中包含着各个节点的信息,如下所示:

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

public class WhereSqlNode extends TrimSqlNode {

    private static final List 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 方法进行增强,添加处理前缀和后缀标识符信息的逻辑,如下所示:

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 {
        // ...
    }
}

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

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

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 中的:

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;
    }

}

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

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

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 单测为例:

class SqlSessionTest extends BaseDataTest {
    @Test
    void dynamicTableName() {
        try (SqlSession session = sqlMapper.openSession()) {
            AuthorMapper mapper = session.getMapper(AuthorMapper.class);
            List author = mapper.selectDynamicTableName("author");
            assertEquals(2, author.size());
        }
    }
}
    

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

### 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 为:

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

所以任何要注入的 SQL 内容是不能影响到 SQL 语句的,保证了安全性。那么 $ 占位符是如何实现动态 SQL 拼接的呢?我们将 SQL 修改一下:

    

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

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

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 为:

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

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

巨人的肩膀

  • 百度百科 - OGNL

相关推荐

MySQL进阶五之自动读写分离mysql-proxy

自动读写分离目前,大量现网用户的业务场景中存在读多写少、业务负载无法预测等情况,在有大量读请求的应用场景下,单个实例可能无法承受读取压力,甚至会对业务产生影响。为了实现读取能力的弹性扩展,分担数据库压...

Postgres vs MySQL_vs2022连接mysql数据库

...

3分钟短文 | Laravel SQL筛选两个日期之间的记录,怎么写?

引言今天说一个细分的需求,在模型中,或者使用laravel提供的EloquentORM功能,构造查询语句时,返回位于两个指定的日期之间的条目。应该怎么写?本文通过几个例子,为大家梳理一下。学习时...

一文由浅入深带你完全掌握MySQL的锁机制原理与应用

本文将跟大家聊聊InnoDB的锁。本文比较长,包括一条SQL是如何加锁的,一些加锁规则、如何分析和解决死锁问题等内容,建议耐心读完,肯定对大家有帮助的。为什么需要加锁呢?...

验证Mysql中联合索引的最左匹配原则

后端面试中一定是必问mysql的,在以往的面试中好几个面试官都反馈我Mysql基础不行,今天来着重复习一下自己的弱点知识。在Mysql调优中索引优化又是非常重要的方法,不管公司的大小只要后端项目中用到...

MySQL索引解析(联合索引/最左前缀/覆盖索引/索引下推)

目录1.索引基础...

你会看 MySQL 的执行计划(EXPLAIN)吗?

SQL执行太慢怎么办?我们通常会使用EXPLAIN命令来查看SQL的执行计划,然后根据执行计划找出问题所在并进行优化。用法简介...

MySQL 从入门到精通(四)之索引结构

索引概述索引(index),是帮助MySQL高效获取数据的数据结构(有序),在数据之外,数据库系统还维护者满足特定查询算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构...

mysql总结——面试中最常问到的知识点

mysql作为开源数据库中的榜一大哥,一直是面试官们考察的重中之重。今天,我们来总结一下mysql的知识点,供大家复习参照,看完这些知识点,再加上一些边角细节,基本上能够应付大多mysql相关面试了(...

mysql总结——面试中最常问到的知识点(2)

首先我们回顾一下上篇内容,主要复习了索引,事务,锁,以及SQL优化的工具。本篇文章接着写后面的内容。性能优化索引优化,SQL中索引的相关优化主要有以下几个方面:最好是全匹配。如果是联合索引的话,遵循最...

MySQL基础全知全解!超详细无废话!轻松上手~

本期内容提醒:全篇2300+字,篇幅较长,可搭配饭菜一同“食”用,全篇无废话(除了这句),干货满满,可收藏供后期反复观看。注:MySQL中语法不区分大小写,本篇中...

深入剖析 MySQL 中的锁机制原理_mysql 锁详解

在互联网软件开发领域,MySQL作为一款广泛应用的关系型数据库管理系统,其锁机制在保障数据一致性和实现并发控制方面扮演着举足轻重的角色。对于互联网软件开发人员而言,深入理解MySQL的锁机制原理...

Java 与 MySQL 性能优化:MySQL分区表设计与性能优化全解析

引言在数据库管理领域,随着数据量的不断增长,如何高效地管理和操作数据成为了一个关键问题。MySQL分区表作为一种有效的数据管理技术,能够将大型表划分为多个更小、更易管理的分区,从而提升数据库的性能和可...

MySQL基础篇:DQL数据查询操作_mysql 查

一、基础查询DQL基础查询语法SELECT字段列表FROM表名列表WHERE条件列表GROUPBY分组字段列表HAVING分组后条件列表ORDERBY排序字段列表LIMIT...

MySql:索引的基本使用_mysql索引的使用和原理

一、索引基础概念1.什么是索引?索引是数据库表的特殊数据结构(通常是B+树),用于...