1. 为什么我们需要告别MyBatis的foreach模板?
在MyBatis的日常开发中,处理IN查询是最常见的场景之一。传统的做法是在XML映射文件中使用foreach标签动态生成IN语句,比如这样:
xml复制<select id="selectUsersByIds" resultType="User">
SELECT * FROM user
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
这种写法虽然能解决问题,但存在几个明显的痛点:
- 代码冗余:每个IN查询都需要重复编写几乎相同的foreach模板
- 可读性差:大量样板代码掩盖了SQL的核心逻辑
- 维护困难:当需要修改IN查询逻辑时,需要修改多处相似的代码
- 性能隐患:大列表IN查询可能导致SQL语句过长,影响数据库性能
实际项目中,我曾遇到过包含20多个IN查询的复杂报表SQL,foreach标签嵌套导致XML文件臃肿不堪,后来排查一个简单的条件逻辑花了整整半天时间。
2. 自定义MyBatis扩展的核心思路
2.1 理解MyBatis的SQL构建机制
MyBatis的SQL语句构建是通过SqlSource接口实现的,默认实现类DynamicSqlSource会处理动态SQL标签(如foreach、if等)。我们可以通过扩展这个机制,实现自定义的SQL构建逻辑。
关键接口和类:
SqlSource:SQL语句源接口LanguageDriver:语言驱动,负责创建SqlSourceSqlNode:SQL节点,处理动态SQL标签
2.2 设计自定义IN处理器
我们的目标是创建一个可以直接使用的<in>标签,简化后的使用方式如下:
xml复制<select id="selectUsersByIds" resultType="User">
SELECT * FROM user
WHERE id IN <in collection="ids"/>
</select>
实现方案:
- 自定义
InSqlNode实现SqlNode接口 - 扩展
XMLLanguageDriver支持新标签 - 注册自定义语言驱动
3. 完整实现步骤详解
3.1 创建自定义SqlNode
java复制public class InSqlNode implements SqlNode {
private final String collectionExpression;
public InSqlNode(String collectionExpression) {
this.collectionExpression = collectionExpression;
}
@Override
public boolean apply(DynamicContext context) {
Object parameter = context.getBindings().get("_parameter");
Object collection = OgnlCache.getValue(collectionExpression, parameter);
if (collection == null || CollectionUtils.isEmpty((Collection<?>) collection)) {
context.appendSql("(NULL)");
return true;
}
context.appendSql("(");
boolean first = true;
for (Object item : (Collection<?>) collection) {
if (!first) {
context.appendSql(",");
}
context.appendSql("#{");
context.appendSql("collection[" + context.getUniqueNumber() + "]");
context.appendSql("}");
first = false;
}
context.appendSql(")");
return true;
}
}
3.2 扩展语言驱动
java复制public class CustomLanguageDriver extends XMLLanguageDriver {
@Override
public SqlSource createSqlSource(Configuration configuration,
String script,
Class<?> parameterType) {
if (script.startsWith("<in")) {
Pattern pattern = Pattern.compile("<in\\s+collection=\"([^\"]+)\"\\s*/?>");
Matcher matcher = pattern.matcher(script);
if (matcher.find()) {
return new InSqlSource(configuration, matcher.group(1));
}
}
return super.createSqlSource(configuration, script, parameterType);
}
private static class InSqlSource implements SqlSource {
private final Configuration configuration;
private final String collectionExpression;
public InSqlSource(Configuration configuration, String collectionExpression) {
this.configuration = configuration;
this.collectionExpression = collectionExpression;
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
SqlNode sqlNode = new InSqlNode(collectionExpression);
DynamicContext context = new DynamicContext(configuration, parameterObject);
sqlNode.apply(context);
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
return sqlSourceParser.parse(context.getSql(), parameterObject.getClass(),
context.getBindings()).getBoundSql(parameterObject);
}
}
}
3.3 注册自定义驱动
在MyBatis配置文件中添加:
xml复制<configuration>
<settings>
<setting name="defaultScriptingLanguage" value="com.example.CustomLanguageDriver"/>
</settings>
</configuration>
或者在Mapper接口上使用注解:
java复制@Lang(CustomLanguageDriver.class)
List<User> selectUsersByIds(@Param("ids") List<Long> ids);
4. 高级功能扩展
4.1 支持数组和Iterable类型
修改InSqlNode的apply方法:
java复制if (collection.getClass().isArray()) {
collection = Arrays.asList((Object[]) collection);
} else if (!(collection instanceof Iterable)) {
throw new BuilderException("...");
}
4.2 添加大小限制防止SQL过长
java复制private static final int MAX_IN_SIZE = 1000;
// 在apply方法中添加检查
if (((Collection<?>) collection).size() > MAX_IN_SIZE) {
throw new BuilderException("IN clause size exceeds limit of " + MAX_IN_SIZE);
}
4.3 支持空集合处理策略
可以添加属性控制空集合时的行为:
xml复制<in collection="ids" emptyBehavior="NULL|EMPTY|EXCEPTION"/>
实现时根据配置决定是生成(NULL)、(1=0)还是抛出异常。
5. 性能优化与安全考量
5.1 预编译与参数绑定
我们的实现使用了#{...}语法,确保所有参数都是预编译的,避免了SQL注入风险。这与直接拼接SQL字符串有本质区别。
5.2 大列表处理方案
对于可能超过1000个元素的IN查询(某些数据库对IN列表长度有限制),可以:
- 自动拆分为多个查询批次执行
- 改用临时表JOIN方式
- 使用
OR条件链替代
java复制if (collection.size() > batchSize) {
// 分批处理逻辑
context.appendSql("(");
int batchCount = 0;
for (Object item : collection) {
if (batchCount > 0 && batchCount % batchSize == 0) {
context.appendSql(") OR column IN (");
}
// 添加当前项
batchCount++;
}
context.appendSql(")");
}
5.3 缓存SqlSource实例
InSqlSource是无状态的,可以在CustomLanguageDriver中缓存实例:
java复制private static final Map<String, SqlSource> cache = new ConcurrentHashMap<>();
public SqlSource createSqlSource(...) {
String key = script + "|" + parameterType.getName();
return cache.computeIfAbsent(key, k -> createNewSqlSource(...));
}
6. 实际应用中的经验分享
6.1 与MyBatis-Plus的兼容性
如果项目中使用MyBatis-Plus,需要确保自定义语言驱动不会影响其功能。可以通过条件注册解决:
java复制@ConditionalOnMissingBean(LanguageDriver.class)
@Bean
public LanguageDriver languageDriver() {
return new CustomLanguageDriver();
}
6.2 调试技巧
当自定义标签不生效时,检查顺序:
- 语言驱动是否正确注册
- 是否被其他组件覆盖
- XML解析是否正确捕获了自定义标签
可以在CustomLanguageDriver中添加日志:
java复制logger.debug("Processing SQL script: {}", script);
6.3 常见问题排查
问题1:报错"Unknown element in SQL"
- 原因:未正确注册自定义语言驱动
- 解决:检查
defaultScriptingLanguage设置或@Lang注解
问题2:参数绑定失败
- 原因:集合表达式路径错误
- 解决:确认
collection属性值与参数对象结构匹配
问题3:性能下降
- 原因:大列表未分批次
- 解决:实现自动分批逻辑或增加大小限制
7. 扩展思考:其他SQL模板优化
同样的思路可以应用于其他常见模板:
7.1 简化LIKE查询
传统方式:
xml复制WHERE name LIKE CONCAT('%', #{name}, '%')
自定义标签:
xml复制WHERE name LIKE <like value="name"/>
7.2 简化BETWEEN查询
传统方式:
xml复制WHERE age BETWEEN #{startAge} AND #{endAge}
自定义标签:
xml复制WHERE <between column="age" start="startAge" end="endAge"/>
这些扩展可以进一步减少样板代码,让SQL更加清晰易读。
