告别 MyBatis IN 语句的 foreach 模板:自定义扩展 让 SQL 编写效率翻倍!
1. 痛点MyBatis 中 IN 语句的 “繁琐魔咒”但凡用过 MyBatis 的开发者几乎都被IN语句的foreach模板 “折磨” 过。比如要实现 “根据用户 ID 列表查询用户” 这个简单需求传统写法必须在 Mapper XML 中嵌套foreach标签代码如下!-- 传统IN语句写法必须嵌套foreach标签 -- select idselectUsersByIds resultTypeUser SELECT id, username, age FROM user WHERE id IN !-- 繁琐的foreach -- foreach collectionuserIds itemitem 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如果看过之前的文章《Mybatis SQL解析源码分析》大概对XML如何解析为SqlSource还有印象每个标签都有个对应的NodeHandler可以解析为对应的SqlNode。那么我们是不是可以扩展一个标签呢比如我们的语法设计成这个样子id in collectionidList/再定义一个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 namedefaultScriptingLanguage valuecom.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重写这两个方法解析XML的createSqlSource:如果存在in #{idList}把对应的节点替换成成foreach节点调用原方法继续执行。解析注解SQL字符串的createSqlSource:如果存在in #{idList}替换成foreach字符串调用原方法继续执行。看起来是可行的可以实现我们最原始的需求。3. 核心实现3.1 重写注解的createSqlSource方法// 匹配 in #{参数} 模式忽略大小写 privatestaticfinal Pattern IN_PATTERN Pattern.compile( \\sin\\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); }关键逻辑解析语法匹配用正则\\sin\\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: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(); ListNode 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提取所有匹配项并拆分原始文本为“非匹配片段”和“匹配项”的有序列表 ListObject 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; }关键逻辑解析节点递归处理processNode方法遍历所有 XML 节点文本节点处理in语法元素节点如if、where递归处理子节点确保嵌套场景兼容DOM 节点替换processTextNode将原文本拆分为 “非匹配文本 匹配项”移除原节点后插入新的文本节点和foreach节点保持原有 XML 格式和顺序自动填充模板createForeachElement自动生成foreach的open/close/separator等属性开发者无需关注模板细节。3.3 配置方式要让 MyBatis 使用SimplifiedInLanguageDriver只需在配置中指定即可支持全局配置和局部配置两种方式。方式 1全局配置所有 Mapper 生效在mybatis-config.xml中设置默认 LanguageDriverconfiguration !-- 全局指定自定义LanguageDriver -- settings setting namedefaultScriptingLanguage valuecom.yourpackage.SimplifiedInLanguageDriver/ /settings /configuration方式 2局部配置指定 Mapper / 方法生效Mapper 接口指定在 Mapper 接口上用Lang注解标注import org.apache.ibatis.annotations.Lang; Lang(SimplifiedInLanguageDriver.class) public interface UserMapper { ListUser selectUsersByIds(ListInteger userIds); }方法指定在具体方法上标注优先级高于接口public interface UserMapper { Lang(SimplifiedInLanguageDriver.class) ListUser selectUsersByIds(ListInteger userIds); }4. 效果演示一行代码搞定 IN 语句4.1 简化后的 Mapper XML用SimplifiedInLanguageDriver后原来的foreach模板消失了直接写in #{参数}即可!-- 简化后的IN语句写法无需foreach -- select idselectUsersByIds resultTypeUser SELECT id, username, age FROM user WHERE id in #{userIds} !-- 直接写in #{参数}自动转foreach -- AND role_id in #{query.roleIds} !-- 支持复杂参数路径 -- /select4.2 Java 代码调用和传统写法完全一致无需任何额外改动// 1. 构造参数支持简单List和复杂对象 ListInteger 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); ListUser users userMapper.selectUsersByIds(userIds, query);4.3 最终生成的 SQLSimplifiedInLanguageDriver会自动将in #{userIds}转换为标准foreach结构最终生成的SELECT id, username, age FROM user WHERE id IN ( ? , ? , ? ) AND role_id IN ( ? , ? )总结至此in语句的扩展能力就完成了将 “重复、易出错” 的foreach模板转化为 “简洁、直观” 的in #{参数}语法不仅提升了 SQL 编写效率还减少了语法错误的可能性。来源juejin.cn/post/7543094107999338537后端专属技术群构建高质量的技术交流社群欢迎从事编程开发、技术招聘HR进群也欢迎大家分享自己公司的内推信息相互帮助一起进步文明发言以交流技术、职位内推、行业探讨为主广告人士勿入切勿轻信私聊防止被骗