关于Mybatis中SQL节点的深入解析
作者:捡对象的cy
一、文章引出原因
某天在完成项目中的一个小功能后进行自测的时候,发现存在一个很奇怪的 bug --- 最终执行的 SQL 与我所期望的 SQL 不一致,有一个 if 分支在我不传特定参数的情况下被拼接在最终的 SQL 上。
①定义在 XML 文件中的 SQL 语句
<select id="balanceByUserIds" parameterType="xxx.BalanceReqVO" resultType="xxx.Balance"> select * from balance <where> <if test="dataOrgCodes != null and dataOrgCodes.size > 0"> and data_org_code in <foreach collection="dataOrgCodes" open="(" separator="," close=")" item="dataOrgCode"> #{dataOrgCode} </foreach> </if> <if test="dataOrgCode != null and dataOrgCode != ''"> and data_org_code = #{dataOrgCode} </if> </where> </select>
②传进来的参数
{ "dataOrgCodes":["6","2"] }
③Mybatis 打印执行的 SQL
SELECT * FROM balance WHERE data_org_code IN (?, ?) AND data_org_code = ?
打印的执行参数
{ "dataOrgCodes":["6","2"] }
二、存在的问题
学过 Mybatis 的人应该一样就看出来了,这个 SQL 不对劲,多了一些不该有的东西。按照我们的理解,最终的执行的 SQL 应该是
SELECT * FROM balance WHERE data_org_code IN (?, ?)
但 mybatis 执行的 SQL 多了一点语句---AND data_org_code = ?
在出现这个问题后我反复进行 debug,确定了自己传进来的参数没有什么问题,也没有什么拦截器添加多余的参数。
三、分析 SQL 生成过程
在确定编写 XML 文件的 if 标签的内容以及传进来的参数无误后,排除了参数导致问题。那么除了这个可能外,问题就可能出现在 SQL 的解析上,也就是 SQL 的生成那里。那么我们定位到 SQL 的生成地方, DynamicSqlSource#getBoundSql(我们查询的参数对象)方法
// Configuration是Mybatis核心类,rootSqlNode 根SQL节点是我们定义在XML中的SQL语句。 //(例如<select>rootSqlNode</sselect>, 标签中间的内容就是 rootSqlNode) public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) { this.configuration = configuration; this.rootSqlNode = rootSqlNode; } public BoundSql getBoundSql(Object parameterObject) { DynamicContext context = new DynamicContext(configuration, parameterObject); rootSqlNode.apply(context); .............................. BoundSql boundSql = sqlSource.getBoundSql(parameterObject); context.getBindings().forEach(boundSql::setAdditionalParameter); return boundSql; }
可以看到方法内部显示创建了一个 DynamicContext,这个对象就是用于存储动态生成的 SQL。
(下面是省略了很多关于本次问题无关的代码,只保留有关代码)
public class DynamicContext { public static final String PARAMETER_OBJECT_KEY = "_parameter"; public static final String DATABASE_ID_KEY = "_databaseId"; // 存储动态生成的SQL,类似于 StringBuilder 的角色 private final StringJoiner sqlBuilder = new StringJoiner(" "); // 唯一编号值,会在生成最终SQL和参数值映射关系的时候用到 private int uniqueNumber = 0; // 拼接SQL public void appendSql(String sql) { sqlBuilder.add(sql); } // 获取拼接好的SQL public String getSql() { return sqlBuilder.toString().trim(); } // 获取唯一编号,返回后进行加一 public int getUniqueNumber() { return uniqueNumber++; } }
而下一句就是解析我们编写的 SQL,完成 SQL 的拼接
rootSqlNode.apply(context)
这里的 rootSqlNode 是我们编写在标签里的 SQL 内容,包括<if>、<foreach>、<where>标签等内容。
rootSqlNode 对象是 SqlNode 类型。其实这里的 SQL 语句被解析成类似于 HTML 的 DOM 节点的树级结构,在本节的测试例子中结构类似如下(不完全正确,只做参考价值,表示 rootSqlNode 结构类似于以下结构):
<SqlNode> select * from balance <SqlNode> where <SqlNode> and data_org_code in <SqlNode> #{dataOrgCode} </SqlNode> </SqlNode> <SqlNode> and data_org_code = <SqlNode> #{dataOrgCode} </SqlNode> </SqlNode> </SqlNode> </SqlNode>
这个 SqlNode 定义如下所示:
public interface SqlNode { boolean apply(DynamicContext context); }
里面的 apply 方法是用于评估是否把这个 SqlNode 的内容拼接到最终返回的 SQL 上的,不同类型的 SqlNode 有不同的实现,例如我们本节相关的 SqlNode 类型就是为 IfSqlNode,对应这我们写的 SQL 语句的 if 标签,以及存储最终的 sql 内容的 StaticTextSqlNode 类型。
public class StaticTextSqlNode implements SqlNode { // 存储我们写的 sql // 类似于 and data_org_code in private final String text; public StaticTextSqlNode(String text) { this.text = text; } @Override public boolean apply(DynamicContext context) { // 调用 DynamicContext 对象的 sqppendSql 方法拼接最终 sql context.appendSql(text); return true; } }
public class IfSqlNode implements SqlNode { // 评估器 private final ExpressionEvaluator evaluator; // if标签中用于判断这个语句是否生效的 test 属性值 // 这里对应我们例子中的一个为 "dataOrgCodes != null and dataOrgCodes.size > 0" private final String test; // if标签中的内容,如果if标签中不存在其他标签,那么这里的值就是StaticTextSqlNode类型的节点 // StaticTextSqlNode 节点的 text 属性就是我们最终需要拼接的 sql 语句 private final SqlNode contents; // contents 是我们定义在 if 标签里面的内容, test 是 if 标签的属性 test 定义的内容 public IfSqlNode(SqlNode contents, String test) { this.test = test; this.contents = contents; this.evaluator = new ExpressionEvaluator(); } @Override public boolean apply(DynamicContext context) { // 使用评估器评估 if 标签中定义的 test 中的内容是否为true if (evaluator.evaluateBoolean(test, context.getBindings())) { // 当contents为StaticTextSqlNode类型的节点时候,就把 if 标签里的内容拼接到 sql 上 // 否则继续调用方法 apply(相当于递归调用,知道找到最下面的内容节点) contents.apply(context); return true; } return false; } }
我们可以看到这里的
evaluator.evaluateBoolean(test, context.getBindings())
这个评估方法是通过把 test 语句内容和 我们传进来的参数解析出来的 Map 进行比对,如果我们的参数中存在值,且值得内容符合 test 语句的判断,则进行 sql 语句的拼接。例如本次例子中的
<if test="userIds != null and userIds.size > 0"> and data_org_code in <foreach collection="dataOrgCodes" open="(" separator="," close=")" item="dataOrgCode"> #{dataOrgCode} </foreach> </if>
以及我们传进来的参数进行比对
{ "dataOrgCodes":["6","2"] }
可以看得出来参数与 test 语句 "dataOrgCodes!= null and dataOrgCodes.size > 0" 比较是返回 true 的。
四、分析多余 SQL 的生成
根据上面的执行步骤可以知道,我们的 bug 的产生是在
evaluator.evaluateBoolean(test, context.getBindings()) 这一步产生的。也就是在 context.getBindings() 中存在满足 dataOrgCode != null and dataOrgCode != '' 的属性。debug 验证以下可知
可以看得出来,存储参数映射的 Map 出现了 dataOrgCode 的属性,但是我们传递进来的属性只有 dataOrgCodes 数组,没有 dataOrgCode 属性,那这个 dataOrgCode 属性是怎么来的?
再次从头进行 debug 发现问题出现在 ForEachSqlNode 的 apply 方法里面
public boolean apply(DynamicContext context) { // 获取参数映射存储Map Map<String, Object> bindings = context.getBindings(); // 获取bingdings中的parameter参数,key为collectionExpression,也就是我们写在标签foreach 标签的 collection 值里的内容 // 根据collectionExpression从参数映射器中获取到对应的值, 本次的值为:["1","2"] final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings, Optional.ofNullable(nullable).orElseGet(configuration::isNullableOnForEach)); if (iterable == null || !iterable.iterator().hasNext()) { return true; } // 第一个参数 boolean first = true; // 再拼接sql里添加我们定义在 foreach 标签的 open 值里的内容 applyOpen(context); // 遍历的计数器 int i = 0; // 遍历我们传进来的数组数据 ["1","2"] // o 表示我们本次遍历数组中的值,例如 ”1“ for (Object o : iterable) { DynamicContext oldContext = context; if (first || separator == null) { context = new PrefixedContext(context, ""); } else { context = new PrefixedContext(context, separator); } int uniqueNumber = context.getUniqueNumber(); // 把 foreach 标签的 index 值里的内容作为 key,计数器的值 i 作为 value 存储到 bingdings 中。 // 例如第一次循环就为("index",0)。注意:由于相同的key会被覆盖住,所以最终存储的为("index",userIds.length - 1) // 同时生成一个 key 为 ITEM_PREFIX + index 值内容 + "_" + uniqueNumber,value 为 uniqueNumber 存储到 bingdings 中。 // 例如第一次循环就为("__frch_index_0",0) applyIndex(context, i, uniqueNumber); // 把 foreach 标签的 item 值里的内容作为 key,本次遍历数组中的值作为 value 存储到 bingdings 中。 // 例如第一次循环就为("userId","1")。注意:由于相同的key会被覆盖住,所以最终存储的为("index",userIds[userIds.length - 1]) // 同时生成一个 key 为 ITEM_PREFIX + item 值内容 + "_" + uniqueNumber,value 为本次遍历数组中的值存储到 bingdings 中。 // 例如第一次循环就为("__frch_userId_0","1") applyItem(context, o, uniqueNumber); contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber)); if (first) { first = !((PrefixedContext) context).isPrefixApplied(); } context = oldContext; // 计数器加一 i++; } // foreach 遍历完,添加 foreach 标签定义的 close 内容 applyClose(context); return true; }
从源码可以知道,问题就出在遍历 dataOrgCodes 这个数组上面。在执行 apply 方法之中有
applyIndex(context, i, uniqueNumber);
applyItem(context, o, uniqueNumber);
#ForEachSqlNode private void applyIndex(DynamicContext context, Object o, int i) { if (index != null) { context.bind(index, o); context.bind(itemizeItem(index, i), o); } } private void applyItem(DynamicContext context, Object o, int i) { if (item != null) { context.bind(item, o); context.bind(itemizeItem(item, i), o); } } #DynamicContext public void bind(String name, Object value) { bindings.put(name, value); }
从上面的逻辑中可以知道,在遍历 dataOrgCodes 数组的时候,会把我们定义在 foreach 标签中
item、index 属性值作为 key 存储在 DynamicContext 的 bingdings 中,也就是我们传进来的查询参数对象对应的 Map 中,这就导致了虽然我们没有传进来 dataOrgCode 属性,但是在执行 dataOrgCodes 的 foreach 过程中产生了中间值 dataOrgCode,导致最终拼接的 SQL 出现了不该有的条件语句。
五、解决办法
按道理我们使用的框架是 Mybatis 二次开发的(基本是 Mybatis),应该不会有这么大的问题。所以在发现问题后在本地写了一个 demo 进行复现,发现本地的不会出现这个问题,顿时疑惑了。然后就去了 github 把 Mybatis 的源码拉下来进行比较,最终发现了一些问题。
Mybatis 在 2017 年发现了问题并进行了修复,在方法结尾处添加了移除本次 foreach 遍历产生的中间值,也就是从参数映射 Map 中删除了我们定义在 <foreach> 标签的 item、index 定义的 key,这样就不会产生本节的问题。
然而我所用的框架依然是没有更新,用的还是 2012 年版本的代码。所以为了解决这个问题,只能修改 foreach 标签中的 item 的属性值名称,避免和 if 标签的 test 中的属性名称冲突。也就是修改为以下的 SQL 代码。
六、总结
使用二次开发的框架可能存在坑,需要注意引用的版本存在未解决问题。
到此这篇关于Mybatis中SQL节点的文章就介绍到这了,更多相关Mybatis中SQL节点解析内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!