首页 > 数据库 >由 Mybatis 源码畅谈软件设计(四):动态 SQL 执行流程

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

时间:2024-12-30 13:54:12浏览次数:1  
标签:private class 源码 BoundSql context SQL Mybatis public

作者:京东保险 王奕龙

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

sqlNode2.png

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


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 方法,装饰器模式便体现在这里:它对组合进来的其他 SqlNodeapply 方法进行增强,添加处理前缀和后缀标识符信息的逻辑,如下所示:


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

实现处理前缀和后缀表示逻辑的 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;
    }

}

IfSqlNode.png

它会借助 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> author = mapper.selectDynamicTableName("author");
            assertEquals(2, author.size());
        }
    }
}

    <select id="selectDynamicTableName" parameterType="string" resultMap="selectAuthor">
        select id, username, password, email, bio, favourite_section
        from #{tableName}
    </select>

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


### 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 修改一下:


    <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

我们继续看一下 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 的文本表达式。

巨人的肩膀

标签:private,class,源码,BoundSql,context,SQL,Mybatis,public
From: https://www.cnblogs.com/Jcloud/p/18640853

相关文章

  • 计算机毕业设计 | SpringBoot+vue商业辅助决策系统 企业销售收支员工OA管理(附源码+论
    1,绪论1.1课题背景二十一世纪互联网的出现,改变了几千年以来人们的生活,不仅仅是生活物资的丰富,还有精神层次的丰富。在互联网诞生之前,地域位置往往是人们思想上不可跨域的鸿沟,信息的传播速度极慢,信息处理的速度和要求还是通过人们骑马或者是信鸽传递,这些信息传递都是不可控......
  • 计算机毕业设计 | SpringBoot+vue入校申报审批系统 大学高校教务管理(附源码+论文)
    1,绪论1.1研究背景现在大家正处于互联网加的时代,这个时代它就是一个信息内容无比丰富,信息处理与管理变得越加高效的网络化的时代,这个时代让大家的生活不仅变得更加地便利化,也让时间变得更加地宝贵化,因为每天的每分钟,每秒钟这些时间都能让人们处理大批量的日常事务,这些场景......
  • 更加便捷!开发陪玩系统源码,多账号登录功能,简化陪玩app注册流程
    在开发陪玩系统源码时,实现多账号统一登录功能可以提升用户体验,使用户能够更便捷地登录系统。以下是一些实现多账号统一登录的关键步骤和考虑因素:陪玩系统前后端演示请直接点击开发与实现第三方登录集成:与第三方平台(如微信、QQ、微博等)进行对接,获取API接口和必要的权限。在......
  • DVWA靶场File Inclusion (文件包含) 漏洞low(低),medium(中等),high(高),impossible(不可
    目录文件包含1.low远程文件包含本地文件包含源码审计2.medium源码审计3.high源码审计4.impossible源码审计文件包含文件包含漏洞(FileInclusionVulnerability)是一种常见的网络安全漏洞,主要出现在应用程序中不安全地处理文件路径时。攻击者可以利用此漏洞执......
  • Qt5.14.2 编译QtCipherSqlitePlugin ,_mm_aesimc_si128 (__m128i __X)报错解决
    1.在相同的cpu,相同版本的Qt5.14.2不同电脑下,编译由devbean/QtCipherSqlitePlugin作者处下载的源码。未修改任何东西的情况下,同事电脑一键即可编译成功!而本人则是尝试了很多次都无法编译成功!总是报错内联失败,目标特定选项不匹配等问题! 2.尝试多次后,在sqlitecipher.pro文件添......
  • debian11安装mysql-client
    1、进入下载页面MySQL::DownloadMySQLCommunityServer(ArchivedVersions)2、下载客户端相关的包cd/tmpwgetmysql-common_8.4.0-1debian11_amd64.debwgetmysql-community-client-plugins_8.4.0-1debian11_amd64.debwgetmysql-community-client-core_8.4.0-1debian......
  • Flink source API定期读取MySQL数据
    主类MyPeriodQueryDbSourceimportorg.apache.flink.api.connector.source.*;importorg.apache.flink.core.io.SimpleVersionedSerializer;importjava.util.Properties;/***定期读取数据source**@param<T>输出对象泛型*/publicclassMyPeriodQueryDbSource<......
  • python之django框架查询mysql数据库
    一、使用Django-admin创建Django项目1、cmd中执行以下命令django-adminstartprojectmysqlQuery2、用pycharm打开如下所示:3、右下角添加解释器4、安装Djangopipinstalldjango安装后如下所示:二、创建app1、创建名为user的模块pythonmanage.pystartappuser......
  • 开启了TDE下的mysql主从部署
    环境:OS:Centos7mysql:5.7.39 1.主库开启了TDEmysql>showvariableslike'%keyring%';+--------------------+------------------------------+|Variable_name|Value|+--------------------+------------------------------+......
  • OpenHarmony源码编译后烧录镜像教程,RK3566鸿蒙开发板演示
    本文介绍瑞芯微主板/开发板编译OpenHarmony源码后烧录镜像的教程,触觉智能PurplePiOH鸿蒙开发板演示。搭载了瑞芯微RK3566四核处理器,树莓派卡片电脑设计,支持开源鸿蒙OpenHarmony3.2-5.0系统,适合鸿蒙开发入门学习。编译源码后镜像路径编译完OpenHarmony源码后,会在以下路径,生成散......