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

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

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

作者:京东保险 王奕龙

本节我们探究动态 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

相关推荐

吸顶大法 -- UWP中的工具栏吸顶的实现方式之一

如果一个页面中有很长的列表/内容,很多应用都会在用户向下滚动时隐藏页面的头,给用户留出更多的阅读空间,同时提供一个方便的吸顶工具栏,比如淘宝中的店铺页面。下面是一个比较简单的实现,如果有同学有更好的实...

C# ListView实现在日志中添加图片

ListView想在日志中添加图片,需要配合ImageList控件使用。1、在ListView控件中添加ImageList,视图选择Details2、添加列,有几张图片显示就添加几列ListViewI...

安卓里面优秀的第三方插件gitHub文件

pinned-section-listview这个列表是正确实施其他实现缺失很多功能。这些都是快速滚动页眉和页脚点击固定部分除此之外,它不产生任何不必要的视图、布局等很瘦。xUtils3...

android使用greendao来保存数据

有时我们的数据属于保存到数据库,对于Android应用和IOS应用,我们一般都会使用SQLite这个嵌入式的数据库作为我们保存数据的工具。由于我们直接操作数据库比较麻烦,而且管理起来也非常的麻烦,所以...

常见电脑桌面问题,你知多少

(一)桌面图标无法删除出现一些不认识的图标1.关闭正常运行且需要删除的程序;2.运行杀毒软件进行扫毒处理;3.系统欢迎或者重装系统;4.通过软件自带的卸载程序或者第三方软件(QQ管家、360)进行卸载...

独家|React Native 无限列表的优化与实践

导语本文介绍了在使用ReactNative开发过程中,如何对无限列表组件进行技术选型,如何使用RecyclerListView组件对无限列表进行性能优化,如何解决无限列表与标签页搭配使用时的内存...

深入浅出SlidingMenu

如果想直接查看源码的话可以从我的Github上下载查看:https://github.com/zhanghuijun0/demo-for-android/tree/master/SlidingMenu...

就问你酷不酷 定制自己的动态壁纸

虽然壁纸可以经常更换,但总是看着静态壁纸难免让人乏味。下面就教你如何设置动态壁纸,让你的桌面动起来炫起来。WindowsXP系统虽已退休多年,但在国内仍有大量的忠实用户,那我们就从它说起。其实Win...

安卓的ViewPager概述

一、ViewPager概述1、定义与用途:...

书评 | 9 年码龄工程师读 Android 经典

...

Flet 手机app界面设计,导航和路由,在多个界面之间自由跳转

前面的几篇文章,基本讲清楚了Flet界面设计,但都是“单个界面”。在实际项目中,肯定需要设计“多个界面”啊,多个界面直接怎么导航呢?也就是Flet的路由功能,这是Flet开发的必备技术。依然保...

Android指示器,轮播与循环轮播

AndroidUILibs之CircleIndicator1.说明CircleIndicator,顾名思义,圆形指示器,只一个可以用来做轮播的第三方库。2.配置在模块的build.gradle...

Shopee新手指南:Shopee卖家中心用户界面介绍

1.Shopee各站点前台网页链接:2.Shopee各站点后台网页链接3.ShopeeAPP下载:安卓版下载链接:https://pan.baidu.com/s/1eSp8M1k#list/path...

Django 官方推荐的姿势:类视图

作者:HelloGitHub-追梦人物在开发网站的过程中,有一些视图函数虽然处理的对象不同,但是其大致的代码逻辑是一样的。比如一个博客和一个论坛,通常其首页都是展示一系列的文章列表或者帖子列表。对处理...

ViewPager介绍和使用说明

ViewPager类提供了多界面切换的新效果。新效果有如下特征:...