絮叨
上面一节已经把一个查询语句的整体过程,大概的过了一遍,但是还有很多的细节这边没说清楚,今天就把当中的一些细节缕缕
获取 BoundSql
我们先把昨天的这个过程拿出来看看
第一步就是 BoundSql 上一篇文章 这个我是直接跳过了,那大家猜猜这个是干嘛的呢?
在执行 SQL 之前,需要将 SQL 语句完整的解析出来。我们都知道 SQL 是配置在映射文 件中的,但由于映射文件中的 SQL 可能会包含占位符#{},以及动态 SQL 标签,比如、 等。因此,我们并不能直接使用映射文件中配置的 SQL。MyBatis 会将映射文件中 的 SQL 解析成一组 SQL 片段。如果某个片段中也包含动态 SQL 相关的标签,那么,MyBatis 会对该片段再次进行分片。最终,一个 SQL 配置将会被解析成一个 SQL 片段树。形如下面 的图片:
我们需要对片段树进行解析,以便从每个片段对象中获取相应的内容。然后将这些内容 组合起来即可得到一个完成的 SQL 语句,这个完整的 SQL 以及其他的一些信息最终会存储 在 BoundSql 对象中。下面我们来看一下 BoundSql 类的成员变量信息,如下:
private final String sql; private final List<ParameterMapping> parameterMappings; private final Object parameterObject; private final Map<String, Object> additionalParameters; private final MetaObject metaParameters; 复制代码
下面用一个表格列举各个成员变量的含义
以上对 BoundSql 的成员变量做了简要的说明,部分参数的用途大家现在可能不是很明 白。不过不用着急,这些变量在接下来的源码分析过程中会陆续的出现。到时候对着源码多 思考,或是写点测试代码调试一下,即可弄懂。
好了,现在准备工作已经做好。接下来,开始分析 BoundSql 的构建过程。我们源码之 旅的第一站是 MappedStatement 的 getBoundSql 方法,代码如下:
// -☆- MappedStatement public BoundSql getBoundSql(Object parameterObject) { // 调用 sqlSource 的 getBoundSql 获取 BoundSql BoundSql boundSql = sqlSource.getBoundSql(parameterObject); List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); if (parameterMappings == null || parameterMappings.isEmpty()) { // 创建新的 BoundSql,这里的 parameterMap 是 ParameterMap 类型。 // 由<ParameterMap> 节点进行配置,该节点已经废弃,不推荐使用。 // 默认情况下,parameterMap.getParameterMappings() 返回空集合 boundSql = new BoundSql(configuration, boundSql.getSql(), parameterMap.getParameterMappings(), parameterObject); } // 省略不重要的逻辑 return boundSql; } 复制代码
如上,MappedStatement 的 getBoundSql 在内部调用了 SqlSource 实现类的 getBoundSql 方法。处理此处的调用,余下的逻辑都不是重要逻辑,就不啰嗦了。接下来,我们把目光转 移到 SqlSource 实现类的 getBoundSql 方法上。SqlSource 是一个接口,它有如下几个实现类:
- DynamicSqlSource
- RawSqlSource
- StaticSqlSource
- ProviderSqlSource
- VelocitySqlSource
在如上几个实现类中,我们应该选择分析哪个实现类的逻辑呢?首先我们把最后两个排 除掉,不常用。剩下的三个实现类中,仅前两个实现类会在映射文件解析的过程中被使用。 当 SQL 配置中包含${}(不是#{})占位符,或者包含、等标签时,会被认为是 动态 SQL,此时使用 DynamicSqlSource 存储 SQL 片段。否则,使用 RawSqlSource 存储 SQL 配置信息。相比之下 DynamicSqlSource 存储的 SQL 片段类型较多,解析起来也更为复杂一 些。因此下面我将分析 DynamicSqlSource 的 getBoundSql 方法。弄懂这个,RawSqlSource 也 不在话下。
// -☆- DynamicSqlSource public BoundSql getBoundSql(Object parameterObject) { // 创建 DynamicContext DynamicContext context = new DynamicContext(configuration, parameterObject); // 解析 SQL 片段,并将解析结果存储到 DynamicContext 中 rootSqlNode.apply(context); SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration); Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass(); // 构建 StaticSqlSource,在此过程中将 sql 语句中的占位符 #{} 替换为问号 ?, // 并为每个占位符构建相应的 ParameterMapping SqlSource sqlSource = sqlSourceParser.parse( context.getSql(), parameterType, context.getBindings()); // 调用 StaticSqlSource 的 getBoundSql 获取 BoundSql BoundSql boundSql = sqlSource.getBoundSql(parameterObject); // 将 DynamicContext 的 ContextMap 中的内容拷贝到 BoundSql 中 for(Map.Entry<String, Object> entry : context.getBindings().entrySet()){ boundSql.setAdditionalParameter(entry.getKey(), entry.getValue()); } return boundSql; } 复制代码
如上,DynamicSqlSource 的 getBoundSql 方法的代码看起来不多,但是逻辑却并不简单。 该方法由数个步骤组成,这里总结一下:
- 创建 DynamicContext
- 解析 SQL 片段,并将解析结果存储到 DynamicContext 中
- 解析 SQL 语句,并构建 StaticSqlSource
- 调用 StaticSqlSource 的 getBoundSql 获取 BoundSql
- 将 DynamicContext 的 ContextMap 中的内容拷贝到 BoundSql 中
DynamicContext
DynamicContext 是 SQL 语句构建的上下文,每个 SQL 片段解析完成后,都会将解析结 果存入 DynamicContext 中。待所有的 SQL 片段解析完毕后,一条完整的 SQL 语句就会出现 在 DynamicContext 对象中。下面我们来看一下 DynamicContext 类的定义。
public class DynamicContext { public static final String PARAMETER_OBJECT_KEY = "_parameter"; public static final String DATABASE_ID_KEY = "_databaseId"; private final ContextMap bindings; private final StringBuilder sqlBuilder = new StringBuilder(); public DynamicContext( Configuration configuration, Object parameterObject) { // 创建 ContextMap if (parameterObject != null && !(parameterObject instanceof Map)) { MetaObject metaObject = configuration.newMetaObject(parameterObject); bindings = new ContextMap(metaObject); } else { bindings = new ContextMap(null); } // 存放运行时参数 parameterObject 以及 databaseId bindings.put(PARAMETER_OBJECT_KEY, parameterObject); bindings.put(DATABASE_ID_KEY, configuration.getDatabaseId()); } } 复制代码
上面只贴了 DynamicContext 类的部分代码。其中 sqlBuilder 变量用于存放 SQL 片段的 解析结果,bindings 则用于存储一些额外的信息,比如运行时参数和 databaseId 等。bindings 类型为 ContextMap,
解析 SQL ⽚段
对于一个包含了${}占位符,或、等标签的 SQL,在解析的过程中,会被分解 成多个片段。每个片段都有对应的类型,每种类型的片段都有不同的解析逻辑。在源码中, 片段这个概念等价于 sql 节点,即 SqlNode。SqlNode 是一个接口,它有众多的实现类。其继 承体系如下:
上图只画出了部分的实现类,还有一小部分没画出来,不过这并不影响接下来的分析。 在众多实现类中,StaticTextSqlNode 用于存储静态文本,TextSqlNode 用于存储带有${}占位 符的文本,IfSqlNode 则用于存储节点的内容。MixedSqlNode 内部维护了一个 SqlNode 集合,用于存储各种各样的 SqlNode。接下来,我将会对 MixedSqlNode、StaticTextSqlNode、 TextSqlNode、IfSqlNode、WhereSqlNode 以及 TrimSqlNode 等进行分析,其他的实现类请大 家自行分析。
public class MixedSqlNode implements SqlNode { private final List<SqlNode> contents; public MixedSqlNode(List<SqlNode> contents) { this.contents = contents; } @Override public boolean apply(DynamicContext context) { // 遍历 SqlNode 集合 for (SqlNode sqlNode : contents) { // 调用 salNode 对象本身的 apply 方法解析 sql sqlNode.apply(context); } return true; } } 复制代码
MixedSqlNode 可以看做是 SqlNode 实现类对象的容器,凡是实现了 SqlNode 接口的类 都可以存储到 MixedSqlNode 中,包括它自己。MixedSqlNode 解析方法 apply 逻辑比较简单, 即遍历 SqlNode 集合,并调用其他 SalNode 实现类对象的 apply 方法解析 sql。那下面我们来 看看其他 SalNode 实现类的 apply 方法是怎样实现的。
public class StaticTextSqlNode implements SqlNode { private final String text; public StaticTextSqlNode(String text) { this.text = text; } @Override public boolean apply(DynamicContext context) { context.appendSql(text); return true; } } 复制代码
StaticTextSqlNode 用于存储静态文本,所以它不需要什么解析逻辑,直接将其存储的 SQL 片段添加到 DynamicContext 中即可。StaticTextSqlNode 的实现比较简单,看起来很轻 松。下面分析一下 TextSqlNode。
public class TextSqlNode implements SqlNode { private final String text; private final Pattern injectionFilter; @Override public boolean apply(DynamicContext context) { // 创建 ${} 占位符解析器 GenericTokenParser parser = createParser( new BindingTokenParser(context, injectionFilter)); // 解析 ${} 占位符,并将解析结果添加到 DynamicContext 中 context.appendSql(parser.parse(text)); return true; } private GenericTokenParser createParser(TokenHandler handler) { // 创建占位符解析器,GenericTokenParser 是一个通用解析器, // 并非只能解析 ${} 占位符 return new GenericTokenParser("${", "}", handler); } private static class BindingTokenParser implements TokenHandler { private DynamicContext context; private Pattern injectionFilter; public BindingTokenParser( DynamicContext context, Pattern injectionFilter) { this.context = context; this.injectionFilter = injectionFilter; } @Override public String handleToken(String content) { Object parameter = context.getBindings().get("_parameter"); if (parameter == null) { context.getBindings().put("value", null); }else if(SimpleTypeRegistry.isSimpleType(parameter.getClass())){ context.getBindings().put("value", parameter); } // 通过 ONGL 从用户传入的参数中获取结果 Object value = OgnlCache .getValue(content, context.getBindings()); String srtValue = (value == null ? "" : String.valueOf(value)); // 通过正则表达式检测 srtValue 有效性 checkInjection(srtValue); return srtValue; } } } 复制代码
如上,GenericTokenParser 是一个通用的标记解析器,用于解析形如{xxx},#{xxx}等标 记 。GenericTokenParser 负责将标记中的内容抽取出来,并将标记内容交给相应的 TokenHandler 去处理。BindingTokenParser 负责解析标记内容,并将解析结果返回给 GenericTokenParser,用于替换{xxx}标记。举个例子说明一下吧,如下。 我们有这样一个 SQL 语句,用于从 article 表中查询某个作者所写的文章。如下: SELECT * FROM article WHERE author = '${author}' 假设我们我们传入的 author 值为 小六六,那么该 SQL 最终会被解析成如下的结果: SELECT * FROM article WHERE author = '小六六'
一般情况下,使用author接受参数都没什么问题。但是怕就怕在有人不怀好意,构建了一些恶意的参数。当用这些恶意的参数替换{author}接受参数都没什么问题。但是怕就怕在有人不怀好意,构建 了一些恶意的参数。当用这些恶意的参数替换author接受参数都没什么问题。但是怕就怕在有人不怀好意,构建了一些恶意的参数。当用这些恶意的参数替换{author}时就会出现灾难性问题——SQL 注
入。比如我们构建这样一个参数 author=tianxiaobo';DELETE FROM article;#,然后我们把这 个参数传给 TextSqlNode 进行解析。得到的结果如下
SELECT * FROM article WHERE author = '小六六'; DELETE FROM article;#'
看到没,由于传入的参数没有经过转义,最终导致了一条 SQL 被恶意参数拼接成了两 条 SQL。更要命的是,第二天 SQL 会把 article 表的数据清空,这个后果就很严重了(从删 库到跑路)。这就是为什么我们不应该在 SQL 语句中是用${}占位符,风险太大。 分析完 TextSqlNode 的逻辑,接下来,分析 IfSqlNode 的实现。
public class IfSqlNode implements SqlNode { private final ExpressionEvaluator evaluator; private final String test; private final SqlNode contents; public IfSqlNode(SqlNode contents, String test) { this.test = test; this.contents = contents; this.evaluator = new ExpressionEvaluator(); } @Override public boolean apply(DynamicContext context) { // 通过 ONGL 评估 test 表达式的结果 if (evaluator.evaluateBoolean(test, context.getBindings())) { // 若 test 表达式中的条件成立,则调用其他节点的 apply 方法进行解析 contents.apply(context); return true; } return false; } } 复制代码
IfSqlNode 对应的是<iftest='xxx'>节点,节点是日常开发中使用频次比较高的一个节 点。它的具体用法我想大家都很熟悉了,这里就不多啰嗦。IfSqlNode 的 apply 方法逻辑并不 复杂,首先是通过 ONGL 检测 test 表达式是否为 true,如果为 true,则调用其他节点的 apply 方法继续进行解析。需要注意的是节点中也可嵌套其他的动态节点,并非只有纯文本。 因此 contents 变量遍历指向的是 MixedSqlNode,而非
StaticTextSqlNode。 关于 IfSqlNode 就说到这,接下来分析 WhereSqlNode 的实现
public class WhereSqlNode extends TrimSqlNode { /** 前缀列表 */ private static 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); } } 复制代码
MyBatis 中,WhereSqlNode 和 SetSqlNode 都是基于 TrimSqlNode 实现的,所以上面 的代码看起来很简单。WhereSqlNode 对应于节点,关于该节点的用法以及它的应用 场景,大家请自行查阅资料。我在分析源码的过程中,默认大家已经知道了该节点的用途和 应用场景。 接下来,我们把目光聚焦在 TrimSqlNode 的实现上
public class TrimSqlNode implements SqlNode { private final SqlNode contents; private final String prefix; private final String suffix; private final List<String> prefixesToOverride; private final List<String> suffixesToOverride; private final Configuration configuration; @Override public boolean apply(DynamicContext context) { // 创建具有过滤功能的 DynamicContext FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context); // 解析节点内容 boolean result = contents.apply(filteredDynamicContext); // 过滤掉前缀和后缀 filteredDynamicContext.applyAll(); return result; } } 复制代码
如上,apply 方法首选调用了其他 SqlNode 的 apply 方法解析节点内容,这步操作完成 后,FilteredDynamicContext 中会得到一条 SQL 片段字符串。接下里需要做的事情是过滤字 符串前缀后和后缀,并添加相应的前缀和后缀。这个事情由 FilteredDynamicContext 负责, FilteredDynamicContext 是 TrimSqlNode 的私有内部类。我们去看一下它的代码
private class FilteredDynamicContext extends DynamicContext { private DynamicContext delegate; /** 构造方法会将下面两个布尔值置为 false */ 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) { // 引用前缀和后缀,也就是对 sql 进行过滤操作,移除掉前缀或后缀 applyPrefix(sqlBuffer, trimmedUppercaseSql); applySuffix(sqlBuffer, trimmedUppercaseSql); } // 将当前对象的 sqlBuffer 内容添加到代理类中 delegate.appendSql(sqlBuffer.toString()); } private void applyPrefix(StringBuilder sql, String trimmedUppercaseSql){ if (!prefixApplied) { // 设置 prefixApplied 为 true,以下逻辑仅会被执行一次 prefixApplied = true; if (prefixesToOverride != null) { for (String toRemove : prefixesToOverride) { // 检测当前 sql 字符串是否包含前缀,比如 'AND ', 'AND\t'等 if (trimmedUppercaseSql.startsWith(toRemove)) { // 移除前缀 sql.delete(0, toRemove.trim().length()); break; } } } // 插入前缀,比如 WHERE if (prefix != null) { sql.insert(0, " "); sql.insert(0, prefix); } } } // 该方法逻辑与 applyPrefix 大同小异,大家自行分析 private void applySuffix( StringBuilder sql, String trimmedUppercaseSql){ } } 复制代码
在上面的代码中,我们重点关注 applyAll 和 applyPrefix 方法,其他的方法大家自行分 析。applyAll 方法的逻辑比较简单,首先从 sqlBuffer 中获取 SQL 字符串。然后调用 applyPrefix 和 applySuffix 进行过滤操作。最后将过滤后的 SQL 字符串添加到被装饰的类中。applyPrefix 方法会首先检测 SQL 字符串是不是以"AND","OR",或"AND\n","OR\n"等前缀开头,若是 则将前缀从 sqlBuffer 中移除。然后将前缀插入到 sqlBuffer 的首部,整个逻辑就结束了。下 面写点代码简单验证一下,如下:
public class SqlNodeTest { @Test public void testWhereSqlNode() throws IOException { String sqlFragment = "AND id = #{id}"; MixedSqlNode msn = new MixedSqlNode( Arrays.asList(new StaticTextSqlNode(sqlFragment))); WhereSqlNode wsn = new WhereSqlNode(new Configuration(), msn); DynamicContext dc = new DynamicContext( new Configuration(), new ParamMap<>()); wsn.apply(dc); System.out.println("解析前:" + sqlFragment); System.out.println("解析后:" + dc.getSql()); } } 复制代码