12. 告别 MyBatis IN 语句的 foreach 模板:自定义扩展 让 SQL 编写效率翻倍

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的插件能力,它提供了对四大组件ExecutorStatementHandlerParameterHandlerResultSetHandler进行扩展的能力,那么我们可以这样:

  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

如果看过上一篇文章(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,重写这两个方法

  1. 解析XML的createSqlSource:如果存在in #{idList},把对应的节点替换成成foreach节点,调用原方法继续执行。
  2. 解析注解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);
}

关键逻辑解析

  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:

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;
}

关键逻辑解析

  1. 节点递归处理processNode方法遍历所有 XML 节点,文本节点处理in语法,元素节点(如<if><where>)递归处理子节点,确保嵌套场景兼容;
  2. DOM 节点替换processTextNode将原文本拆分为 "非匹配文本 + 匹配项",移除原节点后插入新的文本节点和foreach节点,保持原有 XML 格式和顺序;
  3. 自动填充模板createForeachElement自动生成foreachopen/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 编写效率,还减少了语法错误的可能性。

相关推荐
用户37215742613518 分钟前
Java 实现HTML转Word:从HTML文件与字符串到可编辑Word文档
java
yvya_34 分钟前
Mybatis总结
java·spring·mybatis
姜太小白38 分钟前
【VSCode】VSCode为Java C/S项目添加图形用户界面
java·c语言·vscode
谦行1 小时前
Andrej Karpathy 谈持续探索最佳大语言模型辅助编程体验之路
后端
一路向北North1 小时前
java将doc文件转pdf
java·pdf
ALex_zry1 小时前
Golang云端编程入门指南:前沿框架与技术全景解析
开发语言·后端·golang
绝无仅有1 小时前
部署 Go 项目的 N 种方法
后端·面试·github
咕白m6251 小时前
Java 开发:用 Spire.PDF 高效压缩 PDF 文件
java·编程语言
万行1 小时前
点评项目(Redis中间件)&第一部分Redis基础
java·数据库·redis·缓存·中间件