写在前面
如果你写过复杂查询,肯定用过 MyBatis 的动态 SQL:<if> 判断参数是否为空、<foreach> 遍历集合构建 IN 子句、<where> 自动处理 WHERE 关键字和多余的 AND……这些标签让 SQL 拼接从体力劳动变成了脑力劳动。
但问题来了:XML 里的 <if test="name != null"> 是怎么被解析的?test 里的表达式什么时候求值?<foreach> 的 open、close、separator 是怎么拼到 SQL 里的?#{item} 为什么能取到集合里的元素?
这篇文章从 SqlNode 树、DynamicContext、OGNL 表达式三个维度,拆解动态 SQL 的完整生成过程。读完之后你应该能回答:为什么动态 SQL 只能在运行时解析?BoundSql 是怎么从一堆 XML 标签变成带 ? 占位符的 SQL 的?
一、动态 SQL vs 静态 SQL:什么时候需要动态解析
MyBatis 解析 XML 里的 <select>、<insert> 等标签时,会判断 SQL 是否包含动态内容:
静态 SQL:纯文本,没有动态标签,没有 ${}占位符 → 解析成RawSqlSource动态 SQL:包含 <if>、<foreach>、<where>等标签,或者有${}→ 解析成DynamicSqlSource
// XMLLanguageDriver.createSqlSource()
public SqlSource createSqlSource(
Configuration configuration,
XNode script, Class<?> parameterType){
XMLScriptBuilder builder = new XMLScriptBuilder(
configuration, script, parameterType);
return builder.parseScriptNode();
}
// XMLScriptBuilder.parseScriptNode()
public SqlSource parseScriptNode(){
// 递归解析 XML 子节点,生成 SqlNode 树
SqlNode mixedSqlNode =
parseDynamicTags(contextNode);
SqlSource sqlSource;
if (isDynamic) {
sqlSource = new DynamicSqlSource(
configuration, mixedSqlNode);
} else {
sqlSource = new RawSqlSource(
configuration, mixedSqlNode, parameterType);
}
return sqlSource;
}
isDynamic 是在递归解析子节点时标记的:一旦发现 <if>、<choose>、<foreach>、<trim>、<where>、<set>、<bind> 等标签,或者有 ${} 占位符,就标记为动态。
这就像厨师看菜单:如果菜单上全是固定菜品,直接照做;如果有"根据顾客口味调整辣度"这种弹性描述,就得等顾客来了才能确定。
二、SqlNode:动态 SQL 的 AST(抽象语法树)
MyBatis 把每个动态标签都解析成一个 SqlNode,最终形成一棵树。SqlNode 的接口极简:
publicinterfaceSqlNode{
// 根据参数上下文解析自己,把 SQL 片段追加到 context
booleanapply(DynamicContext context);
}
apply 返回 true/false,表示当前节点是否"有内容"(比如 <if> 条件为 false 时,不追加任何内容,返回 false)。
SqlNode 的实现类对应各种动态标签:
SqlNode
├── MixedSqlNode —— 混合节点,包含多个子节点
├── StaticTextSqlNode —— 静态文本(不含动态标签的 SQL 片段)
├── IfSqlNode —— <if> 标签
├── ChooseSqlNode —— <choose> 标签(包含 <when>、<otherwise>)
├── ForEachSqlNode —— <foreach> 标签
├── TrimSqlNode —— <trim> 标签(<where>、<set> 的底层实现)
├── WhereSqlNode —— <where> 标签(继承 TrimSqlNode)
├── SetSqlNode —— <set> 标签(继承 TrimSqlNode)
└── BindSqlNode —— <bind> 标签
2.1 MixedSqlNode:容器节点
publicclassMixedSqlNodeimplementsSqlNode{
privatefinal List<SqlNode> contents;
publicMixedSqlNode(List<SqlNode> contents){
this.contents = contents;
}
@Override
publicbooleanapply(DynamicContext context){
for (SqlNode sqlNode : contents) {
sqlNode.apply(context);
}
returntrue;
}
}
MixedSqlNode 是容器,里面装了一堆子节点。解析 XML 时,<select> 标签的所有子内容会被包装成一个 MixedSqlNode。递归调用每个子节点的 apply,把 SQL 片段逐个追加到 DynamicContext。
这就像拼乐高:你手里有一堆小零件(SqlNode),按说明书(XML 结构)逐个拼到主体上(DynamicContext)。
2.2 IfSqlNode:条件分支
publicclassIfSqlNodeimplementsSqlNode{
privatefinal ExpressionEvaluator evaluator;
privatefinal String test;
privatefinal SqlNode contents;
publicIfSqlNode(SqlNode contents, String test){
this.test = test;
this.contents = contents;
this.evaluator = new ExpressionEvaluator();
}
@Override
publicbooleanapply(DynamicContext context){
if (evaluator.evaluateBoolean(test,
context.getBindings())) {
contents.apply(context);
returntrue;
}
returnfalse;
}
}
IfSqlNode 的核心:ExpressionEvaluator.evaluateBoolean() 用 OGNL 表达式求值。如果表达式结果为 true,执行子节点的 apply;否则什么都不做。
publicclassExpressionEvaluator{
publicbooleanevaluateBoolean(String expression,
Object parameterObject){
Object value = OgnlCache.getValue(
expression, parameterObject);
if (value instanceof Boolean) {
return (Boolean) value;
}
if (value instanceof Number) {
returnnew BigDecimal(
String.valueOf(value))
.compareTo(BigDecimal.ZERO) != 0;
}
return value != null;
}
}
OGNL 表达式从 parameterObject 里取值。比如 test="name != null",OGNL 会先从 context.getBindings() 里找 name 变量,然后判断是否为 null。
OGNL 是什么?Object-Graph Navigation Language,对象图导航语言。简单来说,就是可以通过类似
user.name的表达式从对象里取值。Struts2 也用过 OGNL,不过 MyBatis 用的是精简版,只保留了表达式求值能力。
2.3 ForEachSqlNode:循环遍历
<foreach> 是最复杂的 SqlNode,因为它要处理:
遍历集合参数 生成占位符( #{item}→?)处理 open、close、separator处理 index和item变量
publicclassForEachSqlNodeimplementsSqlNode{
publicstaticfinal String ITEM_PREFIX = "__frch_";
privatefinal ExpressionEvaluator evaluator;
privatefinal String collectionExpression;
privatefinal SqlNode contents;
privatefinal String open;
privatefinal String close;
privatefinal String separator;
privatefinal String item;
privatefinal String index;
privatefinal Configuration configuration;
@Override
publicbooleanapply(DynamicContext context){
Map<String, Object> bindings =
context.getBindings();
final Iterable<?> iterable = evaluator
.evaluateIterable(collectionExpression,
bindings);
if (!iterable.iterator().hasNext()) {
returntrue;
}
boolean first = true;
applyOpen(context);
int i = 0;
for (Object o : iterable) {
DynamicContext oldContext = context;
if (first || separator == null) {
first = false;
} else {
context.appendSql(separator);
}
// 创建新的上下文,绑定 item 和 index 变量
context = new PrefixedContext(context, "");
if (index != null) {
context.bind(index, o);
context.bind(
itemizeItem(index, i), o);
}
context.bind(item, o);
context.bind(
itemizeItem(item, i), o);
// 执行子节点
contents.apply(new FilteredDynamicContext(
configuration, oldContext,
itemizeItem(item, i),
itemizeItem(index, i)));
i++;
}
applyClose(context);
returntrue;
}
privatestatic String itemizeItem(String item, int i){
return ITEM_PREFIX + item + "_" + i;
}
}
<foreach> 的处理流程:
用 OGNL 从参数里取出集合(比如 list、array、user.ids)如果集合为空,返回 true(不生成内容,但也不算失败) 遍历集合,每次迭代: 追加 separator(除了第一次)绑定 item变量到上下文(比如item → 当前元素)生成唯一的参数名( __frch_item_0、__frch_item_1……)执行子节点(通常是 StaticTextSqlNode,内容如#{item})追加 open和close
__frch_ 前缀的参数名是为了避免冲突。<foreach> 展开后,每个元素对应一个独立的参数名,最终由 ParameterHandler 逐个绑定。
比如:
<foreachcollection="ids"item="id"open="("separator=","close=")">
#{id}
</foreach>
传入 ids = [1, 2, 3],解析后生成:
( ? , ? , ? )
同时 DynamicContext 里的参数绑定变成:
__frch_id_0 → 1
__frch_id_1 → 2
__frch_id_2 → 3
2.4 TrimSqlNode / WhereSqlNode / SetSqlNode:修剪器
<where> 和 <set> 底层都是 TrimSqlNode,只是预设了不同的前缀/后缀规则。
publicclassTrimSqlNodeimplementsSqlNode{
privatefinal SqlNode contents;
privatefinal String prefix;
privatefinal String suffix;
privatefinal List<String> prefixesToOverride;
privatefinal List<String> suffixesToOverride;
@Override
publicbooleanapply(DynamicContext context){
// 创建过滤上下文
FilteredDynamicContext filteredDynamicContext =
new FilteredDynamicContext(context);
// 执行子节点,内容写入 filteredDynamicContext
boolean result = contents.apply(filteredDynamicContext);
// 处理前缀/后缀修剪
filteredDynamicContext.applyAll();
return result;
}
}
TrimSqlNode 的工作方式:
创建 FilteredDynamicContext,子节点先把 SQL 片段写到这里子节点执行完后,检查内容的开头是否有 prefixesToOverride里定义的字符串(比如AND、OR),如果有,去掉检查末尾是否有 suffixesToOverride,如果有,去掉追加 prefix(比如WHERE)和suffix
<where> 的定义:
publicclassWhereSqlNodeextendsTrimSqlNode{
publicWhereSqlNode(Configuration configuration,
SqlNode contents){
super(configuration, contents, "WHERE",
Arrays.asList("AND ", "AND\n", "OR ", "OR\n"),
null, null);
}
}
前缀: WHERE要去掉的前缀: AND、OR(带空格和换行)没有后缀处理
这就是为什么 <where> 能自动处理多余的 AND/OR:子节点生成的内容可能是 AND name = ?,TrimSqlNode 会把开头的 AND 去掉,然后加上 WHERE。
三、DynamicContext:SQL 拼接的上下文
DynamicContext 是动态 SQL 解析的上下文,负责两件事:
拼接 SQL 字符串 —— 用 StringBuilder累积 SQL 片段管理参数绑定 —— 存储 OGNL 表达式能访问的变量
publicclassDynamicContext{
privatefinal StringBuilder sqlBuilder = new StringBuilder();
privatefinal Map<String, Object> bindings = new HashMap<>();
privatefinal MetaObject parameterMetaObject;
publicDynamicContext(Configuration configuration,
Object parameterObject){
if (parameterObject != null) {
if (parameterObject instanceof Map) {
// Map 参数直接放进 bindings
bindings.putAll((Map<?, ?>) parameterObject);
}
// 创建 MetaObject,让 OGNL 可以访问属性
parameterMetaObject = configuration
.newMetaObject(parameterObject);
}
// _parameter 指向参数对象本身
bindings.put("_parameter", parameterObject);
// _databaseId 指向当前数据库标识
bindings.put("_databaseId",
configuration.getDatabaseId());
}
publicvoidbind(String name, Object value){
bindings.put(name, value);
}
publicvoidappendSql(String sql){
sqlBuilder.append(sql);
sqlBuilder.append(" ");
}
public String getSql(){
return sqlBuilder.toString().trim();
}
public Map<String, Object> getBindings(){
return bindings;
}
}
bindings 是 OGNL 表达式的变量上下文。parameterMetaObject 让 OGNL 可以通过属性名访问对象(比如 user.name)。
_parameter 和 _databaseId 是内置变量:
_parameter—— 当前参数对象_databaseId—— 当前数据库标识(多数据库支持时用)
四、从 SqlNode 到 BoundSql:完整解析流程
动态 SQL 的执行分两步:
4.1 第一步:SqlNode.apply() → 拼接 SQL 文本
// DynamicSqlSource.getBoundSql()
public BoundSql getBoundSql(Object parameterObject){
// 1. 创建上下文
DynamicContext context = new DynamicContext(
configuration, parameterObject);
// 2. SqlNode 树解析,生成 SQL 文本
rootSqlNode.apply(context);
// 3. 处理 #{ } 占位符
SqlSourceBuilder sqlSourceParser =
new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null
? Object.class : parameterObject.getClass();
SqlSource sqlSource = sqlSourceParser.parse(
context.getSql(), parameterType,
context.getBindings());
// 4. 生成最终的 BoundSql
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// 5. 把 <bind> 产生的额外参数也放进 BoundSql
for (Map.Entry<String, Object> entry
: context.getBindings().entrySet()) {
boundSql.setAdditionalParameter(
entry.getKey(), entry.getValue());
}
return boundSql;
}
4.2 第二步:SqlSourceBuilder.parse() → 处理 #{ }
SqlNode 解析完后,SQL 里可能还有 #{name} 占位符。SqlSourceBuilder 负责把它们替换成 ?,并生成 ParameterMapping。
public SqlSource parse(String originalSql,
Class<?> parameterType,
Map<String, Object> additionalParameters){
ParameterMappingTokenHandler handler =
new ParameterMappingTokenHandler(
configuration, parameterType,
additionalParameters);
GenericTokenParser parser = new GenericTokenParser(
"#{", "}", handler);
String sql = parser.parse(originalSql);
returnnew StaticSqlSource(configuration, sql,
handler.getParameterMappings());
}
GenericTokenParser 是个通用占位符解析器:
public String parse(String text){
StringBuilder builder = new StringBuilder();
if (text != null && text.length() > 0) {
char[] src = text.toCharArray();
int offset = 0;
int start = text.indexOf(openToken, offset);
while (start > -1) {
int end = text.indexOf(closeToken, start);
if (end > -1) {
// 提取 #{ } 里的内容
String content = text.substring(
start + openToken.length(), end);
// 交给 handler 处理
builder.append(handler.handleToken(content));
offset = end + closeToken.length();
start = text.indexOf(openToken, offset);
} else {
builder.append(src, start, src.length - start);
break;
}
}
if (offset < src.length) {
builder.append(src, offset,
src.length - offset);
}
}
return builder.toString();
}
ParameterMappingTokenHandler.handleToken():
@Override
public String handleToken(String content){
parameterMappings.add(
buildParameterMapping(content));
return"?";
}
把 #{name, jdbcType=VARCHAR} 解析成 ParameterMapping(包含属性名、JdbcType、TypeHandler),然后替换成 ?。
4.3 最终产物:BoundSql
publicclassBoundSql{
privatefinal String sql; // 带 ? 的 SQL
privatefinal List<ParameterMapping> parameterMappings;
privatefinal Object parameterObject; // 原始参数
privatefinal Map<String, Object> additionalParameters; // <foreach>、<bind> 产生的额外参数
privatefinal MetaObject metaParameters; // 额外参数的 MetaObject
}
BoundSql 是 SQL 执行的最终产物:
sql:完整的 SQL 语句,所有#{ }已替换为?parameterMappings:按顺序排列的参数映射,每个对应一个?parameterObject:原始参数对象additionalParameters:<foreach>产生的__frch_xxx参数、<bind>产生的变量
五、${} vs #{}:为什么一个能注入,一个不能
这是面试经典题。从源码角度看,区别很明显:
** #{}**:在SqlSourceBuilder.parse()阶段被替换成?,然后用PreparedStatement.setXxx()设置参数。参数值不参与 SQL 拼接,完全交给 JDBC 处理,天然防注入。** ${}**:在DynamicSqlSource阶段直接字符串替换,参数值拼接到 SQL 里。如果参数来自用户输入且未过滤,就能注入。
// ${} 的处理在 TextSqlNode 里
publicclassTextSqlNodeimplementsSqlNode{
privatefinal String text;
@Override
publicbooleanapply(DynamicContext context){
// 创建 GenericTokenParser,解析 ${ }
GenericTokenParser parser = new GenericTokenParser(
"${", "}", new BindingTokenParser(context));
context.appendSql(parser.parse(text));
returntrue;
}
}
BindingTokenParser 直接用 OGNL 取值,然后 toString() 拼到 SQL 里。没有预编译,没有参数绑定,就是纯字符串替换。
这就像两扇门的区别:
#{}是安检门——你先报身份(参数),保安查完才让你进(设置参数)`${}`` 是敞开的大门——你说什么就是什么,直接进(字符串拼接),坏人也能混进来
所以 ${} 只能用于确定安全的场景,比如动态表名、列名、排序字段。用户输入的内容绝对不能用 ${}。
六、完整流程回顾
Mapper XML 中的 <select>
│
▼
XMLLanguageDriver.createSqlSource()
│
▼
XMLScriptBuilder.parseScriptNode()
│
├── parseDynamicTags() —— 递归解析 XML,生成 SqlNode 树
│ ├── 静态文本 → StaticTextSqlNode
│ ├── <if> → IfSqlNode(含子 SqlNode)
│ ├── <foreach> → ForEachSqlNode(含子 SqlNode)
│ ├── <where> → WhereSqlNode(TrimSqlNode 子类)
│ └── ...
│
├── 标记 isDynamic
└── 返回 DynamicSqlSource(动态)或 RawSqlSource(静态)
│
▼
执行查询时:DynamicSqlSource.getBoundSql(parameterObject)
│
├── new DynamicContext(configuration, parameterObject)
│
├── rootSqlNode.apply(context) —— 递归解析 SqlNode 树
│ ├── MixedSqlNode.apply() → 遍历子节点
│ ├── IfSqlNode.apply() → OGNL 求值 → true 则执行子节点
│ ├── ForEachSqlNode.apply() → 遍历集合 → 生成 __frch_xxx 参数
│ ├── TrimSqlNode.apply() → 子节点执行 → 修剪前缀/后缀
│ └── StaticTextSqlNode.apply() → 追加文本到 context.sqlBuilder
│
├── context.getSql() —— 拿到拼接后的 SQL 文本(可能含 #{ })
│
├── SqlSourceBuilder.parse() —— 处理 #{ } 占位符
│ ├── GenericTokenParser 逐个提取 #{ } 内容
│ ├── 生成 ParameterMapping
│ └── 替换成 ?
│
├── new StaticSqlSource(sql, parameterMappings)
└── sqlSource.getBoundSql(parameterObject) → 返回 BoundSql
七、几个常见疑问
为什么 <where> 能自动去掉开头的 AND,但 <trim> 更灵活?
<where> 是 <trim> 的特化版本,预设了 prefix="WHERE" 和 prefixesToOverride="AND | OR"。如果你需要更复杂的前缀/后缀处理(比如 SET 要去掉末尾逗号),直接用 <trim>。
<foreach> 的 collection 里可以写什么?
可以是:
参数名(如 ids、user.ids)OGNL 表达式(如 user.dept.ids)内置变量(如 array、list,当参数是数组或 List 时)
<bind> 是怎么工作的?
<bind> 在解析阶段用 OGNL 计算表达式,把结果绑定到 DynamicContext 的变量里。比如:
<bindname="pattern"value="'%' + name + '%'"/>
<where>
<iftest="name != null">
AND name LIKE #{pattern}
</if>
</where>
pattern 会被计算成 %xxx%,然后绑定到 context.bindings,最终作为额外参数参与 ParameterHandler 的设置。
下篇预告
下一篇讲事务管理——JdbcTransaction 和 ManagedTransaction 有什么区别?MyBatis 和 Spring 集成后,事务到底是谁在管?这也是面试常问的问题。
本系列文章基于 MyBatis 3.5.x 源码,写作时对照源码逐行验证。如果发现有问题的地方,欢迎指正。
夜雨聆风