第08篇 · MyBatis动态SQL:OGNL驱动的SQL组装艺术

如果你完整写过几个 MyBatis 的 XML 映射文件,你大概会遇到这样一个场景:一个查询接口,前端可能传姓名、可能传年龄范围、可能什么都不传------你要根据这些条件动态决定 SQL 的 WHERE 子句长什么样。

用 Java 代码拼接 SQL 字符串,是个非常痛苦的事情。空格多了少了、AND 和 OR 的位置对不对、最后一项后面有没有多余的逗号------这些细节足以让一个下午变得无比漫长。如果你有 JPA 或 Hibernate 的使用经验,你可能知道它们用 Criteria API 来解决这个问题。而 MyBatis 的方式是:在 XML 里写标签,让 SQL 自己"长"出来

这一篇,我们把 MyBatis 动态 SQL 的核心标签和底层原理讲清楚。

学习目标

  • 理解动态 SQL 的诞生背景------拼接 SQL 是"程序员最痛苦的经历之一"
  • 掌握核心动态标签的用法(ifwheresettrimforeachchoose
  • 理解 MyBatis 动态 SQL 的底层原理------OGNL 表达式解析
  • 掌握 <sql><include> 实现 SQL 片段的复用

正文

一、为什么需要动态 SQL

先看一个典型的场景:用户列表查询,支持按用户名模糊搜索、按年龄范围筛选、按性别过滤。前端可能传任意组合的参数,也可能什么都不传。

如果用 JdbcTemplate 或原生 JDBC,你的代码大概是这样的:

java 复制代码
StringBuilder sql = new StringBuilder("SELECT * FROM users WHERE 1=1");
List<Object> params = new ArrayList<>();

if (username != null && !username.isEmpty()) {
    sql.append(" AND username LIKE ?");
    params.add("%" + username + "%");
}
if (minAge != null) {
    sql.append(" AND age >= ?");
    params.add(minAge);
}
if (maxAge != null) {
    sql.append(" AND age <= ?");
    params.add(maxAge);
}
if (gender != null) {
    sql.append(" AND gender = ?");
    params.add(gender);
}
// 还要处理排序、分页...

这段代码的问题很明显:

  • SQL 结构和 Java 代码混在一起,可读性差
  • 每增加一个查询条件,就要改 Java 代码、重新编译
  • 字符串拼接容易出错------少个空格、多个 AND,运行时才发现
  • WHERE 1=1 这种写法虽然能解决问题,但总让人感觉不够优雅

MyBatis 的动态 SQL,就是把"根据条件拼接 SQL"这件事从 Java 代码挪到了 XML 里,用标签来表达条件逻辑。XML 里的 SQL 更接近真实的 SQL 语法,可读性更好,维护起来也更直观。

MyBatis 官方文档对动态 SQL 的定位说得很直接:"如果你用过 JDBC 或其他类似框架,你就会理解有条件地拼接 SQL 字符串有多么痛苦------要确保不忘掉空格,或不要省略列名列表末尾的逗号。"

二、核心标签详解

MyBatis 动态 SQL 的核心标签有四个:ifchoose (when, otherwise)trim (where, set)foreach。我们逐一来看。

1. if:最基础的条件判断

if 是最常用的标签,用法和编程语言里的 if 类似------条件成立时,标签内的 SQL 片段才会被加入最终语句。

xml 复制代码
<select id="findActiveBlogLike" resultType="Blog">
    SELECT * FROM BLOG
    WHERE state = 'ACTIVE'
    <if test="title != null">
        AND title like #{title}
    </if>
    <if test="author != null and author.name != null">
        AND author_name like #{author.name}
    </if>
</select>

如果 titlenull,第一个 if 不会生成任何内容;如果 title 有值,AND title like ? 就会被拼接到 SQL 中。

关键点test 属性的值是一个 OGNL 表达式 ,返回值必须是布尔类型。author != null and author.name != null 这种链式访问是 OGNL 的对象导航能力------你可以一路点下去访问嵌套对象的属性。

2. choose / when / otherwise:多路选择

if 是"每个条件独立判断,符合条件的都加上"。但有时候你需要的是"从多个候选中选一个"------类似 Java 的 switch 语句。

xml 复制代码
<select id="findActiveBlogLike" resultType="Blog">
    SELECT * FROM BLOG
    WHERE state = 'ACTIVE'
    <choose>
        <when test="title != null">
            AND title like #{title}
        </when>
        <when test="author != null and author.name != null">
            AND author_name like #{author.name}
        </when>
        <otherwise>
            AND featured = 1
        </otherwise>
    </choose>
</select>

逻辑是:先判断 title,有值就用 title 条件;否则判断 author,有值就用 author 条件;如果都没有,就用 featured = 1 作为兜底。choose 只取第一个匹配的 when,匹配后不再继续判断后面的条件。

3. where / set:解决"多余的连接词"

if 有一个经典的问题:如果所有条件都不满足,WHERE 后面直接跟了个空,SQL 就语法错误了

xml 复制代码
<select id="findBlog" resultType="Blog">
    SELECT * FROM BLOG
    WHERE
    <if test="state != null">
        state = #{state}
    </if>
    <if test="title != null">
        AND title like #{title}
    </if>
</select>

如果 statetitle 都是 null,生成的 SQL 是 SELECT * FROM BLOG WHERE ------语法错误。

更隐蔽的问题:如果 statenulltitle 有值,生成的 SQL 是 SELECT * FROM BLOG WHERE AND title like ? ------多了一个 AND

where 标签解决的就是这两个问题

xml 复制代码
<select id="findBlog" resultType="Blog">
    SELECT * FROM BLOG
    <where>
        <if test="state != null">
            state = #{state}
        </if>
        <if test="title != null">
            AND title like #{title}
        </if>
    </where>
</select>

<where> 的行为很智能:

  • 只有内部至少有一个条件成立时,才会输出 WHERE 关键字
  • 如果生成的 SQL 以 ANDOR 开头,会自动去掉

set 标签做类似的事情,但针对 UPDATE 语句

xml 复制代码
<update id="updateUser">
    UPDATE users
    <set>
        <if test="username != null">username = #{username},</if>
        <if test="email != null">email = #{email},</if>
        <if test="age != null">age = #{age},</if>
    </set>
    WHERE id = #{id}
</update>

<set> 会动态添加 SET 关键字,并自动去掉最后一个字段后面多余的逗号。

4. trim:更灵活的定制

whereset 实际上是 trim 的特例。trim 提供了更精细的控制:

xml 复制代码
<trim prefix="WHERE" prefixOverrides="AND |OR ">
    ...
</trim>

prefix 表示在内容前添加什么,prefixOverrides 表示移除内容开头的哪些字符串。where 等价于 prefix="WHERE" prefixOverrides="AND |OR "

同理,set 等价于 prefix="SET" suffixOverrides=","

当你需要更复杂的拼接逻辑时(比如在 INSERT 中动态处理列名和值),trim 就派上用场了。

5. foreach:处理集合遍历

这是动态 SQL 里另一个高频使用的标签,主要用在两个场景:IN 查询批量插入

IN 查询

xml 复制代码
<select id="findByIds" resultType="User">
    SELECT * FROM users
    WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

传入 ids = [1, 2, 3],生成的 SQL 是 SELECT * FROM users WHERE id IN (1, 2, 3)

foreach 的属性含义:

  • collection:要遍历的集合名称(后面会详细讲怎么传参)
  • item:每次迭代时当前元素的变量名
  • open:循环开始前添加的字符串
  • close:循环结束后添加的字符串
  • separator:元素之间的分隔符

批量插入

xml 复制代码
<insert id="batchInsert">
    INSERT INTO users (username, email, age) VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.username}, #{user.email}, #{user.age})
    </foreach>
