1. 痛点:MyBatis 中 IN 语句的 "繁琐魔咒"
但凡用过 MyBatis 的开发者,几乎都被IN
语句的foreach
模板 "折磨" 过。比如要实现 "根据用户 ID 列表查询用户" 这个简单需求,传统写法必须在 Mapper XML 中嵌套foreach
标签,代码如下:
xml
<!-- 传统IN语句写法:必须嵌套foreach标签 -->
<select id="selectUsersByIds" resultType="User">
SELECT id, username, age FROM user WHERE id IN
<!-- 繁琐的foreach -->
<foreach collection="userIds" item="item" open="(" close=")" separator=",">
#{item}
</foreach>
</select>
十分简单的in的sql语句,却需要写如此长的代码,这时我不禁希望,如果Mybatis能支持直接用in #{userIds}
这样的语法就好了。
求人不如求己,今天我们就自己实现这个需求。
2. 方案对比
方案一:使用Mybatis插件
提到扩展,我们自然而然第一想到的是Mybatis的插件能力,它提供了对四大组件Executor
、StatementHandler
、ParameterHandler
、ResultSetHandler
进行扩展的能力,那么我们可以这样:
- 拦截StatementHandler的prepare方法,获取
BoundSql
。 - 解析SQL中的
in #{idList}
占位符,识别对应的参数。 - 将#{idList}替换为(?, ?, ...)的形式,生成适配in语法的。
- 处理参数绑定,确保集合中的每个元素正确映射到新生成的占位符。
听起来可行,但仔细想想好像有问题,因为在StatementHandler
获取的BoundSql
中的SQL,是已经处理完成的带有?
占位符的SQL,并不能获取到类似in #{idList}
的原始SQL,替换也就无从说起。
那我们能不能拦截Executor
呢?在Executor
执行过程中,可以获取到MappedStatement
对象,它有个SqlSource
属性,有着较为原始的SqlNode
组成的SQL。但如果你看过之前的源码分析,会记得,Mybatis有个优化,没有动态标签和$
占位符的SQL的场景,会提前解析为?
占位符的样式,以提升性能,这种场景下,我们还是获取不到原始的SQL。
貌似不太可行,读者如果能通过插件解决,可以告诉我。
方案二:扩展SqlNode
如果看过上一篇文章(11. Mybatis SQL解析源码分析),大概对XML如何解析为SqlSource
还有印象,每个标签都有个对应的NodeHandler
,可以解析为对应的SqlNode
。那么我们是不是可以扩展一个标签呢?
比如我们的语法设计成这个样子:
xml
id <in collection="idList"/>
再定义一个InNodeHandler
和一个InSqlNode
。不过Mybatis并没有直接提供可以扩展NodeHandler
的配置,我们只能曲线救国。自定义一个LanguageDriver
。
java
public class CustomXMLLanguageDriver extends XMLLanguageDriver {
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
// TODO:在这里通过反射,把自定义的NodeHandler注册到XMLScriptBuilder中
return builder.parseScriptNode();
}
}
然后在mybatis-config.xml中,配置自定义的LanguageDriver
:
xml
<settings>
<setting name="defaultScriptingLanguage" value="com.hellohu.mybatis.self.CustomXMLLanguageDriver"/>
</settings>
看起来是可行的,只是和我们的需求还有一丢丢的差异,还是要写标签(不过已经简单很多了)。
方案三:扩展LanguageDriver
刚刚提到了LanguageDriver
,那么什么是LanguageDriver
呢?
LanguageDriver
是MyBatis中负责"SQL 生成与解析" 的接口,用于解析XML,生成SqlSource
(不知道SqlSource
的可以看看之前的文章)。MyBatis的默认实现是XMLLanguageDriver
。这个XMLLanguageDriver
构造XMLScriptBuilder
,解析XML(也就是上一篇文章解析的源码)。
我们看下它的接口:
java
//创建 ParameterHandler实例
ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql);
//从XML生成SqlSource,注意这里输入的不是XML原始字符串,而是Mybatis封装的XNode节点
SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType);
//从注解(比如@Select)的字符串生成SqlSource
SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType);
在LanguageDriver
的两个createSqlSource
方法中,我们可以获取到相对原始的XML节点(XNode
)或者SQL字符串,我们可以继承原生的XMLLanguageDriver
,重写这两个方法
- 解析XML的
createSqlSource
:如果存在in #{idList}
,把对应的节点替换成成foreach
节点,调用原方法继续执行。 - 解析注解SQL字符串的
createSqlSource
:如果存在in #{idList}
,替换成foreach
字符串,调用原方法继续执行。
看起来是可行的,可以实现我们最原始的需求。
3. 核心实现
3.1 重写注解的createSqlSource方法
java
// 匹配 in #{参数} 模式(忽略大小写)
private static final Pattern IN_PATTERN = Pattern.compile(
"\\s+in\\s+#\{([^}]+)}\\s*",
Pattern.CASE_INSENSITIVE
);
@Override
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
Matcher matcherIN = IN_PATTERN.matcher(script);
if (matcherIN.find()) {
//正则匹配成功,替换成foreach标签
script = matcherIN.replaceAll(
" in <foreach collection="$1" item = "item" open= "(" separator="," close=")">#{item}</foreach>");
//注解中使用动态SQL标签,需要使用script包起来
if (!script.startsWith("<script>")) {
script = "<script>" + script + "</script>";
}
}
return super.createSqlSource(configuration, script, parameterType);
}
关键逻辑解析
- 语法匹配 :用正则
\\s+in\\s+#\{([^}]+)}\\s
匹配in #{参数}
,支持灵活的空格(如IN #{ids}
、in #{query.roleIds}
)和大小写; - 替换为foreach :正则替换,替换为foreach,这里的
$1
正则表达式第一个分组,(第一个括号内)的字符,也就是#{}
内的内容。 - 套上
<script>
标签 如果没有以<script>
开始,则给SQL套个<script>
,因为注解内的SQL如果使用类似<foreach>
这种动态标签,需要加上<script>
。
3.2 重写XML的createSqlSource方法
这里我们获取原生的Node
(XNode
是Mybatis封装的节点)进行修改,修改后,再转换回XNode
:
java
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
// 1. 获取原生Node并处理
Node originalNode = script.getNode();
processNode(originalNode); // 直接修改原生Node
// 2. 基于修改后的原生Node创建新XNode,交给MyBatis解析
XNode processedNode = script.newXNode(originalNode);
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, processedNode, parameterType);
return builder.parseScriptNode();
}
java
/**
* 递归处理Node
*
* @param node 原生DOM节点(org.w3c.dom.Node)
*/
private void processNode(Node node) {
if (node == null) return;
// 1. 处理文本节点(可能包含 in #{...} 语法)
if (node.getNodeType() == Node.TEXT_NODE) {
processTextNode((Text) node);
return;
}
// 2. 处理元素节点(递归处理子节点)
if (node.getNodeType() == Node.ELEMENT_NODE) {
// 复制子节点列表(避免遍历中修改节点导致修改异常)
NodeList childNodes = node.getChildNodes();
List<Node> children = new ArrayList<>(childNodes.getLength());
for (int i = 0; i < childNodes.getLength(); i++) {
children.add(childNodes.item(i));
}
// 递归处理每个子节点
for (Node child : children) {
processNode(child);
}
}
}
/**
* 处理文本节点:替换 in #{...} 为 foreach 元素
*/
private void processTextNode(Text textNode) {
String originalText = textNode.getTextContent();
Matcher matcher = IN_PATTERN.matcher(originalText);
// 步骤1:提取所有匹配项,并拆分原始文本为"非匹配片段"和"匹配项"的有序列表
List<Object> segments = new ArrayList<>();
int lastEnd = 0;
while (matcher.find()) {
// 添加匹配项之前的非匹配片段
if (matcher.start() > lastEnd) {
segments.add(originalText.substring(lastEnd, matcher.start()));
}
// 添加匹配项(存储MatchResult)
segments.add(matcher.toMatchResult());
lastEnd = matcher.end();
}
// 添加最后一个匹配项之后的非匹配片段
if (lastEnd < originalText.length()) {
segments.add(originalText.substring(lastEnd));
}
// 无匹配项则直接返回
if (segments.stream().noneMatch(s -> s instanceof MatchResult)) {
return;
}
// 步骤2:按顺序创建节点并插入DOM
Document doc = textNode.getOwnerDocument();
Node parent = textNode.getParentNode();
// 记录原文本节点的下一个兄弟节点,作为插入基准位置
Node nextSibling = textNode.getNextSibling();
// 移除原文本节点
parent.removeChild(textNode);
// 遍历所有片段,依次创建节点
for (Object segment : segments) {
if (segment instanceof String) {
// 非匹配片段:创建文本节点
String text = (String) segment;
if (!text.isEmpty()) {
Text textNodeSegment = doc.createTextNode(text );
parent.insertBefore(textNodeSegment, nextSibling);
}
} else if (segment instanceof MatchResult) {
// 匹配项:创建foreach节点
MatchResult match = (MatchResult) segment;
String parameter = match.group(1).trim();
Element foreach = createForeachElement(doc, parameter);
parent.insertBefore(foreach, nextSibling);
}
}
}
/**
* 创建原生foreach元素节点
*/
private Element createForeachElement(Document doc, String parameter) {
Element foreach = doc.createElement("foreach");
// 设置foreach必要属性
foreach.setAttribute("collection", parameter);
foreach.setAttribute("item", "item");
foreach.setAttribute("open", " in (");
foreach.setAttribute("close", ") ");
foreach.setAttribute("separator", ",");
// 添加#{item}子节点(保持缩进格式)
Text itemText = doc.createTextNode("#{item}");
foreach.appendChild(itemText);
return foreach;
}
关键逻辑解析
- 节点递归处理 :
processNode
方法遍历所有 XML 节点,文本节点处理in
语法,元素节点(如<if>
、<where>
)递归处理子节点,确保嵌套场景兼容; - DOM 节点替换 :
processTextNode
将原文本拆分为 "非匹配文本 + 匹配项",移除原节点后插入新的文本节点和foreach
节点,保持原有 XML 格式和顺序; - 自动填充模板 :
createForeachElement
自动生成foreach
的open/close/separator
等属性,开发者无需关注模板细节。
3.3 配置方式
要让 MyBatis 使用SimplifiedInLanguageDriver
,只需在配置中指定即可,支持全局配置 和局部配置两种方式。
方式 1:全局配置(所有 Mapper 生效)
在mybatis-config.xml
中设置默认 LanguageDriver:
xml
<configuration>
<!-- 全局指定自定义LanguageDriver -->
<settings>
<setting name="defaultScriptingLanguage"
value="com.yourpackage.SimplifiedInLanguageDriver"/>
</settings>
</configuration>
方式 2:局部配置(指定 Mapper / 方法生效)
- Mapper 接口指定 :在 Mapper 接口上用
@Lang
注解标注:
java
import org.apache.ibatis.annotations.Lang;
@Lang(SimplifiedInLanguageDriver.class)
public interface UserMapper {
List<User> selectUsersByIds(List<Integer> userIds);
}
- 方法指定:在具体方法上标注(优先级高于接口):
java
public interface UserMapper {
@Lang(SimplifiedInLanguageDriver.class)
List<User> selectUsersByIds(List<Integer> userIds);
}
4. 效果演示:一行代码搞定 IN 语句
4.1 简化后的 Mapper XML
用SimplifiedInLanguageDriver
后,原来的foreach
模板消失了,直接写in #{参数}
即可:
xml
<!-- 简化后的IN语句写法:无需foreach! -->
<select id="selectUsersByIds" resultType="User">
SELECT id, username, age
FROM user
WHERE id in #{userIds} <!-- 直接写in #{参数},自动转foreach -->
AND role_id in #{query.roleIds} <!-- 支持复杂参数路径 -->
</select>
4.2 Java 代码调用
和传统写法完全一致,无需任何额外改动:
java
// 1. 构造参数(支持简单List和复杂对象)
List<Integer> userIds = Arrays.asList(101, 102, 103);
QueryParam query = new QueryParam();
query.setRoleIds(Arrays.asList(2, 3)); // 复杂参数:query.roleIds
// 2. 调用Mapper方法
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
List<User> users = userMapper.selectUsersByIds(userIds, query);
4.3 最终生成的 SQL
SimplifiedInLanguageDriver
会自动将in #{userIds}
转换为标准foreach
结构,最终生成的
sql
SELECT id, username, age
FROM user
WHERE id IN ( ? , ? , ? )
AND role_id IN ( ? , ? )
总结
至此,in语句的扩展能力就完成了,将 "重复、易出错" 的foreach
模板转化为 "简洁、直观" 的in #{参数}
语法,不仅提升了 SQL 编写效率,还减少了语法错误的可能性。