java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > MyBatis动态SQL

MyBatis动态SQL:OGNL驱动的SQL组装艺术

作者:C137的本贾尼

MyBatis动态SQL通过if、where、set等等标签简化SQL拼接,提升代码可读性和维护性,OGNL表达式解析让条件判断更灵活,这篇文章给大家介绍MyBatis动态SQL:OGNL驱动的SQL组装艺术,感兴趣的朋友一起看看吧

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

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

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

学习目标

正文

一、为什么需要动态 SQL

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

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

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);
}
// 还要处理排序、分页...

这段代码的问题很明显:

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 片段才会被加入最终语句。

<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 语句。

<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 就语法错误了

<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 标签解决的就是这两个问题:

<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> 的行为很智能:

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

<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 提供了更精细的控制:

<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 查询

<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 的属性含义:

批量插入

<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 中引用。常见于模糊查询:

<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> 引用。

<!-- 定义字段列表 -->
<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 片段。

// 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 接口

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 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 <= #{maxAge}
            </if>
        </where>
        ORDER BY create_time DESC
    </select>
</mapper>

关键观察

示例二:批量删除(foreach)

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

Mapper 接口

void deleteByIds(@Param("ids") List<Integer> ids);

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 为:

DELETE FROM users WHERE id IN (1, 2, 3, 5, 8)

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

参数类型collection说明
直接传入 Listlist默认名称
直接传入数组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>

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

思考与延伸

参考与延伸阅读

到此这篇关于MyBatis动态SQL:OGNL驱动的SQL组装艺术的文章就介绍到这了,更多相关MyBatis动态SQL内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文