⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7543094107999338537 「灵魂猎手」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

1. 痛点:MyBatis 中 IN 语句的 “繁琐魔咒”

但凡用过 MyBatis 的开发者,几乎都被IN语句的foreach模板 “折磨” 过。比如要实现 “根据用户 ID 列表查询用户” 这个简单需求,传统写法必须在 Mapper XML 中嵌套foreach标签,代码如下:

<!-- 传统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进行扩展的能力,那么我们可以这样:

  1. 拦截StatementHandler的prepare方法,获取BoundSql。
  2. 解析SQL中的in #{idList}占位符,识别对应的参数。
  3. 将#{idList}替换为(?, ?, …)的形式,生成适配in语法的。
  4. 处理参数绑定,确保集合中的每个元素正确映射到新生成的占位符。

听起来可行,但仔细想想好像有问题,因为在StatementHandler获取的BoundSql中的SQL,是已经处理完成的带有?占位符的SQL,并不能获取到类似in #{idList}的原始SQL,替换也就无从说起。

那我们能不能拦截Executor呢?在Executor执行过程中,可以获取到MappedStatement对象,它有个SqlSource属性,有着较为原始的SqlNode组成的SQL。但如果你看过之前的源码分析,会记得,Mybatis有个优化,没有动态标签和$占位符的SQL的场景,会提前解析为?占位符的样式,以提升性能,这种场景下,我们还是获取不到原始的SQL。

貌似不太可行,读者如果能通过插件解决,可以告诉我。

方案二:扩展SqlNode

如果看过之前的文章《Mybatis SQL解析源码分析》,大概对XML如何解析为SqlSource还有印象,每个标签都有个对应的NodeHandler,可以解析为对应的SqlNode。那么我们是不是可以扩展一个标签呢?

比如我们的语法设计成这个样子:

id <in collection="idList"/>

再定义一个InNodeHandler和一个InSqlNode。不过Mybatis并没有直接提供可以扩展NodeHandler的配置,我们只能曲线救国。自定义一个LanguageDriver。

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:

<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(也就是上一篇文章解析的源码)。

我们看下它的接口:

//创建 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,重写这两个方法

  1. 解析XML的createSqlSource:如果存在in #{idList},把对应的节点替换成成foreach节点,调用原方法继续执行。
  2. 解析注解SQL字符串的createSqlSource:如果存在in #{idList},替换成foreach字符串,调用原方法继续执行。

看起来是可行的,可以实现我们最原始的需求。

3. 核心实现

3.1 重写注解的createSqlSource方法

// 匹配 in #{参数} 模式(忽略大小写)
privatestaticfinal 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>";
}
}
returnsuper.createSqlSource(configuration, script, parameterType);
}

关键逻辑解析

  1. 语法匹配:用正则\\s+in\\s+#\{([^}]+)}\\s匹配in #{参数},支持灵活的空格(如IN #{ids}、in #{query.roleIds})和大小写;
  2. 替换为foreach:正则替换,替换为foreach,这里的$1正则表达式第一个分组,(第一个括号内)的字符,也就是#{}内的内容。
  3. 套上<script>标签 如果没有以<script>开始,则给SQL套个<script>,因为注解内的SQL如果使用类似<foreach>这种动态标签,需要加上<script>。

3.2 重写XML的createSqlSource方法

这里我们获取原生的Node(XNode是Mybatis封装的节点)进行修改,修改后,再转换回XNode:

@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();
}
/**
* 递归处理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);
}
} elseif (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;
}

关键逻辑解析

  1. 节点递归处理:processNode方法遍历所有 XML 节点,文本节点处理in语法,元素节点(如<if>、<where>)递归处理子节点,确保嵌套场景兼容;
  2. DOM 节点替换:processTextNode将原文本拆分为 “非匹配文本 + 匹配项”,移除原节点后插入新的文本节点和foreach节点,保持原有 XML 格式和顺序;
  3. 自动填充模板:createForeachElement自动生成foreach的open/close/separator等属性,开发者无需关注模板细节。

3.3 配置方式

要让 MyBatis 使用SimplifiedInLanguageDriver,只需在配置中指定即可,支持全局配置和局部配置两种方式。

方式 1:全局配置(所有 Mapper 生效)

在mybatis-config.xml中设置默认 LanguageDriver:

<configuration>
<!-- 全局指定自定义LanguageDriver -->
<settings>
<setting name="defaultScriptingLanguage"
value="com.yourpackage.SimplifiedInLanguageDriver"/>
</settings>
</configuration>

方式 2:局部配置(指定 Mapper / 方法生效)

Mapper 接口指定: 在 Mapper 接口上用@Lang注解标注:

import org.apache.ibatis.annotations.Lang;

@Lang(SimplifiedInLanguageDriver.class)
public interface UserMapper {
List<User> selectUsersByIds(List<Integer> userIds);
}

方法指定: 在具体方法上标注(优先级高于接口):

public interface UserMapper {
@Lang(SimplifiedInLanguageDriver.class)
List<User> selectUsersByIds(List<Integer> userIds);
}

4. 效果演示:一行代码搞定 IN 语句

4.1 简化后的 Mapper XML

用SimplifiedInLanguageDriver后,原来的foreach模板消失了,直接写in #{参数}即可:

<!-- 简化后的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 代码调用

和传统写法完全一致,无需任何额外改动:

// 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结构,最终生成的

SELECT id, username, age 
FROM user
WHERE id IN ( ? , ? , ? )
AND role_id IN ( ? , ? )

总结

至此,in语句的扩展能力就完成了,将 “重复、易出错” 的foreach模板转化为 “简洁、直观” 的in #{参数}语法,不仅提升了 SQL 编写效率,还减少了语法错误的可能性。

文章目录
  1. 1. 1. 痛点:MyBatis 中 IN 语句的 “繁琐魔咒”
  2. 2. 2. 方案对比
    1. 2.1. 方案一:使用Mybatis插件
    2. 2.2. 方案二:扩展SqlNode
    3. 2.3. 方案三:扩展LanguageDriver
  3. 3. 3. 核心实现
    1. 3.1. 3.1 重写注解的createSqlSource方法
    2. 3.2. 3.2 重写XML的createSqlSource方法
    3. 3.3. 3.3 配置方式
      1. 3.3.1. 方式 1:全局配置(所有 Mapper 生效)
      2. 3.3.2. 方式 2:局部配置(指定 Mapper / 方法生效)
  4. 4. 4. 效果演示:一行代码搞定 IN 语句
    1. 4.1. 4.1 简化后的 Mapper XML
    2. 4.2. 4.2 Java 代码调用
    3. 4.3. 4.3 最终生成的 SQL
  5. 5. 总结