script 元素:在注解映射器中启用动态 SQL
导学
在前面的章节中,我们学习了 XML 映射器中的动态 SQL——<if>、<where>、<set>、<foreach> 等标签让 SQL 具备了条件判断和循环能力。然而,很多开发者更偏爱注解方式编写 Mapper,因为它省去了 XML 文件的维护成本,代码更加紧凑直观。
但注解方式有一个天然的限制:Java 注解的值是字符串常量,无法直接嵌入 XML 标签。如果你尝试在 @Select 中写 <if>,MyBatis 会把它当成纯文本 SQL 的一部分,导致语法错误。
script 元素正是为了解决这一痛点而生。它允许你在注解的字符串值中,用 <script> 标签包裹标准的动态 SQL 片段,从而让注解映射器也能享受到动态 SQL 的全部能力。
定义与作用
script 是 MyBatis 提供的一个特殊 XML 根标签,专门用于注解映射器的字符串值中。它的作用是将包裹在内的内容识别为动态 SQL 片段,交由 MyBatis 的动态 SQL 引擎解析,而不是当作普通 SQL 文本直接提交给数据库。
当 MyBatis 解析到带有 <script> 的注解字符串时,会将其交给 XMLLanguageDriver 处理,内部的 <if>、<where>、<set>、<foreach> 等标签会被正常解析并生成最终 SQL。
核心原理
解析流程
关键说明
| 组件 | 职责 |
|---|---|
RawLanguageDriver | 处理不含 <script> 的注解字符串,直接作为静态 SQL |
XMLLanguageDriver | 处理含 <script> 的注解字符串,解析动态 SQL 标签 |
DynamicSqlSource | 根据运行时参数,通过 OGNL 计算动态标签条件,生成最终 SQL |
OgnlCache | 缓存 OGNL 表达式解析结果,避免重复编译,提升性能 |
MyBatis 在启动时会为每个注解方法选择合适的 LanguageDriver。一旦检测到字符串以 <script> 开头,就会自动切换到 XML 解析模式,因此开发者无需额外配置。
script 元素属性说明
| 属性 | 是否必填 | 说明 |
|---|---|---|
| 无显式属性 | — | <script> 本身不需要任何属性,它只是一个标识根标签 |
| 内部可嵌套标签 | — | <if>、<choose>、<when>、<otherwise>、<where>、<set>、<trim>、<foreach>、<bind> 等所有 XML 动态 SQL 标签 |
示例一:@Select 中使用 script + if 实现动态条件查询
场景说明
需要根据学生姓名和专业的可选条件查询学生信息。当传入参数中有 name 或 major 时,SQL 自动追加 WHERE 条件;当两个参数都为空时,查询全部学生。
操作前数据
| id | name | age | major | score |
|---|---|---|---|---|
| 1 | 大翔 | 22 | 计算机科学 | 95.50 |
| 2 | 白歌 | 21 | 软件工程 | 88.00 |
| 3 | 小崔 | 20 | 计算机科学 | 92.00 |
| 4 | 黄俪 | 21 | 信息安全 | 90.50 |
| 5 | 李眉 | 22 | 软件工程 | 87.00 |
Mapper 接口代码
package com.flying.mapper;
import com.flying.entity.Student;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface StudentMapper {
/**
* 根据可选条件动态查询学生
* @param name 学生姓名(可选)
* @param major 专业(可选)
* @return 符合条件的学生列表
*/
@Select("<script>"
+ "SELECT id, name, age, major, score FROM student"
+ "<where>"
+ " <if test='name != null'> AND name = #{name} </if>"
+ " <if test='major != null'> AND major = #{major} </if>"
+ "</where>"
+ "</script>")
List<Student> findByCondition(@Param("name") String name, @Param("major") String major);
}
生成的 SQL 语句展示
场景 A:两个参数都传入
传入参数:name = "大翔", major = "计算机科学"
SELECT id, name, age, major, score FROM student WHERE name = ? AND major = ?
场景 B:只传入 name
传入参数:name = "白歌", major = null
SELECT id, name, age, major, score FROM student WHERE name = ?
场景 C:两个参数都为 null
传入参数:name = null, major = null
SELECT id, name, age, major, score FROM student
执行结果
场景 A 结果:
| id | name | age | major | score |
|---|---|---|---|---|
| 1 | 大翔 | 22 | 计算机科学 | 95.50 |
场景 B 结果:
| id | name | age | major | score |
|---|---|---|---|---|
| 2 | 白歌 | 21 | 软件工程 | 88.00 |
场景 C 结果:
| id | name | age | major | score |
|---|---|---|---|---|
| 1 | 大翔 | 22 | 计算机科学 | 95.50 |
| 2 | 白歌 | 21 | 软件工程 | 88.00 |
| 3 | 小崔 | 20 | 计算机科学 | 92.00 |
| 4 | 黄俪 | 21 | 信息安全 | 90.50 |
| 5 | 李眉 | 22 | 软件工程 | 87.00 |
分析
<where> 标签在这里起到了关键作用:当内部没有任何 <if> 条件满足时,它不会生成 WHERE 关键字;当有条件满足时,它会自动处理开头的 AND 或 OR,确保 SQL 语法正确。这避免了手动拼接 SQL 时容易出现的 "WHERE AND" 语法错误。
示例二:@Update 中使用 script + set 实现动态更新
场景说明
更新学生信息时,只更新传入的非空字段。例如,只想修改小崔的年龄,而不修改其他字段,此时 SQL 应该只包含 age 的更新逻辑。
操作前数据
| id | name | age | major | score |
|---|---|---|---|---|
| 3 | 小崔 | 20 | 计算机科学 | 92.00 |
Mapper 接口代码
package com.flying.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface StudentMapper {
/**
* 动态更新学生信息,只更新非空字段
* @param id 学生ID(必填)
* @param name 姓名(可选)
* @param age 年龄(可选)
* @param major 专业(可选)
* @param score 成绩(可选)
* @return 影响的行数
*/
@Update("<script>"
+ "UPDATE student"
+ "<set>"
+ " <if test='name != null'> name = #{name}, </if>"
+ " <if test='age != null'> age = #{age}, </if>"
+ " <if test='major != null'> major = #{major}, </if>"
+ " <if test='score != null'> score = #{score}, </if>"
+ "</set>"
+ "WHERE id = #{id}"
+ "</script>")
int updateStudentDynamic(@Param("id") Integer id,
@Param("name") String name,
@Param("age") Integer age,
@Param("major") String major,
@Param("score") Double score);
}
生成的 SQL 语句展示
场景 A:只更新年龄
传入参数:id = 3, age = 21,其余为 null
UPDATE student SET age = ? WHERE id = ?
场景 B:更新姓名和专业
传入参数:id = 3, name = "小崔崔", major = "人工智能"
UPDATE student SET name = ?, major = ? WHERE id = ?
执行结果
场景 A 执行后:
| id | name | age | major | score |
|---|---|---|---|---|
| 3 | 小崔 | 21 | 计算机科学 | 92.00 |
场景 B 执行后:
| id | name | age | major | score |
|---|---|---|---|---|
| 3 | 小崔崔 | 21 | 人工智能 | 92.00 |
分析
<set> 标签与 <where> 类似,它会自动处理末尾多余的逗号。如果没有字段需要更新,<set> 不会生成 SET 关键字,但此时 WHERE id = #{id} 仍然存在,SQL 会变成 UPDATE student WHERE id = ?,这在大多数数据库中是语法错误的。因此,动态更新时通常至少保证有一个字段会被更新,或者在业务层做校验。
示例三:@Insert 中使用 script + foreach 实现批量插入
场景说明
需要一次性插入多条学生记录。使用 <foreach> 遍历传入的列表,生成多组 VALUES 子句,实现批量插入,减少与数据库的交互次数。
操作前数据
当前 student 表已有 5 条记录(id 从 1 到 5)。
Mapper 接口代码
package com.flying.mapper;
import com.flying.entity.Student;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
public interface StudentMapper {
/**
* 批量插入学生记录
* @param students 学生列表
* @return 影响的行数
*/
@Insert("<script>"
+ "INSERT INTO student (name, age, major, score) VALUES"
+ "<foreach collection='students' item='s' separator=','>"
+ " (#{s.name}, #{s.age}, #{s.major}, #{s.score})"
+ "</foreach>"
+ "</script>")
int batchInsertStudents(@Param("students") List<Student> students);
}
调用代码
List<Student> newStudents = Arrays.asList(
new Student("大翔", 23, "数据科学", 91.5),
new Student("白歌", 22, "网络安全", 89.0),
new Student("小崔", 21, "云计算", 93.5)
);
int rows = mapper.batchInsertStudents(newStudents);
System.out.println("批量插入成功,影响行数:" + rows);
生成的 SQL 语句展示
INSERT INTO student (name, age, major, score) VALUES
(?, ?, ?, ?),
(?, ?, ?, ?),
(?, ?, ?, ?)
实际参数绑定:
- 第一组:大翔, 23, 数据科学, 91.5
- 第二组:白歌, 22, 网络安全, 89.0
- 第三组:小崔, 21, 云计算, 93.5
执行结果
插入后 student 表数据:
| id | name | age | major | score |
|---|---|---|---|---|
| 1 | 大翔 | 22 | 计算机科学 | 95.50 |
| 2 | 白歌 | 21 | 软件工程 | 88.00 |
| 3 | 小崔 | 20 | 计算机科学 | 92.00 |
| 4 | 黄俪 | 21 | 信息安全 | 90.50 |
| 5 | 李眉 | 22 | 软件工程 | 87.00 |
| 6 | 大翔 | 23 | 数据科学 | 91.50 |
| 7 | 白歌 | 22 | 网络安全 | 89.00 |
| 8 | 小崔 | 21 | 云计算 | 93.50 |
分析
<foreach> 标签的 collection 属性对应传入的参数名(这里是 students),item 定义了每次迭代的局部变量名(这里是 s),separator 指定了每组之间的分隔符(这里是逗号)。注意在注解中引用列表内对象的属性时,使用 #{s.name} 的形式,其中 s 必须与 item 属性一致。
批量插入相比循环单条插入,性能提升显著。在 MySQL 5.7 中,单次批量插入的 VALUES 组数建议控制在 1000 组以内,避免 SQL 过长导致解析开销过大。
script 注解方式与 XML 方式的对比
| 对比维度 | 注解 + script 方式 | XML 方式 |
|---|---|---|
| 代码位置 | 与 Java 接口在一起,紧凑 | 独立的 XML 文件,分散 |
| 可读性 | 字符串拼接,复杂 SQL 可读性下降 | 原生 XML 标签,结构清晰 |
| IDE 支持 | SQL 语法高亮较弱 | 部分 IDE 支持 XML 内 SQL 高亮 |
| 动态 SQL 能力 | 完整支持,与 XML 等价 | 完整支持 |
| 维护成本 | 修改 SQL 需改 Java 文件,重新编译 | 修改 XML 即可,无需编译 |
| 适用场景 | 简单到中等复杂度的 SQL | 复杂 SQL、团队有 DBA 参与 |
选择建议
- 如果项目以注解方式为主,且 SQL 复杂度不高,使用
<script>是最佳实践,无需为了少量动态 SQL 引入 XML。 - 如果 SQL 非常长(超过 20 行),或者需要频繁由非 Java 开发人员调整,建议改用 XML 映射器。
- 混合使用也是常见做法:简单 CRUD 用注解,复杂报表查询用 XML。
易错场景
| 错误场景 | 错误表现 | 正确做法 |
|---|---|---|
忘记加 <script> 标签 | @Select 中的 <if> 被当作纯文本,数据库报 SQL 语法错误 | 所有动态 SQL 标签必须用 <script> 包裹 |
忘记用 @Param 注解参数 | OGNL 表达式无法识别参数名,报 There is no getter for property named 'xxx' | 多参数时必须加 @Param,单参数可不加 |
| 特殊字符未用 CDATA 包裹 | < 被 XML 解析器误认为是标签开头,报 XML 解析错误 | 在 <script> 内使用 <![CDATA[ < ]]> 包裹比较运算符 |
< 和 > 在 OGNL 表达式中直接使用 | test='age < 18' 导致 XML 解析失败 | 使用 test='age < 18' 或改用 > / <,或者将表达式放入 CDATA |
collection 参数名与 @Param 不一致 | foreach 找不到集合,报 There is no getter for property named 'students' | 确保 collection='students' 与 @Param("students") 一致 |
CDATA 使用示例
当 SQL 中需要用到 < 或 > 时,必须使用 CDATA 包裹:
@Select("<script>"
+ "<![CDATA["
+ "SELECT * FROM student WHERE age < #{maxAge} AND score > #{minScore}"
+ "]]>"
+ "</script>")
List<Student> findByRange(@Param("maxAge") int maxAge, @Param("minScore") double minScore);
或者使用 XML 实体转义:
@Select("<script>"
+ "SELECT * FROM student WHERE age < #{maxAge} AND score > #{minScore}"
+ "</script>")
面试考点
Q1:为什么注解方式中不能直接用 <if>,必须用 <script> 包裹?
A:MyBatis 的注解字符串默认由
RawLanguageDriver处理,直接作为静态 SQL 提交给数据库。只有检测到<script>标签时,才会切换到XMLLanguageDriver,将内容当作 XML 动态 SQL 解析。没有<script>标识,MyBatis 不会启动动态 SQL 引擎。
Q2:<script> 方式与 XML 方式的动态 SQL,在运行时性能上有差异吗?
A:没有本质差异。两者最终都会生成
DynamicSqlSource,由相同的 OGNL 引擎解析参数并生成最终 SQL。性能差异主要来自字符串拼接和 XML 解析的启动开销,但在运行时缓存机制(OgnlCache)的作用下,这一差异可以忽略不计。
Q3:在 <script> 中使用了 <where>,但生成的 SQL 仍然多出了一个 AND,可能是什么原因?
A:
<where>标签只能自动处理开头的AND或OR。如果<if>条件写在中间位置,且前面的静态 SQL 已经包含了WHERE关键字,那么<where>不会生效。正确的做法是让<where>作为WHERE关键字的唯一来源,不要在<where>外部再写WHERE。
Q4:批量插入时,如何控制单次插入的数据量,避免 SQL 过长?
A:在业务层对列表进行分批处理,例如每 500 或 1000 条调用一次
batchInsertStudents。MySQL 5.7 对单条 SQL 的长度有限制(max_allowed_packet默认 4MB),超过限制会导致连接断开。分批插入既能避免此问题,也能更好地控制事务粒度。
小结
script 元素是连接注解映射器与动态 SQL 世界的桥梁。通过 <script> 标签,开发者可以在 @Select、@Insert、@Update、@Delete 中完整使用 MyBatis 的所有动态 SQL 能力,包括条件判断、动态更新、批量操作等。
核心要点回顾:
<script>是注解方式使用动态 SQL 的唯一入口,不可省略。- 多参数时必须使用
@Param注解,确保 OGNL 表达式能正确识别参数名。 - SQL 中的特殊字符(
<、>)需用 CDATA 或 XML 实体转义,避免解析错误。 - 注解方式与 XML 方式在运行时能力完全等价,选择哪种方式取决于团队的开发习惯和 SQL 的复杂度。
下一章引子
掌握了 script 元素后,注解映射器已经具备了完整的动态 SQL 能力。但在实际企业开发中,一个常见的挑战是:同一套代码需要部署到不同的数据库环境中——开发用 MySQL,生产用 Oracle,测试用 SQL Server。不同数据库的 SQL 语法差异(如分页、字符串拼接、自增主键)该如何优雅处理?
下一节将介绍 _databaseId 与动态 SQL 的多数据库支持,学习如何在一套 Mapper 中根据数据库类型自动选择对应的 SQL 片段,实现真正的"一份代码,多处运行"。