资讯动态

MyBatis <include>标签进阶:从SQL复用走向动态模板化设计

发布时间:2026/8/17 20:04:10 来源:尧图企业网站定制
1. 项目概述不只是简单的代码复用如果你用过 MyBatis那对include标签肯定不陌生。官方文档和大多数教程会告诉你它的核心作用是复用 SQL 片段减少重复代码。这没错但如果你对它的认知仅停留在“把一段公共 SQL 抽出来然后在需要的地方引用一下”那可能就错过了它至少一半的威力。在实际项目中尤其是面对复杂业务逻辑、动态查询条件拼接或者需要维护大量相似但略有差异的 SQL 时include标签配合其子标签property能演变出一套非常优雅的“动态 SQL 模板”方案。它解决的不仅仅是代码重复问题更是 SQL 逻辑的模块化、参数化和动态化问题。想象一下你有一个复杂的统计报表查询核心SELECT字段和JOIN逻辑是固定的但WHERE条件会根据前端传入的十几个参数动态变化。用纯if标签写一个 SQL 映射文件可能就几百行难以阅读和维护。而用进阶的include思路你可以把固定的部分做成“模板”把变化的部分作为“参数”注入整个结构会清晰得多。这篇文章我就从一个多年 MyBatis 使用者的角度抛开那些基础定义直接深入include标签那些容易被忽略的进阶用法、实战技巧和背后的原理。我们会重点探讨如何利用property标签和${}表达式实现 SQL 片段的动态装配如何规避常见的陷阱以及如何将这些技巧融入到诸如分页、动态表名、字段枚举映射等实际场景中。无论你是正在被复杂 MyBatis XML 文件困扰的开发者还是想提升代码质量的架构爱好者这里都有你想要的“干货”。2. 核心机制深度解析从静态包含到动态模板要玩转include的进阶用法必须彻底理解它的两种传参机制和底层实现原理。很多人只用了第一种却不知道第二种才是解锁高级玩法的钥匙。2.1 两种传参方式及其本质区别方式一通过refid的属性传递静态替换这是最常见的方式直接在include标签的refid属性后通过property子标签传递参数。sql idbaseColumn ${alias}.id, ${alias}.name, ${alias}.status /sql select idselectUser resultTypeUser SELECT include refidbaseColumn property namealias valueu/ /include FROM user u /select在这个例子中property namealias valueu/定义了一个参数。在 SQL 片段baseColumn中${alias}会被替换为u。最终生成的 SQL 是SELECT u.id, u.name, u.status FROM user u。关键理解这里的valueu是一个字面量字符串。替换发生在 MyBatis 解析 XML 映射文件的阶段可以理解为一种静态的、基于字符串的模板替换。它不涉及运行时参数 (#{})也无法直接使用OGNL表达式从传入的参数对象中动态取值。方式二通过refid的value属性动态指定动态装配这是进阶用法的核心。include标签的refid属性本身可以直接接受一个${}表达式这意味着我们可以动态决定要包含哪个 SQL 片段。sql idcolumnSimple id, name /sql sql idcolumnDetail id, name, email, create_time, update_time /sql select idselectUser resultTypeUser SELECT include refid${columnSet}/ FROM user where if testid ! nullAND id #{id}/if /where /select对应的 Java Mapper 接口方法可能是ListUser selectUser(Param(columnSet) String columnSet, Param(id) Long id);调用时你可以传入columnSet为columnSimple或columnDetail。MyBatis 在解析时会用传入的columnSet参数值去查找对应的sql片段。核心原理refid${columnSet}中的${columnSet}是OGNL表达式它会在运行时被求值。求值过程发生在 MyBatis 构建SqlSource的时候。这意味着动态性可以根据传入参数的不同包含完全不同的 SQL 片段。与#{}的界限${}在这里用于标识符SQL 片段 ID的动态替换属于 SQL 语句结构的一部分。而#{}用于参数值的安全替换。两者用途截然不同不能混淆。解析阶段这种动态refid的解析发生在 MyBatis 将#{}参数替换成?占位符之前。它决定了最终的 SQL 语句骨架。两种方式的对比与选择特性方式一 (属性传参)方式二 (动态refid)传参目标传递给sql片段内的${}变量决定包含哪个sql片段替换内容SQL 片段内部的字符串整个sql片段的引用 ID动态性弱。value通常是字面量或简单拼接。强。可根据运行时参数选择不同片段。主要用途微调 SQL 片段内容如别名、固定值。切换完全不同的 SQL 模块如简单/详细字段集、不同查询模式。安全性较高因为替换内容在 XML 内定义。需谨慎。确保${}的值来源可靠防止 SQL 注入。2.2property标签的妙用不仅仅是传值property标签的value属性同样支持${}表达式这将它从一个“静态值传递器”变成了一个“动态参数注入器”。sql idtimeRangeQuery AND create_time BETWEEN ${startTime} AND ${endTime} /sql select idselectByTime resultTypeOrder SELECT * FROM orders where status 1 include refidtimeRangeQuery property namestartTime value${timeRange.start}/ property nameendTime value${timeRange.end}/ /include /where /select假设传入的参数对象有一个timeRange属性它本身又是一个对象包含start和end字段。通过value${timeRange.start}我们实现了嵌套对象属性的动态传递。实操心得这里有一个非常重要的细节。sql片段内使用的是${startTime}和${endTime}。这意味着替换进去的将是timeRange.start和timeRange.end的实际值而不是预编译的占位符?。因此你必须确保这些值是安全的例如是数据库本身的时间戳格式字符串或者是你手动格式化的安全字符串否则会引入 SQL 注入风险。对于时间范围查询更安全的做法是在 Java 代码中将Date对象格式化为数据库兼容的字符串如‘2023-10-01 00:00:00’再作为参数传入。或者在极度追求安全的情况下可以放弃这种动态性在sql片段内使用#{...}但这就需要通过property传递一个能在#{}中解析的复杂属性路径这通常很棘手也是include用法的一个边界。2.3 作用域与生命周期理解替换发生的时刻理解include和property的解析时机对于调试和避免诡异问题至关重要。XML 解析期MyBatis 在启动时会读取所有*Mapper.xml文件构建一个初步的SqlSource。此时对于静态的include refid固定ID和property value字面量会进行第一轮替换。运行时动态替换期当 Mapper 方法被调用MyBatis 需要为本次调用生成具体的BoundSql。此时它会处理所有${}表达式。这包括动态refid中的${}和动态property value中的${}。替换顺序可以简单理解为“由外向内”。先根据动态refid确定最终要包含的sql片段内容然后将这个片段文本取出再根据property传递的参数无论是静态value还是动态${}求值结果替换掉片段内的${变量名}。与#{}的关系${}的替换发生在#{}被替换为?之前。也就是说MyBatis 会先拼装出完整的、包含#{}的 SQL 字符串然后再将#{}替换为?并设置参数。因此include机制完全不影响#{}的参数化查询特性。一个常见的坑如果你在sql片段里写了#{item}然后希望通过include的property来改变item所指向的参数名这是行不通的。因为#{}的解析依赖于它所在的上下文即外层select/insert/update/delete标签的参数映射property无法影响#{}的解析绑定。property只能影响${}。3. 实战进阶应用场景掌握了核心机制后我们来看几个能显著提升代码质量和开发效率的实战场景。3.1 场景一动态字段选择与列权限控制在管理后台或 API 接口中经常需要根据调用者的权限或前端需求返回不同的字段集。比如普通用户只能看到基础信息管理员能看到所有敏感字段。传统做法写多个查询方法或者在一个方法里用if标签拼接SELECT字段后者会导致 SQL 非常冗长。进阶include解法!-- 定义不同的字段集片段 -- sql idcolumnsForUser id, username, nickname, avatar /sql sql idcolumnsForAdmin id, username, nickname, avatar, email, phone, create_ip, status /sql sql idcolumnsForSuperAdmin include refidcolumnsForAdmin/, last_login_time, login_fail_count /sql !-- 动态选择字段集 -- select idselectUserById resultTypeUser SELECT include refid${columnPrefix}/ FROM sys_user WHERE id #{id} /select在 Mapper 接口中User selectUserById(Param(id) Long id, Param(columnPrefix) String columnPrefix);在 Service 层根据当前用户的角色决定传入columnPrefix是“columnsForUser”、“columnsForAdmin”还是“columnsForSuperAdmin”。优势清晰每个字段集的定义独立且可读。可复用columnsForSuperAdmin通过嵌套include复用了columnsForAdmin。易维护增减字段只需修改对应的sql片段。灵活权限逻辑在 Java 代码中更容易理解和测试。3.2 场景二复杂查询条件的模块化这是include标签最能发挥价值的地方。假设有一个订单查询条件极其复杂时间范围、订单状态、商品类目、支付方式、关键词搜索等等。传统做法一个长达百行的select标签里面嵌套了无数if、choose阅读和维护都是噩梦。模块化重构思路将相关的查询条件分组封装成独立的、语义化的sql片段。在主查询中通过include按需引入这些条件模块。利用property传递模块所需的参数。!-- 条件模块时间范围 -- sql idconditionTimeRange if test${startTime} ! null and ${endTime} ! null AND create_time BETWEEN #{${startTime}} AND #{${endTime}} /if if test${startTime} ! null and ${endTime} null AND create_time #{${startTime}} /if if test${startTime} null and ${endTime} ! null AND create_time lt; #{${endTime}} /if /sql !-- 条件模块状态筛选 -- sql idconditionStatus if test${statusList} ! null and ${statusList}.size() 0 AND status IN foreach collection${statusList} itemstatus open( separator, close) #{status} /foreach /if /sql !-- 条件模块关键词搜索 (商品名或订单号) -- sql idconditionKeywordSearch if test${keyword} ! null and ${keyword} ! AND (product_name LIKE CONCAT(%, #{${keyword}}, %) OR order_no #{${keyword}}) /if /sql !-- 主查询 -- select idselectOrderPage resultTypeOrderVO SELECT * FROM order_info where deleted 0 !-- 引入时间范围条件模块并指定参数名映射 -- include refidconditionTimeRange property namestartTime valuequery.startTime/ property nameendTime valuequery.endTime/ /include !-- 引入状态筛选条件模块 -- include refidconditionStatus property namestatusList valuequery.statusList/ /include !-- 引入关键词搜索条件模块 -- include refidconditionKeywordSearch property namekeyword valuequery.keyword/ /include /where ORDER BY create_time DESC /select关键点解析参数名映射这是精髓所在。sql片段conditionTimeRange内部使用的变量名是${startTime}和${endTime}。在主查询的include中通过property标签将片段内的变量名startTime映射到了实际参数对象的路径query.startTime。这样片段就与外部具体的参数结构解耦了成为一个通用的“时间范围查询”模块。test表达式中的${}注意看if test${startTime} ! null ...。这里的${startTime}会被替换为query.startTime然后 MyBatis 再对testquery.startTime ! null这个OGNL表达式进行求值。这实现了条件判断逻辑的动态化。#{}中的${}BETWEEN #{${startTime}} AND #{${endTime}}这行是关键。首先${startTime}被替换为query.startTime于是表达式变成了BETWEEN #{query.startTime} AND #{query.endTime}。然后MyBatis 再将#{query.startTime}和#{query.endTime}处理为预编译的占位符?。这实现了参数化查询同时保持了 SQL 片段的通用性。带来的好处主查询极其简洁一眼就能看出查询由哪几个条件模块构成。模块高度可复用conditionTimeRange这个模块可以用在任何需要时间查询的 SQL 中。易于测试可以单独验证每个条件模块的正确性。团队协作不同的人可以负责不同条件模块的开发和维护。3.3 场景三动态表名与数据库分片在一些分库分表或者多租户系统中表名可能需要根据某些规则动态生成。sql idfromUserTable FROM ${tableName} /sql select idselectUserFromShard resultTypeUser SELECT id, name include refidfromUserTable property nametableName value${shardPrefix}_user/ /include WHERE id #{id} /selectMapper 接口User selectUserFromShard(Param(id) Long id, Param(shardPrefix) String shardPrefix);这里${shardPrefix}_user会根据传入的shardPrefix例如 “user_2023q1”动态计算表名然后替换到sql片段的${tableName}中。严重警告这是 SQL 注入的高风险区${tableName}的内容会直接拼接到 SQL 语句中。你必须确保shardPrefix的值绝对安全它应该来自系统内部逻辑计算如根据用户ID取模得到分片号再拼接成user_001绝不能直接来自任何不可信的用户输入。在代码中应该对shardPrefix进行严格的白名单校验或格式校验例如只允许数字、字母和下划线。3.4 场景四与foreach等标签的嵌套使用include可以嵌套在foreach内部实现循环内 SQL 片段的复用这在批量操作中特别有用。sql idbatchInsertValues (#{item.username}, #{item.email}, NOW()) /sql insert idbatchInsertUsers INSERT INTO user (username, email, create_time) VALUES foreach collectionuserList itemitem separator, include refidbatchInsertValues/ /foreach /insert虽然这个例子中片段很简单但如果VALUES子句非常复杂包含多个函数调用、默认值逻辑等使用include可以保证每个循环体内生成的内容完全一致避免手误。4. 避坑指南与最佳实践强大的功能往往伴随着陷阱。下面是我在多年实践中总结的常见问题和最佳实践。4.1 常见问题排查问题1include引入后 SQL 报错提示列名或表名不存在。可能原因Aproperty传参失败。检查include标签内的property的name是否与sql片段内的${变量名}完全一致大小写敏感。检查value的值是否正确如果是${}表达式确认其对应的参数在运行时确实存在且不为空。可能原因B动态refid解析错误。检查refid${xxx}中的xxx参数值是否与已定义的sql片段的id完全匹配。注意${xxx}求值后的结果必须是一个纯粹的、不含其他字符的id字符串。排查工具开启 MyBatis 的 SQL 日志打印配置log4j或使用mybatis.logging设置查看最终发送到数据库的 SQL 语句与预期进行对比。这是最直接的调试方法。问题2使用了property但sql片段内的#{}参数绑定失败。根本原因如前所述property无法改变#{}的解析上下文。#{}中的内容如#{item.id}是在包含该sql片段的外层标签的上下文中解析的。解决方案确保sql片段内的#{}表达式在外层标签的传入参数中可以直接访问到。如果片段需要通用性考虑将#{}改为${}并通过property传递完整的参数路径但必须自行承担 SQL 注入风险校验通常不推荐。更好的做法是重新设计参数结构或者接受片段与当前上下文的部分耦合。问题3在复杂的include嵌套中性能感觉有影响。影响分析include的解析发生在 MyBatis 初始化静态部分和每次 SQL 执行前动态部分。对于纯静态的include性能开销微乎其微。对于包含大量${}动态表达式和OGNL求值的复杂嵌套在超高并发场景下可能会产生可测量的开销。优化建议减少不必要的动态性如果某个片段在大多数情况下是固定的就不要为了1%的场景而设计成100%动态。使用 MyBatis 缓存合理配置localCacheScope默认SESSION和二级缓存可以避免重复解析 SQL。终极方案对于性能极其敏感、且 SQL 模式相对固定的场景可以考虑使用 MyBatis 的Provider注解如SelectProvider在 Java 代码中动态构建 SQL获得最大的灵活性但会失去 XML 的直观性。4.2 安全与可维护性最佳实践严格区分${}和#{}的使用场景${}仅用于SQL 结构部分如动态表名、动态列名、动态ORDER BY子句、动态 SQL 片段 ID (refid)。使用时必须确保参数值来源绝对安全或经过严格过滤/转义。#{}用于所有值类型的替换。MyBatis 会对其进行预编译防止 SQL 注入。99% 的参数传递都应该用#{}。为sql片段设计清晰的命名和文档片段的id应该具有自解释性如columnsForBasicUser、conditionOrderByCreateTime。在大型项目中可以考虑在独立的*fragment.xml文件中集中管理这些片段并通过mapper的namespace进行逻辑分组。避免过度抽象虽然include很好用但不要为了复用而复用。如果一个 SQL 片段只被用到一次或者抽象后反而让逻辑变得更难理解需要跳转多个文件查看那就应该直接写在原处。保持代码的局部可读性同样重要。进行单元测试对于包含复杂动态include逻辑的 Mapper 方法务必编写全面的单元测试。测试应覆盖各种参数组合确保生成的 SQL 正确无误并且没有因为${}的使用而引入 SQL 注入漏洞。可以使用 MyBatis 的SqlSession直接获取BoundSql来验证生成的 SQL 字符串。与 MyBatis 代码生成器如 MyBatis Generator结合代码生成器通常会生成一个包含所有基础字段的Base_Column_List片段。你可以基于这个片段通过include和property来添加别名或者创建更复杂的字段组合而无需手动维护字段列表。5. 与其他 MyBatis 特性的联动思考include的进阶用法不是孤立的它与 MyBatis 的其他特性结合能产生更强大的化学反应。与动态 SQL 标签 (if,choose,foreach) 的结合上文已有大量示例核心思想是将动态 SQL 标签封装在可复用的sql片段内实现逻辑的模块化。与Param注解的协同为了在include的property中清晰地传递参数强烈建议在 Mapper 接口的方法参数上使用Param注解为参数起一个明确的名称。这比依赖param1,param2这样的默认名称要清晰和安全得多。对 MyBatis Plus 等增强工具的启示MyBatis Plus 的Wrapper提供了 Lambda 查询方式在 Java 代码中构建动态查询条件。这可以看作是另一种形式的、类型安全的动态 SQL 模块化方案。当你觉得 XML 中的include动态模板过于复杂时或许可以考虑将这部分逻辑转移到Wrapper中利用 Java 代码的强大表达能力。两者并非互斥可以根据场景选择简单的、固定的条件用 XMLinclude复杂的、需要大量业务逻辑参与的条件拼接用Wrapper可能更合适。对 SQL 可观测性的影响当你使用非常动态的include时打印出来的 SQL 日志可能是“最终形态”丢失了模板结构。这会给线上问题排查带来一定困难。一个建议是在开发环境可以通过自定义 MyBatis 的ScriptingLanguageDriver或拦截器在日志中同时输出“模板 SQL”包含${}标记和“实际 SQL”便于调试。回过头看include标签的进阶用法本质上是一种声明式的、基于 XML 的 SQL 模板引擎。它通过${}表达式和property标签将静态的 SQL 片段转化为可配置、可装配的“乐高积木”。这种思路对于管理大型项目中复杂且多变的 SQL 逻辑是一种非常有效的手段。它要求开发者不仅把 MyBatis 当作一个简单的 ORM 工具而是以一种更架构化的视角去组织数据访问层。下次当你面对一个充斥着if标签的、长达数百行的 SQL 映射文件时不妨思考一下哪些逻辑可以抽象成独立的、通过include动态装配的模块。这一个小小的习惯改变可能会让你的代码质量提升一个档次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价