动态 SQL 中插入脚本语言
概述
MyBatis 从 3.2 版本开始支持可插拔脚本语言(Pluggable Scripting Language),允许开发者用自定义的脚本语言驱动来编写动态 SQL,替代默认的 OGNL 表达式。你可以接入 Velocity、FreeMarker 甚至 Groovy 作为 SQL 模板引擎,让动态 SQL 的编写方式彻底改变。
飞翔科技的技术总监大翔在一个报表系统中引入了 Velocity 引擎,让原本嵌套 5 层的 <if> <choose> 标签变成了一段干净的可读模板。
LanguageDriver 机制
MyBatis 的动态 SQL 处理核心在 LanguageDriver 接口,它负责两件事:
- 创建 SqlSource:把 XML 中的 SQL 脚本(或注解中的 SQL 字符串)解析成可执行的
SqlSource对象 - 创建 ParameterHandler:处理 SQL 参数绑定
public interface LanguageDriver {
// 从 XML 节点创建 SqlSource(XML 映射文件方式)
SqlSource createSqlSource(Configuration configuration,
XNode script, Class<?> parameterType);
// 从字符串创建 SqlSource(注解方式)
SqlSource createSqlSource(Configuration configuration,
String script, Class<?> parameterType);
// 创建参数处理器
ParameterHandler createParameterHandler(
MappedStatement mappedStatement,
Object parameterObject, BoundSql boundSql);
}
MyBatis 核心组件交互流程
自定义 LanguageDriver 实现
下面实现一个 简单模板驱动,支持 ${param} 占位符替换(仅用于演示原理,生产请用 MyBatis-Velocity 正式项目):
package com.feixiang.mybatis.scripting;
import org.apache.ibatis.builder.xml.XMLMapperEntityResolver;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.parsing.XNode;
import org.apache.ibatis.scripting.LanguageDriver;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 简易模板 LanguageDriver — 演示原理
* 支持 ${paramName} 占位符替换为 MyBatis 的 #{paramName} 预编译参数
*/
public class SimpleTemplateDriver extends XMLLanguageDriver {
// 匹配 ${变量名} —— 非贪婪
private static final Pattern TEMPLATE_PATTERN =
Pattern.compile("\\$\\{(\\w+)\\}");
@Override
public SqlSource createSqlSource(Configuration configuration,
XNode script,
Class<?> parameterType) {
// 1. 先让 XMLLanguageDriver 按标准 XML 方式处理
// (支持 <if> <where> <foreach> 等动态标签)
SqlSource original = super.createSqlSource(
configuration, script, parameterType);
// 2. 包装一层:在 getBoundSql 时把 ${xxx} 替换为 #{xxx}
return new SqlSource() {
@Override
public BoundSql getBoundSql(Object parameterObject) {
BoundSql boundSql = original.getBoundSql(parameterObject);
String sql = boundSql.getSql();
// 提取 ${xxx} 中的变量名
List<String> paramNames = new ArrayList<>();
Matcher matcher = TEMPLATE_PATTERN.matcher(sql);
while (matcher.find()) {
paramNames.add(matcher.group(1));
}
// 构建新的 ParameterMapping 列表
List<ParameterMapping> newMappings =
new ArrayList<>(boundSql.getParameterMappings());
for (String name : paramNames) {
ParameterMapping mapping =
new ParameterMapping.Builder(
configuration, name, Object.class).build();
newMappings.add(mapping);
}
// 把 ${xxx} 替换为 #{xxx} 作为 MyBatis 标准占位符
String newSql = TEMPLATE_PATTERN.matcher(sql)
.replaceAll("#{$1}");
return new BoundSql(configuration, newSql,
newMappings, parameterObject);
}
};
}
@Override
public SqlSource createSqlSource(Configuration configuration,
String script,
Class<?> parameterType) {
// 注解模式:直接处理字符串
String processed = TEMPLATE_PATTERN.matcher(script)
.replaceAll("#{$1}");
return new XMLLanguageDriver().createSqlSource(
configuration, processed, parameterType);
}
@Override
public ParameterHandler createParameterHandler(
MappedStatement mappedStatement,
Object parameterObject, BoundSql boundSql) {
// 使用默认的参数处理器即可
return new DefaultParameterHandler(
mappedStatement, parameterObject, boundSql);
}
}
注册自定义 LanguageDriver
在 mybatis-config.xml 中注册别名并设为默认:
<configuration>
<typeAliases>
<typeAlias type="com.feixiang.mybatis.scripting.SimpleTemplateDriver"
alias="simpleTemplate"/>
</typeAliases>
<settings>
<!-- 设为全局默认脚本语言 -->
<setting name="defaultScriptingLanguage" value="simpleTemplate"/>
</settings>
</configuration>
三种指定方式
对比总览
| 指定方式 | 作用范围 | 配置位置 | 优先级 |
|---|---|---|---|
| 全局默认 | 所有 Mapper 的所有 SQL 语句 | mybatis-config.xml 的 <settings> | 最低 |
语句级 lang 属性 | 单个 <select>/<insert>/<update>/<delete> | Mapper XML 中 | 中 |
@Lang 注解 | 单个 Mapper 方法 | Mapper 接口上 | 最高 |
方式一:全局默认
<settings>
<setting name="defaultScriptingLanguage" value="simpleTemplate"/>
</settings>
所有未显式指定语言的 SQL 语句都使用 SimpleTemplateDriver。
方式二:语句级 lang 属性
<mapper namespace="com.feixiang.mapper.EmployeeMapper">
<!-- 仅此语句使用 simpleTemplate -->
<select id="findByCondition" lang="simpleTemplate">
SELECT * FROM t_employee
WHERE 1=1
<if test="name != null">
AND name = ${name}
</if>
<if test="deptId != null">
AND dept_id = ${deptId}
</if>
</select>
<!-- 未指定 lang,使用全局默认 -->
<select id="findById" resultType="Employee">
SELECT * FROM t_employee WHERE id = #{id}
</select>
</mapper>
方式三:@Lang 注解
public interface EmployeeMapper {
// 注解指定,优先级最高
@Lang(SimpleTemplateDriver.class)
@Select("SELECT * FROM t_employee WHERE name = ${name}")
Employee findByName(@Param("name") String name);
// 未指定,使用全局默认
@Select("SELECT * FROM t_employee WHERE id = #{id}")
Employee findById(int id);
}
飞翔科技实战:报表系统的模板化 SQL
场景
孔蓝负责的报表模块需要根据 10 多个筛选条件动态生成 SQL,XML 中 <if> 标签嵌套了 5 层,代码审查时被大翔打回:
大翔:"这种嵌套深度没人能维护。引入 Velocity 脚本语言,把 SQL 模板化。"
改造前(XML 地狱)
<select id="buildReport" resultType="map">
SELECT * FROM t_report
<where>
<if test="startDate != null">
AND create_time >= #{startDate}
</if>
<if test="endDate != null">
AND create_time <= #{endDate}
</if>
<choose>
<when test="deptId != null">
AND dept_id = #{deptId}
</when>
<otherwise>
<if test="region != null">
AND region = #{region}
</if>
</otherwise>
</choose>
<!-- ... 还有 7 层嵌套 ... -->
</where>
</select>
改造后(Velocity 模板)
<select id="buildReport" resultType="map" lang="velocity">
SELECT * FROM t_report
WHERE 1=1
#if($_parameter.startDate)
AND create_time >= @{startDate}
#end
#if($_parameter.endDate)
AND create_time <= @{endDate}
#end
#if($_parameter.deptId)
AND dept_id = @{deptId}
#elseif($_parameter.region)
AND region = @{region}
#end
</select>
SQL 模板现在是一个平坦结构,黄俪(测试)一眼就能看懂所有分支逻辑。
MyBatis-Velocity 项目
官方提供了 Velocity 集成:mybatis-velocity。使用方法:
- 添加 Maven 依赖:
<dependency>
<groupId>org.mybatis.scripting</groupId>
<artifactId>mybatis-velocity</artifactId>
<version>2.1.2</version>
</dependency>
- 注册别名:
<typeAliases>
<typeAlias type="org.mybatis.scripting.velocity.VelocityLanguageDriver"
alias="velocity"/>
</typeAliases>
- 使用
lang="velocity"即可。
易错场景提醒
1. 混淆 ${} 和 #{}
在自定义驱动中,${xxx} 和 #{xxx} 的行为由 LanguageDriver 决定。
如果没有正确处理,${xxx} 可能变成字符串拼接(SQL 注入风险),
而不是 MyBatis 的预编译占位符。务必在驱动中将变量转为 ParameterMapping。
2. 全局改默认语言影响所有语句
如果全局设置 defaultScriptingLanguage=velocity,但 XML 中有
对 OGNL 表达式的依赖(如 <if test="...">),可能会解析失败。
建议:只在需要的语句上用 lang 属性单独指定。
3. @Lang 注解的优先级陷阱
// 即使全局默认是 velocity,这个方法也被 @Lang 锁定为 XMLLanguageDriver
@Lang(XMLLanguageDriver.class)
@Select("SELECT * FROM t WHERE name = #{name}")
Employee findByName(String name);
4. 自定义驱动忘记处理参数映射
LanguageDriver.createSqlSource 返回的 SqlSource 必须正确生成
ParameterMapping 列表,否则 MyBatis 不知道有哪些参数需要设置,
运行时会抛出 ParameterNotFoundException。
5. 生产环境慎用自研驱动
自研 LanguageDriver 需要覆盖各种边界情况(动态标签嵌套、foreach、bind 等)。
官方推荐的 mybatis-velocity 项目经过了充分测试,生产环境优先选用。
面试考点
MyBatis 的脚本语言机制由哪个接口定义?
LanguageDriver,包含createSqlSource和createParameterHandler两个核心方法。
如何为 MyBatis 替换默认的 OGNL 脚本语言?
- 实现
LanguageDriver接口(或继承XMLLanguageDriver),注册类型别名,通过defaultScriptingLanguage设置全局默认,或通过lang属性 /@Lang注解局部指定。
- 实现
三种指定脚本语言的方式优先级如何?
@Lang注解 >lang属性 > 全局defaultScriptingLanguage。
MyBatis 官方支持的替代脚本语言是什么?
- Apache Velocity(mybatis-velocity 项目)。也有社区支持的 FreeMarker 驱动。
自定义脚本驱动最需要注意什么?
- 正确处理参数映射(ParameterMapping),防止
${}退化为字符串拼接导致 SQL 注入。
- 正确处理参数映射(ParameterMapping),防止