</insert>
6. bind:变量绑定

bind 用于在 OGNL 表达式中创建一个新变量,在 SQL 中引用。常见于模糊查询:

xml 复制代码
<select id="findByUsername" resultType="User">
    <bind name="pattern" value="'%' + username + '%'"/>
    SELECT * FROM users WHERE username LIKE #{pattern}
</select>

这样就不用每次在 Java 代码里拼接 % 了。

7. sql + include:SQL 片段复用

如果你发现多个查询语句中重复出现相同的字段列表或相同的 WHERE 条件,可以用 <sql> 定义片段,用 <include> 引用。

xml 复制代码
<!-- 定义字段列表 -->
<sql id="userColumns">
    id, username, email, age, create_time
</sql>

<!-- 定义通用的 WHERE 条件 -->
<sql id="userWhere">
    <where>
        <if test="username != null">
            AND username LIKE CONCAT('%', #{username}, '%')
        </if>
        <if test="email != null">
            AND email = #{email}
        </if>
    </where>
</sql>

<!-- 使用 -->
<select id="findAll" resultType="User">
    SELECT <include refid="userColumns"/>
    FROM users
</select>

<select id="findByCondition" resultType="User">
    SELECT <include refid="userColumns"/>
    FROM users
    <include refid="userWhere"/>
</select>

这样,字段列表和条件逻辑只需维护一份,修改时所有引用的地方自动生效。

三、OGNL 原理浅析

动态 SQL 的"动态"二字,靠的是 OGNL(Object-Graph Navigation Language) 表达式引擎。

OGNL 是一种表达式语言,它的核心能力是:通过字符串表达式访问 Java 对象图中的任意属性、调用方法、进行逻辑运算

在 MyBatis 的动态 SQL 标签中,test 属性的值就是一个 OGNL 表达式。MyBatis 在解析 XML 时,会把 test 表达式交给 OGNL 引擎求值------结果是 true,标签内的 SQL 片段就保留;结果是 false,就丢弃。

OGNL 能做什么?

能力 表达式示例 说明
访问属性 user.name 获取 user 对象的 name 属性
访问嵌套属性 user.address.city 链式导航
调用方法 name.startsWith('A') 调用字符串的 startsWith 方法
逻辑运算 age != null and age > 18 与或非、比较运算
集合访问 list[0]map['key'] 访问集合中的元素

动态 SQL 的解析时机

MyBatis 在启动时会把 XML 中的动态 SQL 解析成一棵 SqlNode 树,存放在 MappedStatement 中。真正执行时,DynamicSqlSource.getBoundSql() 方法会遍历这棵树:遇到静态文本直接拼接,遇到动态标签就用 OGNL 对参数求值,决定是否包含对应的 SQL 片段。

java 复制代码
// DynamicSqlSource 的核心逻辑(简化)
public BoundSql getBoundSql(Object parameterObject) {
    DynamicContext context = new DynamicContext(configuration, parameterObject);
    rootSqlNode.apply(context);  // 遍历 SqlNode 树,动态生成 SQL
    // 解析 SQL 中的 #{}
    return sqlSourceParser.parse(context.getSql()).getBoundSql(parameterObject);
}

OGNL 的引入,让 MyBatis 的动态 SQL 有了极大的灵活性------你可以在 test 里写复杂的条件判断,甚至可以调用参数对象的方法。但灵活性也意味着责任:OGNL 表达式中不要包含来自用户输入的不可信内容,否则存在安全隐患。

代码示例

示例一:多条件查询(if + where)

这是一个用户管理系统的典型场景:支持按用户名模糊搜索、按性别筛选、按年龄范围查询。

Mapper 接口

java 复制代码
package com.example.demo.mapper;

import com.example.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;

@Mapper
public interface UserMapper {

    /**
     * 多条件查询用户列表
     * @param username 用户名(模糊匹配)
     * @param gender 性别
     * @param minAge 最小年龄
     * @param maxAge 最大年龄
     */
    List<User> findUsers(@Param("username") String username,
                         @Param("gender") String gender,
                         @Param("minAge") Integer minAge,
                         @Param("maxAge") Integer maxAge);
}

XML 映射文件

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.UserMapper">

    <select id="findUsers" resultType="com.example.demo.entity.User">
        SELECT id, username, email, gender, age, create_time
        FROM users
        <where>
            <if test="username != null and username != ''">
                AND username LIKE CONCAT('%', #{username}, '%')
            </if>
            <if test="gender != null and gender != ''">
                AND gender = #{gender}
            </if>
            <if test="minAge != null">
                AND age >= #{minAge}
            </if>
            <if test="maxAge != null">
                AND age &lt;= #{maxAge}
            </if>
        </where>
        ORDER BY create_time DESC
    </select>

</mapper>

关键观察

  • 当所有参数都为 null 时,<where> 不输出任何内容,SQL 变为 SELECT ... FROM users ORDER BY create_time DESC
  • 当只有 username 有值时,<where> 输出 WHERE username LIKE CONCAT('%', ?, '%'),开头的 AND 被自动去掉
  • age &lt;= #{maxAge} 中的 &lt;= 是 XML 转义,因为 < 在 XML 中会被解析为标签开始符

示例二:批量删除(foreach)

批量删除是 foreach 最典型的使用场景。

Mapper 接口

java 复制代码
void deleteByIds(@Param("ids") List<Integer> ids);

XML 映射文件

xml 复制代码
<delete id="deleteByIds">
    DELETE FROM users
    WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</delete>

传入 ids = Arrays.asList(1, 2, 3, 5, 8),生成的 SQL 为:

sql 复制代码
DELETE FROM users WHERE id IN (1, 2, 3, 5, 8)

collection 属性的取值规则(这是新手最容易写错的地方):

参数类型 collection 说明
直接传入 List list 默认名称
直接传入数组 array 默认名称
使用 @Param("ids") ids 注解指定的名称,优先使用
单个对象中的集合属性 属性名 user.orderIds

新手错误 vs 正确姿势

错误表象 根本原因 正确姿势
<where> 生成的 SQL 开头多了一个 AND,查询结果不对 <where> 内部的第一个 <if> 中写了 AND,但 <where> 只会去掉开头AND。如果第一个条件不成立、第二个条件成立,第二个条件的 AND 在开头,会被正确去掉 所有 <if> 内部的连接词都写 ANDOR,让 <where> 自动处理第一个
foreachcollection 写错导致报错 There is no getter for property 不理解参数类型对应的 collection 默认名称 单参 Listlist,数组用 array;建议统一使用 @Param 显式指定名称
<set> 中所有条件都不满足,生成的 SQL 是 UPDATE users SET WHERE id = ?,语法错误 未考虑所有更新字段都为 null 的边界情况 在业务层校验,确保至少有一个字段要更新;或使用 <trim> 配合条件判断做兜底
OGNL 表达式中用 = 而不是 == 做相等判断 混淆了 XML 属性和 Java 代码 OGNL 表达式遵循 Java 语法:相等用 ==,字符串比较用 equals()==(字面量)

疑难深度追问

Q1:MyBatis 解析动态 SQL 是在哪个阶段完成的?

分两个阶段。第一阶段是启动时 :MyBatis 解析 XML 文件,将动态标签(<if><where> 等)解析成 SqlNode 对象树,存储在 MappedStatement 中。第二阶段是执行时DynamicSqlSource.getBoundSql() 被调用,遍历 SqlNode 树,用 OGNL 对参数求值,动态生成最终的 SQL 字符串。

Q2:为什么动态 SQL 的标签中可以使用对象的嵌套属性(如 user.address.city)?

因为 OGNL 支持对象导航(Object Navigation)。当你在 test 中写 user.address.city 时,OGNL 会从传入的参数对象开始,依次调用 getUser()getAddress()getCity() 来获取值。如果中间某个属性为 null,OGNL 会返回 null 而不会抛出 NullPointerException------这让条件判断更安全。

Q3:<trim> 的四个属性分别控制什么?能否用 <trim> 替代 <where><set>

  • prefix:在内容前面添加的字符串
  • suffix:在内容后面添加的字符串
  • prefixOverrides:从内容开头 移除的字符串列表(用 | 分隔)
  • suffixOverrides:从内容结尾移除的字符串列表

<trim prefix="WHERE" prefixOverrides="AND |OR "> 完全等价于 <where><trim prefix="SET" suffixOverrides=","> 完全等价于 <set>。所以 trim 可以替代 whereset,但反过来不行------trim 更灵活,而 whereset 更语义化,代码可读性更好。

思考与延伸

  1. 动手验证 :在 Spring Boot 项目中开启 MyBatis 的 SQL 日志(logging.level.com.example.demo.mapper=DEBUG),分别调用 findUsers 方法传入不同的参数组合,观察最终生成的 SQL 语句,验证 <where>AND 的处理是否符合预期。

  2. 思考题 :如果需要支持"姓名包含 A 或 B"这样的 OR 条件,用动态 SQL 怎么实现?提示:<if> 中可以用 OGNL 表达式组合多个条件。

  3. 延伸阅读 :MyBatis 官方文档的"Dynamic SQL"章节对每个标签都有详细的说明和示例。另外,Apache Commons 的 OGNL 官方语言指南对 OGNL 的语法有完整介绍------如果你需要在 test 中写更复杂的表达式,这份文档会很有帮助。

参考与延伸阅读

  • MyBatis. MyBatis 3 | Dynamic SQL. mybatis.org, 2025-01-02
  • Apache Commons. OGNL Language Guide. commons.apache.org
  • 阿里云开发者社区. MyBatis动态SQL解析原理. 阿里云开发者社区, 2023-07-23
  • 腾讯云. MyBatis 动态 SQL 为什么这么灵活?背后靠的是 OGNL. 腾讯云, 2025-12-27
  • MySql教程网. OGNL 表达式是如何助力 MyBatis 实现动态 SQL 的?. mysql360.com, 2025-01-18