MyBatis <include>标签进阶:从SQL复用走向声明式模板引擎 1. 项目概述不只是简单的代码复用如果你用过 MyBatis那对include标签肯定不陌生。官方文档和大多数教程里它就是个“SQL 片段复用”的工具——把一段通用的 SQL 抽出来然后在需要的地方引用一下避免重复写。这听起来很简单对吧但在我过去几年处理复杂企业级应用时发现很多人对include的理解就停留在这个“复制粘贴”的层面结果就是 XML 文件越来越臃肿动态 SQL 写得像面条一样又长又难维护。实际上include标签尤其是结合property子标签和${}表达式的用法是 MyBatis 动态 SQL 能力中一个被严重低估的“瑞士军刀”。它远不止是代码复用更是一种声明式的、可参数化的 SQL 模板引擎。你可以用它来动态组装表名、字段列表、复杂的查询条件块甚至实现基于不同数据库方言的 SQL 适配而无需在 Java 代码里拼接令人头疼的字符串。举个例子我们常遇到这种需求根据用户权限查询不同的数据列或者报表系统里根据前端选择的维度动态组合GROUP BY和SELECT字段。用 Java 代码拼接 SQL 字符串不仅容易出错还破坏了 MyBatis 本身优雅的映射哲学。而高阶的include用法能让这些动态性依然保留在清晰、可管理的 XML 层面。接下来我就带你跳出基础用法的舒适区看看如何用include标签写出更灵活、更强大、也更易于维护的 MyBatis 映射文件。2. 核心机制深度解析属性替换与作用域要玩转进阶用法必须吃透它的核心机制。很多人以为include refidsomeSql/就是简单的文本替换其实不然。MyBatis 在处理include时背后有一套精细的解析和上下文传递逻辑。2.1include与property标签的协作原理include标签的本质是引入一个定义好的sql片段。但关键在于你可以通过include标签体内的property子标签向这个片段传递参数。!-- 定义可配置的片段 -- sql idbaseColumnList id, ${prefix}name, ${prefix}age /sql !-- 使用并传递参数 -- select idselectUser resultTypeUser SELECT include refidbaseColumnList property nameprefix valueu./ /include FROM user u /select上面这个例子最终生成的 SQL 会是SELECT id, u.name, u.age FROM user u。这里${prefix}被替换成了u.。核心原理property标签定义的name-value对会在引入的sql片段内创建一个临时的、局部的作用域。片段中所有${xxx}占位符都会优先从这个局部作用域中查找对应的value进行替换。如果没找到才会去上一级作用域通常是传入的参数对象parameterType或者Param注解定义的参数里找。注意这里用的是${}OGNL 表达式直接文本替换而不是#{}预编译参数占位符。这是因为include的替换发生在 SQL 语句构建阶段目的是生成最终的 SQL 字符串而不是设置预编译参数。因此${}在这里是唯一正确的选择但也意味着你需要自行注意 SQL 注入问题——通常我们传入的是固定的别名、字段名或关键字而非用户输入。2.2 作用域与优先级变量从哪里来理解变量解析的优先级是避免踩坑的关键。当一个sql片段被include引用时MyBatis 会按以下顺序解析${variable}局部作用域最高优先级由当前include标签内的property标签定义。参数作用域Mapper 接口方法传入的参数对象。如果参数是单个基本类型或String可以用_parameter引用如果是多个参数或用Param注解则使用注解指定的名称。全局作用域在mybatis-config.xml中通过properties定义的属性或通过Properties对象传入的属性。这部分通常用于配置数据库方言、表前缀等全局信息。一个常见的混淆点很多人试图在sql片段里用#{}来接收property的值这是行不通的。#{}是用于预编译参数绑定的它的解析发生在 SQL 语句构建之后、执行之前与include的文本替换阶段不匹配。记住这个黄金法则在sql片段内部凡是需要被include动态替换的部分一律使用${}。2.3 与动态 SQL 标签if,choose的联动include的强大之处在于它能和if、choose等动态 SQL 标签无缝结合。你可以在include外面套动态标签来决定引入哪个片段更厉害的是也可以在sql片段内部使用动态标签让片段本身具备逻辑判断能力。!-- 定义动态片段 -- sql iddynamicWhereClause where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatusList ! null and statusList.size 0 AND status IN foreach collectionstatusList itemstatus open( separator, close) #{status} /foreach /if !-- 这里可以接收来自include的property -- if test${optionalCondition} true AND optional_field #{optionalValue} /if /where /sql !-- 在查询中条件性地应用动态WHERE -- select idsearch resultTypeResult SELECT * FROM some_table if testshouldApplyFilter true !-- 传入property控制片段内的动态逻辑 -- include refiddynamicWhereClause property nameoptionalCondition valuetrue/ /include /if /select这种组合拳使得 SQL 片段的复用不再是“死”的而是“活”的。你可以创建出高度可配置、可组合的 SQL 模块库。3. 进阶实战五大应用场景与代码拆解掌握了核心原理我们来看几个实实在在能提升开发效率和代码质量的进阶场景。这些都不是纸上谈兵而是我从实际项目中提炼出来的模式。3.1 场景一动态字段列表与表别名管理这是最实用的场景之一。在多表关联查询或者需要根据不同场景如列表页、详情页返回不同字段集时动态字段列表能让你事半功倍。问题一个User对象有20个字段列表页只需要id, name, avatar详情页需要全部字段编辑页可能需要排除createTime等字段。写三个几乎一样的SELECT语句解决方案使用include和property来参数化字段列表和表别名。!-- 定义核心字段映射片段 -- sql iduserColumns ${alias}.id, ${alias}.username, ${alias}.email, if test${includeProfile} true ${alias}.bio, ${alias}.avatar_url, /if ${alias}.create_time /sql !-- 列表查询只取基础字段 -- select idselectUserList resultMapuserResultMap SELECT include refiduserColumns property namealias valueu/ property nameincludeProfile valuefalse/ /include FROM users u WHERE u.status ACTIVE /select !-- 详情查询包含所有字段 -- select idselectUserDetail resultMapuserDetailResultMap SELECT include refiduserColumns property namealias valueu/ property nameincludeProfile valuetrue/ /include -- 还可以连接其他表 LEFT JOIN user_profile up ON u.id up.user_id WHERE u.id #{userId} /select实操心得别名统一管理所有字段都通过${alias}前缀切换表别名比如从u换成a只需改一个地方避免了在多表关联中因别名不一致导致的“列名不明确”错误。字段集模块化你可以定义多个“粒度”不同的字段片段如baseColumns、detailColumns、sensitiveColumns需权限然后像搭积木一样组合。注意逗号在sql片段的if标签内部处理字段时要特别注意末尾的逗号。上面的写法在includeProfile为false时create_time前面会多一个逗号导致 SQL 语法错误。更稳健的写法是使用 MyBatis 的trim标签来智能处理逗号或者确保每个字段行都自带逗号最后在片段外处理最后一个逗号。3.2 场景二可复用的复杂条件块WHERE/HAVING查询条件复杂且在多处重复时将其抽离成可配置的片段是保持代码清晰的关键。问题一个复杂的过滤条件比如根据时间范围、状态集合、关键词进行筛选在多个查询接口中都需要用到。解决方案将整个WHERE或HAVING块定义为可参数化的sql片段。!-- 定义可复用的高级过滤条件 -- sql idadvancedFilter if test${enableTimeRange} true AND create_time BETWEEN #{startTime} AND #{endTime} /if if test${statusKey} ! null AND status #{${statusKey}} !-- 注意这里的#{}解析的是外部参数 -- /if if test${keyword} ! null and ${keyword} ! AND ( title LIKE CONCAT(%, #{keyword}, %) OR content LIKE CONCAT(%, #{keyword}, %) ) /if !-- 动态决定按哪个字段排序 -- if test${orderByField} ! null ORDER BY ${orderByField} ${orderDirection} /if /sql !-- 在统计报表和列表查询中复用 -- select idcountReport resultTypelong SELECT COUNT(*) FROM orders o where o.deleted 0 include refidadvancedFilter property nameenableTimeRange valuetrue/ property namestatusKey valueorderStatus/ !-- 告诉片段去参数里找orderStatus -- property nameorderByField valuenull/ !-- 统计不需要排序 -- /include /where /select select idselectReportList resultTypeOrderVO SELECT * FROM orders o where o.deleted 0 include refidadvancedFilter property nameenableTimeRange valuetrue/ property namestatusKey valueorderStatus/ property nameorderByField valueo.amount/ property nameorderDirection valueDESC/ /include /where LIMIT #{offset}, #{limit} /select注意事项作用域隔离片段内的#{keyword}是从方法参数Map或对象中获取的而${orderByField}是从property传入的。它们来源不同不要混淆。SQL注入防范${orderByField}和${orderDirection}直接拼接进 SQL必须确保其值来自可信的枚举或经过严格校验的后端逻辑绝不能直接使用前端传入的字符串。通常的做法是在 Service 层将前端传入的排序字段映射为合法的数据库列名。where标签的智能处理通常将include放在where标签内这样即使引入的片段所有if都不成立where标签也会智能地移除开头的AND关键字避免WHERE AND ...的语法错误。3.3 场景三多数据库方言适配有些项目需要支持 MySQL、PostgreSQL、Oracle 等多种数据库。SQL 语法常有细微差别比如分页LIMITvsROWNUMvsFETCH NEXT、函数名CONCATvs||等。问题如何写一份 MyBatis XML使其能根据不同的数据库生成不同的 SQL解决方案结合 MyBatis 的databaseId属性和include的动态能力。首先在mybatis-config.xml中配置数据库厂商标识databaseIdProvider typeDB_VENDOR property nameMySQL valuemysql/ property namePostgreSQL valuepostgresql/ property nameOracle valueoracle/ /databaseIdProvider然后在映射文件中可以这样定义和使用方言特定的片段!-- 定义分页片段根据databaseId选择 -- sql idpageSuffix databaseIdmysql LIMIT #{pageSize} OFFSET #{offset} /sql sql idpageSuffix databaseIdpostgresql LIMIT #{pageSize} OFFSET #{offset} /sql sql idpageSuffix databaseIdoracle ) WHERE rn BETWEEN #{offset} 1 AND #{offset} #{pageSize} /sql !-- 定义字符串连接函数 -- sql idconcatFunction choose when test${dbType} mysql or ${dbType} postgresql CONCAT(%, #{value}, %) /when when test${dbType} oracle % || #{value} || % /when otherwise CONCAT(%, #{value}, %) !-- 默认 -- /otherwise /choose /sql !-- 在查询中使用 -- select idselectWithPage resultTypeMap databaseIdmysql !-- 这里指定databaseId会级联到include -- SELECT * FROM some_table WHERE name LIKE include refidconcatFunction property namedbType valuemysql/ !-- 也可以显式传递 -- /include include refidpageSuffix/ !-- MyBatis会根据当前databaseId自动选择mysql版本 -- /select更灵活的玩法你可以不依赖databaseId而是通过property显式传递一个代表数据库类型的变量如${dbVendor}然后在sql片段内部使用choose进行判断。这样控制权更集中在 SQL 片段本身。3.4 场景四动态表名与架构名在 SaaS 多租户系统或分库分表场景中表名可能是动态的如order_2024、tenant_001_orders。问题如何优雅地在 MyBatis 中处理动态表名而不是在 Java 代码里拼接 SQL 字符串解决方案使用include将表名作为参数传递。这是${}表达式极少数被推荐使用的安全场景之一因为表名通常由系统逻辑生成而非用户输入。!-- 定义表名片段 -- sql iddynamicTableName ${tableSchema}.${tablePrefix}_orders /sql select idselectByTenant resultTypeOrder SELECT * FROM include refiddynamicTableName property nametableSchema valuetenant_data/ property nametablePrefix value#{tenantId}/ !-- 注意这里#{}是参数${}是文本替换。不能嵌套。 -- /include WHERE id #{orderId} /select重要警告上面的写法value#{tenantId}是错误的。property标签的value属性期望的是一个 OGNL 表达式它会被求值后作为字符串传递给sql片段。#{tenantId}是 MyBatis 的参数占位符在这里不会被正确解析。正确的做法是在调用 Mapper 方法前在 Service 层就计算出完整的表名或表名前缀然后作为字符串参数传入// Service层 String tablePrefix tenant_ tenantId; // 或者从分片规则计算 orderMapper.selectByTenant(tablePrefix, orderId);!-- Mapper XML -- select idselectByTenant resultTypeOrder SELECT * FROM include refiddynamicTableName property nametableSchema valuetenant_data/ property nametablePrefix value${tablePrefix}/ !-- 直接使用参数 -- /include WHERE id #{orderId} /select核心要点动态表名的值必须在进入 MyBatis SQL 解析层之前就已经是确定的安全字符串。绝对不要让用户输入的任何内容直接作为${}的值。3.5 场景五构建可配置的通用 Mapper 片段对于所有表都有的通用操作比如逻辑删除、乐观锁更新、插入时的基础字段填充等可以抽象成“通用片段”极大减少重复代码。!-- 通用逻辑删除条件 -- sql idlogicalDeleteCondition ${alias}.is_deleted 0 /sql !-- 通用乐观锁更新set子句 -- sql idoptimisticLockSet ${alias}.version ${alias}.version 1, ${alias}.update_time NOW() /sql !-- 通用插入字段排除自增ID和更新字段 -- sql idcommonInsertColumns trim suffixOverrides, if testid nullid,/if !-- 可能是UUID -- name, type, status, creator_id, create_time /trim /sql sql idcommonInsertValues trim suffixOverrides, if testid null#{id},/if #{name}, #{type}, #{status}, #{creatorId}, NOW() /trim /sql !-- 在具体的操作中引用 -- update idlogicalDeleteById UPDATE user SET is_deleted 1 WHERE id #{id} AND include refidlogicalDeleteConditionproperty namealias valueuser//include /update insert idinsertSelective useGeneratedKeystrue keyPropertyid INSERT INTO asset ( include refidcommonInsertColumns/ ) VALUES ( include refidcommonInsertValues/ ) /insert这种模式将业务无关的、技术性的 SQL 模式沉淀下来新加一个表的 CRUD 操作时很多代码就只是“组装”这些通用片段开发速度和一致性都得到保障。4. 性能考量、最佳实践与避坑指南任何高级特性用不好都会带来副作用。下面是我在实践中总结的关于include进阶用法的性能点和最佳实践。4.1 性能影响解析与缓存很多人担心多用include会影响性能。实际上MyBatis 在初始化时会解析所有的 XML 文件将sql片段和包含它的语句一起构建成最终的MappedStatement对象。这个过程只发生一次应用启动时或首次加载该映射文件时。运行时include几乎没有额外开销它不像某些模板引擎在每次执行时都要做字符串替换。所以从性能角度可以放心使用。但是要注意避免过度设计。如果一个sql片段只被引用一次且逻辑非常简单那么抽离它反而会增加文件的跳转阅读成本。抽离的原则是真正被复用或者逻辑复杂到需要独立出来以提升主语句的可读性。4.2 最佳实践总结命名要有意义sql片段的id要能清晰表达其用途如userBaseColumns、paginateSuffix、logicalDeleteWhere。避免使用sql1、fragmentA这种无意义的名称。单一职责一个sql片段最好只做一件事。比如专门负责字段列表或者专门负责一个复杂的条件子句。不要把一个既能选字段又能加条件的巨大片段塞进去。明确作用域在片段内部和引用处清楚地知道每个${}变量是从哪里来的property传入、方法参数、还是全局配置。可以通过规范的命名来区分例如用p_前缀表示来自property(${p_alias})。优先使用property对于片段内部的动态行为尽量通过property传递开关或参数而不是让片段自己去方法参数里“猜”。这样片段的接口更清晰复用性更强。谨慎使用${}牢记${}是文本替换。除了动态列名、表名由系统生成、关键字如ASC/DESC这些绝对安全的场景外其他任何可能包含用户输入的地方都必须用#{}预编译。对于排序字段必须在服务层做映射和校验。处理逗号和 AND/OR在动态片段中使用trim标签的prefix、suffix、prefixOverrides、suffixOverrides属性来智能地添加或移除连接词如AND、OR和分隔符如逗号。这是写出健壮动态 SQL 的关键技巧。4.3 常见问题与排查技巧问题1引入片段后SQL 报语法错误提示“列名不明确”或“附近有语法错误”。排查首先打开 MyBatis 的 SQL 日志配置log4j.logger.org.apache.ibatisDEBUG或使用 MyBatis Log Plugin 等工具查看最终生成的完整 SQL 语句。95%的问题在这里都能发现。可能原因逗号问题在if标签内部动态添加字段后末尾多了一个逗号。使用trim suffixOverrides,包裹字段列表。AND/OR 问题动态条件片段开头多了一个AND。确保整个片段被包裹在where标签内或者使用trim prefixWHERE prefixOverridesAND |OR 。别名未替换检查${alias}是否在所有需要的地方都被正确替换。有时片段中硬编码了别名但引用时传入了不同的别名。问题2${variable}没有被替换原样输出到了 SQL 中。排查确认variable这个名字在property标签的name属性中是否拼写一致大小写敏感。检查作用域这个变量是否确实通过property传递了还是你期望它从方法参数中获取如果从参数获取确保参数名正确且使用了Param注解。问题3片段中的if test判断始终不成立或始终成立。排查if test中的表达式是 OGNL。如果判断的是property传入的变量要用${var}格式。如果判断的是方法参数则用参数名。例如if test${flag} true和if testuser.name ! null是不同的。查看日志中绑定的参数值确认判断条件是否符合预期。问题4在 IntelliJ IDEA 中include标签跳转或提示失效。原因IDEA 的 MyBatis 插件有时对动态refid或复杂作用域支持不好。解决确保refid的值是字符串常量如refidbaseColumnList而不是用${}动态计算的。清理 IDEA 缓存并重启File - Invalidate Caches...。安装更专业的 MyBatis 插件如 MyBatisX它对此类功能的支持更好。5. 与 MyBatis Plus 等增强工具的对比思考现在很多项目使用 MyBatis PlusMP来简化开发。MP 提供了SqlFragment等注解以及强大的Wrapper查询条件构造器还能通过自定义 SQL 片段并注入到 MP 的通用方法中。那么原生的include还有必要吗我的观点是两者是互补关系而非替代关系。MyBatis Plus 的 Wrapper擅长在Java 代码层以类型安全、链式调用的方式动态构造查询条件eq(),like(),in()等。这对于复杂的、业务逻辑驱动的动态查询非常直观和强大。原生include标签擅长在XML 层声明和组合复杂的、结构化的 SQL 片段。它更适合那些固定模式、但需要根据简单开关参数化变体的 SQL 部分如动态字段、多方言支持、通用条件块。结合使用示例你可以用 MP 的 Wrapper 构建核心的动态 WHERE 条件同时用include来管理动态的SELECT字段列表和GROUP BY子句然后在自定义的 XML 方法中将它们组合起来。这样既利用了 MP 的便捷又保持了 XML 对复杂 SQL 结构良好的组织和复用能力。最终技术选型的核心是“合适”。对于纯静态或参数化模板型的 SQL 复用原生include标签的简洁和声明式特性依然是 MyBatis 生态中一个非常锋利且高效的工具。理解它的进阶用法能让你在应对复杂 SQL 场景时多一份从容写出既灵活又易于维护的数据访问层代码。