MyBatis <include>标签进阶:从SQL复用走向动态模板引擎

📅 发布时间:2026/8/17 21:56:36
MyBatis <include>标签进阶:从SQL复用走向动态模板引擎 1. 从“能用”到“好用”为什么需要关注include标签的进阶用法如果你用过MyBatis那对include标签肯定不陌生。它就像代码里的一个“宏”把一段重复的SQL片段抽出来然后在需要的地方引用一下解决了XML里SQL语句的复用问题。这大概是每个MyBatis初学者都会接触到的第一个“高级”标签。但说实话大多数人的用法也就止步于此了定义一个sql idbaseColumnid, name, age/sql然后在select里include refidbaseColumn/。这没错但仅仅是把“重复代码”变成了“可复用的重复代码”离“优雅”和“强大”还差得远。我见过不少项目初期为了赶进度SQL写得比较随意。等业务复杂起来UserMapper.xml可能就变成了一个上千行的“巨无霸”文件。里面充斥着各种select、update虽然用了include但引用的片段本身又长又复杂或者一个片段被几十个地方引用想改一个字段名都战战兢兢生怕“牵一发而动全身”。更头疼的是动态SQL场景比如根据不同的查询条件SELECT的字段列表需要动态变化或者WHERE子句的片段需要根据权限动态组合。这时候如果还只会基础的include refid.../要么就得写好几个几乎一样的sql片段要么就得在Java代码里用字符串拼接SQL把MyBatis的动态SQL优势丢得一干二净。这就是我们今天要深入聊include标签进阶用法的原因。它绝不仅仅是一个“代码提取器”。当你结合property标签和OGNL表达式就是MyBatis里用在${}和#{}里的那套表达式语言include能变身成一个强大的、参数化的、可配置的SQL模板引擎。你可以实现动态传递表名、字段名、排序规则甚至根据运行时条件决定包含哪一部分SQL逻辑。这能极大地提升Mapper XML的可维护性、可读性和灵活性把那些冗长、僵硬的SQL脚本变成清晰、模块化、易于管理的声明式配置。所以这篇文章不是给完全没接触过include的新手看的入门教程。而是面向那些已经“会用”但总感觉没“用透”在复杂业务场景下被SQL维护问题困扰的中高级开发者。我们会绕过那些基础定义直接切入几个在生产环境中真实、高频出现的痛点场景看看如何用include的进阶特性来优雅地解决它们。2. 核心机制拆解include、property与OGNL的化学反应在动手解决具体问题之前我们必须先搞清楚include标签这套组合拳的核心工作原理。很多人用不好是因为只知其然不知其所以然。2.1include标签的本质一个“编译时”的替换操作首先要明确一点include标签的处理发生在MyBatis解析XML配置文件的时候而不是在运行时。你可以把它想象成C语言里的#include或者模板引擎的宏展开。当MyBatis启动加载*Mapper.xml文件时它会遍历整个DOM树。遇到include refidsomeId节点它就会去当前文件或者通过sql标签引入的其他文件里寻找idsomeId的sql节点然后用这个sql节点的完整内容包括其所有子节点原地替换掉include节点本身。替换完成后内存中存在的就是一个完整的、展开了所有include的SQL语句树。后续的动态SQL标签如if、where、foreach处理、参数绑定#{}等操作都是基于这个展开后的“完整SQL树”进行的。这意味着include里面完全可以包含动态SQL标签这是实现高级用法的基础。2.2property标签向SQL片段传递参数的桥梁基础的include只是静态包含。要想动态就得传参。property标签就是干这个的。它作为include的子标签使用。include refidsomeFragment property namecolumnPrefix valueu./ property namesuffix value_alias/ /include这里有两个关键属性name: 参数名。它会在被引用的sql片段内部作为一个“变量名”被使用。value: 参数值。这是一个字符串字面量。注意它可以是固定的字符串如u.也可以是一个OGNL表达式字符串如${tableName}。但无论如何在XML解析层面它的值就是一个字符串。2.3 OGNL表达式与${}占位符实现动态替换的关键参数传过去了怎么在用呢这就轮到OGNL表达式和${}占位符上场了。在sql片段内部你可以通过${propName}的方式来引用通过property传递进来的参数。sql idsomeFragment ${columnPrefix}name${suffix}, ${columnPrefix}age${suffix} /sql结合上面的include最终展开的SQL会是u.name_alias, u.age_alias。这里有一个至关重要的细节也是很多坑的来源${}是文本替换而#{}是参数预编译。#{propName}在include的property上下文中是无效的。因为#{}是用于绑定运行时传入的JavaBean参数或Map参数的它的解析发生在更靠后的阶段。而property的value和sql中的${}替换发生在XML解析的早期。${propName}是直接的字符串替换。${columnPrefix}会被替换成字符串u.。这意味着如果你传递的值来自用户输入比如value${userInput}将直接面临SQL注入风险。因此绝对不要通过property传递未经净化的用户输入数据。它最适合传递的是开发者可控的、静态或半静态的元信息比如表别名、字段名前缀、固定的条件值等。它们是如何协作的MyBatis解析到include标签。读取其子标签property在内存中建立一个临时的“参数上下文”键值对为{columnPrefix: u., suffix: _alias}。找到对应的sql idsomeFragment节点。遍历这个sql节点的内容将其中所有的${propName}占位符用上一步“参数上下文”里对应name的value字符串进行替换。用替换后的完整内容替换掉原先的include节点。后续流程中这个位置就好像一开始就写的是u.name_alias, u.age_alias一样。理解了这套机制我们就能玩出很多花样了。下面我们进入实战场景。3. 实战场景一构建可配置的动态字段列表与条件片段这是最常见的高级需求。你的查询接口可能需要支持“字段选择”只返回指定的字段以提升性能或者“动态条件组合”多个可选条件任意组合查询。3.1 动态SELECT字段列表假设有一个用户查询前端可能传一个字段列表过来比如id,name,email希望只查询这些字段。笨办法是在Java里拼接SELECT子句然后通过${}传入整个子句但这很不安全也不优雅。用include可以这样做首先在Mapper接口定义方法使用Param注解明确参数名。ListUser selectByFields(Param(fieldList) ListString fieldList, Param(user) User queryCondition);然后在XML中我们利用foreach标签来生成字段列表但把它封装成一个可复用的片段。!-- 定义动态字段列表片段 -- sql iddynamicFields choose when testfieldList ! null and fieldList.size() 0 foreach collectionfieldList itemfield separator, ${field} /foreach /when otherwise id, name, email, age, create_time !-- 默认字段 -- /otherwise /choose /sql !-- 在查询中使用 -- select idselectByFields resultTypeUser SELECT include refiddynamicFields/ FROM user where if testuser.name ! null and user.name ! AND name LIKE CONCAT(%, #{user.name}, %) /if !-- 其他条件 -- /where /select进阶用法带表别名的字段列表。在多表关联查询时字段需要带表别名前缀如u.name,d.dept_name。我们可以创建一个更通用的片段通过property传递别名前缀。!-- 定义一个接收前缀的字段列表片段 -- sql idprefixedFields choose when testfieldList ! null and fieldList.size() 0 foreach collectionfieldList itemfield separator, ${prefix}${field} /foreach /when otherwise ${prefix}id, ${prefix}name, ${prefix}email /otherwise /choose /sql !-- 在关联查询中使用 -- select idselectUserWithDept resultMapuserDeptMap SELECT include refidprefixedFields property nameprefix valueu./ !-- 为用户表字段添加别名 -- /include, include refidprefixedFields property nameprefix valued./ property namefieldList valuedeptName, deptCode/ !-- 这里直接传值实际中可能来自参数 -- /include FROM user u LEFT JOIN department d ON u.dept_id d.id /select注意上面的例子中第二个include的fieldList是硬编码的。在实际复杂场景中你可能需要设计更巧妙的参数传递方式或者使用多个不同的sql片段来应对。3.2 动态WHERE条件片段复用WHERE子句中的一些复杂条件组合也经常需要复用。例如一个“时间范围查询”条件在很多查询中都会用到。!-- 定义可复用的时间范围查询片段 -- sql idtimeRangeCondition if teststartTime ! null AND create_time gt; #{startTime} /if if testendTime ! null AND create_time lt; #{endTime} /if /sql !-- 在查询1中使用 -- select idselectOrders resultTypeOrder SELECT * FROM order where status #{status} include refidtimeRangeCondition/ !-- 直接复用 -- /where /select !-- 在查询2中使用但字段名不同比如是update_time -- !-- 这时就需要参数化字段名 -- sql idgenericTimeRangeCondition if teststartTime ! null AND ${timeColumn} gt; #{startTime} /if if testendTime ! null AND ${timeColumn} lt; #{endTime} /if /sql select idselectLogs resultTypeLog SELECT * FROM operation_log where operator #{operator} include refidgenericTimeRangeCondition property nametimeColumn valueoperate_time/ !-- 传入具体的字段名 -- /include /where /select实操心得命名清晰sql片段的id要能清晰表达其功能如baseColumnList、activeUserCondition、paginationSuffix等。作用域管理sql片段默认只在当前XML文件中有效。可以通过sql id...标签的resource或url属性引入其他Mapper文件的片段但过度使用会使依赖关系变得隐蔽建议谨慎。对于全局通用的片段如逻辑删除条件is_deleted0可以集中放在一个如CommonSql.xml的文件中然后在各个Mapper.xml的头部用include引入注意MyBatis原生不支持直接include文件但可以通过配置sql的resource来达到类似效果或者更常见的做法是使用MyBatis的sql标签的databaseId属性配合include来包含外部片段但这需要额外的配置。更简单的做法是利用MyBatis代码生成器MyBatis Generator的插件功能在生成每个Mapper时自动注入这些通用片段。避免过度设计不要为了复用而复用。如果一个SQL片段只在两个地方用到且逻辑简单直接复制粘贴的维护成本可能低于设计一个参数化片段带来的理解成本。当复用点超过3个或者片段本身逻辑复杂、经常需要统一修改时再考虑抽离。4. 实战场景二实现动态表名与排序逻辑在一些更动态的场景比如分表存储按时间分表、多租户每个租户独立表或者前端可自定义排序规则时表名和ORDER BY子句可能需要动态生成。4.1 动态表名查询假设我们有一个按月份分表的订单系统表名格式为order_202401、order_202402。查询时需要根据传入的月份参数动态选择表。危险做法绝对禁止select idselectByMonth resultTypeOrder SELECT * FROM order_${month} WHERE user_id #{userId} !-- SQL注入风险极高 -- /select${month}直接拼接如果month参数来自不可信源后果不堪设想。安全做法在Java层进行严格的校验和映射确保传入的month参数只能是合法的、预定义的月份格式如“202401”。// Service层 public ListOrder getOrdersByMonth(String month, Long userId) { // 1. 校验month格式必须是yyyyMM且在一定时间范围内 if (!isValidMonth(month)) { throw new IllegalArgumentException(Invalid month format); } // 2. 拼接表名 String tableName order_ month; // 3. 调用Mapper传入校验后的表名 return orderMapper.selectByTableName(tableName, userId); }!-- Mapper.xml -- select idselectByTableName resultTypeOrder SELECT * FROM ${tableName} WHERE user_id #{userId} AND is_deleted 0 /select虽然这里仍然用了${tableName}但因为tableName是在Service层用代码拼接的、经过严格校验的字符串风险是可控的。这是一种“白名单”思想。使用include和property的优雅实现我们可以把动态表名部分也封装起来使SQL主体更清晰。!-- 定义动态表名片段 -- sql iddynamicOrderTable order_${month} /sql select idselectByMonth resultTypeOrder SELECT * FROM include refiddynamicOrderTable/ WHERE user_id #{userId} /select这个例子中${month}参数需要能从当前执行的参数上下文中获取。确保你的Mapper接口方法参数中包含month或者传入的Map/JavaBean中有month属性。这并没有改变安全本质只是让SQL结构更好看。核心安全原则依然是在Java代码层做校验。4.2 动态排序ORDER BY前端传递排序字段和方向如sortFieldcreateTimesortOrderDESC是非常常见的需求。同样需要警惕SQL注入。!-- 定义安全的排序片段 -- sql idsafeOrderBy ORDER BY choose !-- 白名单校验排序字段 -- when testsortField createTime or sortField updateTime or sortField amount ${sortField} !-- 校验排序方向 -- choose when testsortOrder ! null and sortOrder.toUpperCase() DESC DESC /when otherwise ASC !-- 默认升序 -- /otherwise /choose /when otherwise id DESC !-- 默认排序 -- /otherwise /choose /sql select idselectWithSort resultTypeSomeEntity SELECT * FROM some_table where.../where include refidsafeOrderBy/ /select在这个片段里我们利用MyBatis的动态SQL标签choose、when、otherwise实现了一个安全的、可配置的排序逻辑。它只允许排序几个预定义的字段createTime,updateTime,amount并对排序方向做了校验和默认值处理。这样即使前端传递了恶意参数也无法注入非法SQL。更复杂的动态排序有时排序规则可能更复杂比如多个字段排序或者根据不同的业务场景有不同的默认排序。我们可以进一步参数化。sql idmultiFieldOrderBy ORDER BY foreach collectionorderByList itemorderItem separator, ${orderItem.field} if testorderItem.direction ! null and orderItem.direction.toUpperCase() DESC DESC /if !-- 这里可以继续扩展比如处理NULL值排序 NULLS FIRST/LAST -- /foreach /sql使用时需要传入一个ListOrderByItem这样的参数其中OrderByItem是一个包含field和direction属性的对象。同样在业务层需要确保field的合法性。5. 实战场景三解决分页查询中的COUNT语句复用与优化分页查询几乎每个项目都有。标准的MyBatis分页查询通常是一个SELECT语句配合一个COUNT(*)语句。COUNT语句往往和SELECT语句的WHERE条件完全一样。这就产生了大量的重复代码。5.1 基础复用提取公共的WHERE条件最直接的做法是把复杂的WHERE条件抽成一个sql片段。!-- 公共查询条件 -- sql idqueryUserCondition where is_deleted 0 if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if if testdeptIdList ! null and deptIdList.size() 0 AND dept_id IN foreach collectiondeptIdList itemdeptId open( separator, close) #{deptId} /foreach /if !-- 引入时间范围片段 -- include refidtimeRangeCondition/ /where /sql !-- 分页查询数据 -- select idselectUserPage resultTypeUser SELECT id, name, email FROM user include refidqueryUserCondition/ ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select !-- 查询总数 -- select idcountUser resultTypelong SELECT COUNT(*) FROM user include refidqueryUserCondition/ /select这解决了重复定义WHERE条件的问题。但还有优化空间COUNT语句通常不需要ORDER BY和LIMIT但有些复杂的SELECT语句可能包含GROUP BY或DISTINCT这时COUNT语句需要做相应调整不能简单复用WHERE。5.2 进阶复用处理GROUP BY和DISTINCT当主查询使用了GROUP BY时COUNT语句需要计算的是分组后的行数而不是原始表的行数。通常我们会用子查询SELECT COUNT(*) FROM (SELECT 1 FROM ... GROUP BY ...) tmp。我们可以设计一个更智能的片段根据参数决定是否生成GROUP BY子句。!-- 可配置是否包含GROUP BY的查询主体片段 -- sql idqueryBodyWithOptionalGroupBy FROM user include refidqueryUserCondition/ if testgroupByFields ! null and groupByFields ! GROUP BY ${groupByFields} /if /sql select idselectUserGroupPage resultTypemap SELECT dept_id, COUNT(*) as user_count, AVG(age) as avg_age include refidqueryBodyWithOptionalGroupBy/ ORDER BY user_count DESC LIMIT #{offset}, #{pageSize} /select select idcountUserGroup resultTypelong SELECT COUNT(*) FROM ( SELECT 1 include refidqueryBodyWithOptionalGroupBy !-- 强制传入groupByFields确保COUNT子查询和主查询分组条件一致 -- property namegroupByFields valuedept_id/ /include ) tmp /select这里我们通过property标签在COUNT查询中强制指定了groupByFields确保了子查询和主查询的GROUP BY逻辑一致。这是一种通过参数控制片段行为的典型用法。5.3 使用MyBatis-Plus等插件简化虽然手动封装include很灵活但在现代开发中我们更倾向于使用MyBatis-Plus这类增强工具。它内置了强大的分页插件能自动优化COUNT查询例如遇到GROUP BY时会自动转换为子查询形式开发者几乎不需要关心COUNT语句的编写。但理解其背后的原理对于排查问题和进行深度定制非常有帮助。当你需要脱离框架实现一些定制化分页逻辑时上面这些include的用法就能派上用场。6. 避坑指南与性能考量掌握了高级用法也别忘了脚下的坑。include用不好反而会带来维护灾难和性能问题。6.1 常见陷阱与排查refid找不到的错误这是最常遇到的。可能原因id拼写错误。检查大小写。sql片段定义在了另一个Mapper XML文件中但没有被正确引入。确保使用include时该片段在当前文件或已引入的文件中可见。跨文件引用通常需要配置mapper的namespace或使用sql的databaseId等特性比较复杂建议优先将通用片段放在一个公共文件然后通过MyBatis配置的mapper扫描或者代码生成器插件来确保每个Mapper都能“看到”它。片段定义在了select或其他语句标签内部。sql标签必须作为mapper的直接子元素不能嵌套在其他动态SQL标签里。${}替换后SQL语法错误缺失引号或逗号当${}替换的内容是字符串值且需要参与SQL比较时容易出错。例如WHERE type ${typeValue}如果typeValue是字符串A替换后是WHERE type A这没问题。但如果typeValue来自property的value且value本身没有引号如property nametypeValue valueA/替换后就成了WHERE type AA会被认为是列名导致错误。解决方法要么在property的value里加上单引号valueA要么在SQL片段里用#{}但property不支持#{}要么就避免用${}传递需要引号的字符串值这类值最好通过方法参数传入用#{param}绑定。注入风险再强调任何直接使用${}接收用户输入的地方都是高危的。务必在业务层进行白名单校验或强制类型转换。动态SQL标签在include内不生效 检查你的sql片段里使用的OGNL表达式test条件里的判断。这些表达式求值时所使用的“上下文”或“参数对象”是调用include的那个语句所传入的参数。如果include片段里写了if testname ! null那么调用它的select方法必须传入一个包含name属性的参数或Map中有name键。property标签传入的参数在sql片段内部可以通过${propName}访问但不能直接在if test的OGNL表达式中使用。例如你不能在sql里写if test${flag} true。property传参和动态SQL的OGNL表达式求值是两套机制。6.2 性能与可维护性的平衡过度碎片化把SQL拆得过碎一个简单的查询需要include七八个片段跳来跳去阅读理解成本反而更高。建议将逻辑紧密相关的部分放在一个片段内。一个sql片段最好能代表一个完整的“语义单元”比如“查询基础字段”、“活跃用户过滤条件”、“分页排序后缀”。嵌套过深include标签可以嵌套即A片段包含B片段B片段又包含C片段。但嵌套深度最好不要超过3层否则调试和定位问题会非常困难。MyBatis的报错信息有时不会展开被包含的片段你看到的行号可能是include标签所在的行而不是片段内部出错的行。影响解析性能理论上大量的include和动态SQL会增加XML解析的复杂度。但在实际应用中除非你的Mapper文件极其庞大数万行否则这部分开销在应用启动时是微不足道的与运行时SQL执行的开销相比可以忽略不计。不必过早优化。与MyBatis缓存的关系一级缓存SqlSession级别和二级缓存Mapper级别缓存的是最终执行SQL语句和结果映射。include的解析过程发生在缓存之前因此不会影响缓存逻辑。你不用担心因为使用了include而导致相同的查询条件因为片段不同而被误认为不同SQL。6.3 调试技巧如何查看被include展开后的真实SQL这是排查include相关问题最有效的手段。MyBatis最终执行的SQL是经过所有动态标签包括include处理后的。有几种方式查看开启MyBatis全局SQL日志在application.yml或mybatis-config.xml中配置log-impl为STDOUT_LOGGING或SLF4J并设置对应Mapper接口的日志级别为DEBUG。这样可以在控制台看到完整的、带参数占位符?的SQL。使用第三方插件比如“MyBatis Log Plugin”IDEA插件它可以自动将控制台中输出的Preparing:和Parameters:两行日志合并还原出可直接在数据库客户端执行的、带真实参数的SQL语句非常方便。使用Arthas等诊断工具对于线上问题可以使用Arthas的watch命令来拦截org.apache.ibatis.mapping.BoundSql.getSql()方法的返回值直接获取到运行时MyBatis准备执行的原生SQL字符串。命令类似watch org.apache.ibatis.mapping.BoundSql getSql -x 3。这能让你看到最真实的、展开后的SQL形态。当你发现执行的SQL不符合预期时第一件事就是通过上述方法把完整的SQL打印出来然后逐字逐句地和你的XML配置进行比对特别是检查${}替换后的结果是否正确动态SQL条件是否按预期添加或省略。