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

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

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

作者:京东保险 王奕龙

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

相关推荐

oracle数据导入导出_oracle数据导入导出工具

关于oracle的数据导入导出,这个功能的使用场景,一般是换服务环境,把原先的oracle数据导入到另外一台oracle数据库,或者导出备份使用。只不过oracle的导入导出命令不好记忆,稍稍有点复杂...

继续学习Python中的while true/break语句

上次讲到if语句的用法,大家在微信公众号问了小编很多问题,那么小编在这几种解决一下,1.else和elif是子模块,不能单独使用2.一个if语句中可以包括很多个elif语句,但结尾只能有一个else解...

python continue和break的区别_python中break语句和continue语句的区别

python中循环语句经常会使用continue和break,那么这2者的区别是?continue是跳出本次循环,进行下一次循环;break是跳出整个循环;例如:...

简单学Python——关键字6——break和continue

Python退出循环,有break语句和continue语句两种实现方式。break语句和continue语句的区别:break语句作用是终止循环。continue语句作用是跳出本轮循环,继续下一次循...

2-1,0基础学Python之 break退出循环、 continue继续循环 多重循

用for循环或者while循环时,如果要在循环体内直接退出循环,可以使用break语句。比如计算1至100的整数和,我们用while来实现:sum=0x=1whileTrue...

Python 中 break 和 continue 傻傻分不清

大家好啊,我是大田。今天分享一下break和continue在代码中的执行效果是什么,进一步区分出二者的区别。一、continue例1:当小明3岁时不打印年龄,其余年龄正常循环打印。可以看...

python中的流程控制语句:continue、break 和 return使用方法

Python中,continue、break和return是控制流程的关键语句,用于在循环或函数中提前退出或跳过某些操作。它们的用途和区别如下:1.continue(跳过当前循环的剩余部分,进...

L017:continue和break - 教程文案

continue和break在Python中,continue和break是用于控制循环(如for和while)执行流程的关键字,它们的作用如下:1.continue:跳过当前迭代,...

作为前端开发者,你都经历过怎样的面试?

已经裸辞1个月了,最近开始投简历找工作,遇到各种各样的面试,今天分享一下。其实在职的时候也做过面试官,面试官时,感觉自己问的问题很难区分候选人的能力,最好的办法就是看看候选人的github上的代码仓库...

面试被问 const 是否不可变?这样回答才显功底

作为前端开发者,我在学习ES6特性时,总被const的"善变"搞得一头雾水——为什么用const声明的数组还能push元素?为什么基本类型赋值就会报错?直到翻遍MDN文档、对着内存图反...

2023金九银十必看前端面试题!2w字精品!

导文2023金九银十必看前端面试题!金九银十黄金期来了想要跳槽的小伙伴快来看啊CSS1.请解释CSS的盒模型是什么,并描述其组成部分。答案:CSS的盒模型是用于布局和定位元素的概念。它由内容区域...

前端面试总结_前端面试题整理

记得当时大二的时候,看到实验室的学长学姐忙于各种春招,有些收获了大厂offer,有些还在苦苦面试,其实那时候的心里还蛮忐忑的,不知道自己大三的时候会是什么样的一个水平,所以从19年的寒假放完,大二下学...

由浅入深,66条JavaScript面试知识点(七)

作者:JakeZhang转发链接:https://juejin.im/post/5ef8377f6fb9a07e693a6061目录由浅入深,66条JavaScript面试知识点(一)由浅入深,66...

2024前端面试真题之—VUE篇_前端面试题vue2020及答案

添加图片注释,不超过140字(可选)1.vue的生命周期有哪些及每个生命周期做了什么?beforeCreate是newVue()之后触发的第一个钩子,在当前阶段data、methods、com...

今年最常见的前端面试题,你会做几道?

在面试或招聘前端开发人员时,期望、现实和需求之间总是存在着巨大差距。面试其实是一个交流想法的地方,挑战人们的思考方式,并客观地分析给定的问题。可以通过面试了解人们如何做出决策,了解一个人对技术和解决问...