ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis-Plus多表查询实战:QueryWrapper与@Select注解结合解决分页失效

MyBatis-Plus多表查询实战:QueryWrapper与@Select注解结合解决分页失效 1. 为什么单靠QueryWrapper搞不定多表查询1.1 一个真实的需求场景先说一个我最近接到的需求。后台管理系统要做一个订单列表页展示字段包括订单号、下单时间、客户姓名、客户手机号、商品名称、商品单价、购买数量、订单总金额。数据分散在四张表里t_order、t_customer、t_order_item、t_product。前端还要支持按客户姓名模糊搜索、按下单时间范围筛选、按订单状态过滤并且要分页。如果用最原始的方式要么手写一大段 JOIN 的 XML要么在 Java 里查完订单再循环查客户和商品——前者维护起来痛苦后者就是经典的 N1 问题。MyBatis-Plus 的QueryWrapper单表用起来很爽但一碰到多表 JOIN很多人第一反应是这玩意儿不支持吧然后乖乖回去写 XML。其实不是不支持而是需要换一个思路把 QueryWrapper 当作条件构造器把多表 SQL 的骨架交给Select注解两者通过${ew.customSqlSegment}这个占位符缝合起来。这套方案我在三个项目里用过稳定、可维护而且分页插件照样生效。1.2 核心思路一句话讲清传统 XML 写法是这样的在 Mapper 接口里定义方法在 XML 里写select标签用if test...拼条件。这套东西能用但条件一多XML 就变成了一棵巨大的 if 树改一个字段要在 Java 和 XML 之间来回跳。新思路是把条件拼接这件事完全交给 QueryWrapperSQL 的 SELECT 部分和 JOIN 部分写死在Select注解里条件部分用${ew.customSqlSegment}占位。MyBatis-Plus 在执行时会把这个占位符替换成 QueryWrapper 生成的条件片段包括 WHERE、ORDER BY 等。这样 Java 代码里只操作 WrapperSQL 骨架保持干净。打个比方Select注解负责画好一张表格的框架和表头QueryWrapper 负责往格子里填筛选条件。两者分工明确谁也不越界。1.3 这套方案适合谁、解决什么问题适合的人群很明确已经在用 MyBatis-Plus、不想为了多表查询退回纯 XML、又希望条件构造保持类型安全和链式调用的开发者。如果你还在用 JPA 或者纯 MyBatis这套方案的核心思想SQL 骨架与条件分离同样有参考价值。它解决的核心痛点有三个一是多表 JOIN 条件下 XML 维护成本高二是分页插件在自定义 SQL 里容易失效三是条件复用困难同一个查询条件在不同接口里要重复写。下面我会逐个拆解怎么解决。2. 方案整体设计与关键选型考量2.1 为什么选 Select 注解而不是 XMLMyBatis-Plus 支持两种写自定义 SQL 的方式XML 映射文件和注解。多表查询场景下我优先选注解原因有三。第一注解和 Mapper 接口方法定义在一起看方法签名就能看到 SQL不用在两个文件之间跳转。对于中等复杂度的多表查询JOIN 三到五张表注解的可读性完全够用。第二注解方式天然支持${ew.customSqlSegment}占位符和 QueryWrapper 配合是无缝的。XML 里虽然也能用但配置起来多一层。第三注解方式下分页插件的兼容性更好处理。MyBatis-Plus 的分页拦截器会识别方法参数里的IPage对象自动改写 SQL 加上 LIMIT。注解方式下这个机制工作得很稳定。当然如果 SQL 超过 50 行、嵌套子查询特别多那还是老老实实写 XML。注解适合的是骨架清晰、条件多变的场景这恰好是多表查询的典型特征。2.2 ${ew.customSqlSegment} 到底做了什么这个占位符是整个方案的核心必须讲清楚它的行为否则很容易踩坑。当 Mapper 方法里有一个名为ew的Wrapper参数时MyBatis-Plus 会拦截这次查询调用 Wrapper 的getCustomSqlSegment()方法生成一段类似WHERE (customer_name LIKE ? AND status ?) ORDER BY create_time DESC的 SQL 片段然后替换掉${ew.customSqlSegment}。注意几个关键点占位符名字里的ew不是固定的它对应的是方法参数里 Wrapper 的参数名。如果你把参数命名为wrapper那占位符就得写${wrapper.customSqlSegment}。我习惯统一用ew因为这是 MyBatis-Plus 官方示例里的约定团队协作时不容易乱。生成的条件片段自带 WHERE 关键字。所以你的Select里不要再写 WHERE否则会变成WHERE WHERE。如果 Wrapper 里没有任何条件customSqlSegment会返回空字符串SQL 依然合法。这一点很关键意味着你可以放心地传一个空 Wrapper。它生成的是${}拼接而非#{}预编译但因为 Wrapper 内部的值最终是通过#{}参数化的所以不存在 SQL 注入风险。真正被拼接进 SQL 的只有字段名和关键字值都是占位符。2.3 分页为什么容易失效怎么保证不失效这是热词里出现频率很高的问题mybatisplus分页失效。在多表自定义 SQL 场景下分页失效通常有三个原因。第一个原因是方法签名里没有IPage参数。分页拦截器靠识别IPage类型的参数来决定是否改写 SQL。如果你只传了 Wrapper 没传 IPage拦截器根本不知道你要分页。第二个原因是Select里的 SQL 被拦截器判定为无法安全改写。比如 SQL 里有UNION、复杂的子查询、或者SELECT后面跟了聚合函数拦截器可能放弃改写。解决办法是尽量让外层 SQL 结构简单把复杂逻辑放到子查询里。第三个原因是count查询慢。热词里也提到了select count 就会很慢。多表 JOIN 的 count 查询会扫描所有关联表数据量大时非常慢。我的做法是给 count 查询单独写一个简化版的 SQL只 JOIN 必要的表或者用optimizeCountSql配置让插件自动优化。// 分页配置关键是开启 count 优化 Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); // 开启 count 的 join 优化只针对部分 left join pagination.setOptimizeJoin(true); interceptor.addInnerInterceptor(pagination); return interceptor; }2.4 参数命名与返回类型的约定为了让这套方案在团队里可复制我定了几条约定实测下来能减少大量沟通成本。Mapper 方法里Wrapper 参数统一命名为ew分页参数统一命名为page。返回类型如果是分页用IPageVO如果是列表用ListVO。VO 类专门用于接收多表查询结果字段名和 SQL 里的别名一一对应不要复用 Entity。Select(SELECT o.order_no, o.create_time, c.customer_name, c.phone, p.product_name, i.price, i.quantity FROM t_order o LEFT JOIN t_customer c ON o.customer_id c.id LEFT JOIN t_order_item i ON i.order_id o.id LEFT JOIN t_product p ON i.product_id p.id ${ew.customSqlSegment}) IPageOrderDetailVO selectOrderDetailPage(IPageOrderDetailVO page, Param(ew) QueryWrapperOrderDetailVO ew);这段代码是整个方案的模板后面所有变体都从这里衍生。3. 核心细节解析与实操要点3.1 Wrapper 里字段名必须写数据库列名这是新手最容易踩的坑。用QueryWrapper单表查询时你可以写eq(customerName, name)MyBatis-Plus 会自动帮你做驼峰转下划线。但在${ew.customSqlSegment}场景下这个自动转换不会发生因为插件不知道你的字段属于哪张表无法推断映射关系。所以多表查询时Wrapper 里的字段名必须直接写数据库列名而且要带上表别名。QueryWrapperOrderDetailVO ew new QueryWrapper(); ew.like(c.customer_name, keyword); ew.eq(o.status, status); ew.ge(o.create_time, startTime); ew.le(o.create_time, endTime); ew.orderByDesc(o.create_time);如果你写ew.like(customerName, keyword)生成的 SQL 会是WHERE (customerName LIKE ?)数据库直接报未知列。这个错误我第一次用的时候卡了半小时因为报错信息不会告诉你应该写列名只会说列不存在。提示团队里最好在 VO 类的字段上加注释标明对应的数据库列名和表别名避免有人写错。3.2 条件为空时的处理逻辑实际业务里筛选条件经常是用户填了才筛没填就不筛。QueryWrapper 的链式调用天然支持这个条件方法都有重载版本第一个参数是boolean condition为 false 时该条件不加入 SQL。ew.like(StringUtils.isNotBlank(keyword), c.customer_name, keyword); ew.eq(status ! null, o.status, status); ew.ge(startTime ! null, o.create_time, startTime);这样写比在外面套一堆if干净得多。我见过有人这么写// 不推荐 if (StringUtils.isNotBlank(keyword)) { ew.like(c.customer_name, keyword); } if (status ! null) { ew.eq(o.status, status); }两种写法功能一样但前者更紧凑而且条件一多优势就明显了。不过要注意condition为 false 时后面的参数依然会被求值所以如果参数是方法调用比如getStatus()要确保它不会抛异常。3.3 动态排序与多字段排序排序也是多表查询的常见需求。前端可能传sortField和sortOrder后端要动态拼 ORDER BY。这里有个安全考量排序字段不能直接拼接用户输入否则就是 SQL 注入。我的做法是维护一个白名单把前端传的字段名映射到数据库列名。private static final MapString, String SORT_FIELD_MAP Map.of( createTime, o.create_time, amount, o.total_amount, customerName, c.customer_name ); String column SORT_FIELD_MAP.get(sortField); if (column ! null) { ew.orderBy(true, asc.equalsIgnoreCase(sortOrder), column); }orderBy方法的签名是orderBy(boolean condition, boolean isAsc, String column)第二个参数控制升序还是降序。多字段排序就多次调用会按调用顺序拼接。3.4 用 LambdaQueryWrapper 还是 QueryWrapperMyBatis-Plus 提供了LambdaQueryWrapper用方法引用代替字符串字段名编译期就能发现拼写错误。但在多表场景下它有个致命限制Lambda 表达式只能引用实体类的字段无法表达表别名。比如你想写c.customer_nameLambda 写法是Customer::getCustomerName生成的 SQL 是customer_name没有表别名。如果多张表有同名列比如都有create_time就会报列名歧义。所以多表查询我统一用普通QueryWrapper接受字符串字段名带来的拼写风险用单元测试来兜底。单表查询才用 Lambda 版本。3.5 参数传递的几种方式对比Select注解里引用参数有几种写法各有适用场景。写法示例适用场景注意事项Param命名Param(ew) QueryWrapper ewWrapper 参数占位符名必须与 Param 值一致Param命名Param(status) Integer status固定条件参数用#{status}引用对象属性Param(query) QueryDTO query多参数封装用#{query.status}引用集合Param(ids) ListLong idsIN 查询配合foreach或 Wrapper 的 inWrapper 参数必须用Param显式命名否则 MyBatis 无法确定参数名${ew.customSqlSegment}会解析失败。这一点和普通参数不同普通参数在单参数时可以省略Param。4. 完整实操过程与核心环节实现4.1 环境准备与依赖配置先确认依赖版本。这套方案依赖 MyBatis-Plus 3.4.0 以上版本customSqlSegment在更早版本里行为不一致。我用的是 3.5.3.1稳定。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency分页插件必须配置否则IPage参数不生效。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); // 单页最大 500 条 pagination.setOptimizeJoin(true); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)是热词里接触 mybatisplus 单页 500 条限制的来源。这个配置是主动设的防止前端传个size100000把数据库拖垮。如果不设默认是不限制的。4.2 定义 VO 与 Mapper 接口VO 类的字段要和 SQL 里的别名严格对应。我习惯用Data加 Lombok字段名用驼峰SQL 别名用下划线MyBatis 的mapUnderscoreToCamelCase会自动转换。Data public class OrderDetailVO { private String orderNo; private LocalDateTime createTime; private String customerName; private String phone; private String productName; private BigDecimal price; private Integer quantity; private BigDecimal totalAmount; }Mapper 接口定义方法。注意IPage参数要放在第一个这是 MyBatis-Plus 的约定虽然放后面也能工作但放第一个可读性最好。public interface OrderMapper extends BaseMapperOrder { Select(SELECT o.order_no, o.create_time, c.customer_name, c.phone, p.product_name, i.price, i.quantity, (i.price * i.quantity) AS total_amount FROM t_order o LEFT JOIN t_customer c ON o.customer_id c.id LEFT JOIN t_order_item i ON i.order_id o.id LEFT JOIN t_product p ON i.product_id p.id ${ew.customSqlSegment}) IPageOrderDetailVO selectOrderDetailPage(IPageOrderDetailVO page, Param(ew) QueryWrapperOrderDetailVO ew); }这里有个细节total_amount是计算字段用(i.price * i.quantity)算出来。如果 Wrapper 里要按这个字段排序直接写ew.orderByDesc(total_amount)就行因为它是 SELECT 里的别名MySQL 允许在 ORDER BY 里用别名。4.3 Service 层组装查询条件Service 层负责把前端参数翻译成 Wrapper。我习惯把条件组装抽成一个私有方法方便复用和测试。Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Override public IPageOrderDetailVO queryOrderDetail(OrderQueryDTO query) { PageOrderDetailVO page new Page(query.getPageNum(), query.getPageSize()); QueryWrapperOrderDetailVO ew buildWrapper(query); return orderMapper.selectOrderDetailPage(page, ew); } private QueryWrapperOrderDetailVO buildWrapper(OrderQueryDTO query) { QueryWrapperOrderDetailVO ew new QueryWrapper(); ew.like(StringUtils.isNotBlank(query.getKeyword()), c.customer_name, query.getKeyword()); ew.eq(query.getStatus() ! null, o.status, query.getStatus()); ew.ge(query.getStartTime() ! null, o.create_time, query.getStartTime()); ew.le(query.getEndTime() ! null, o.create_time, query.getEndTime()); ew.eq(query.getCustomerId() ! null, o.customer_id, query.getCustomerId()); // 动态排序白名单校验 String column SORT_FIELD_MAP.get(query.getSortField()); if (column ! null) { ew.orderBy(true, asc.equalsIgnoreCase(query.getSortOrder()), column); } else { ew.orderByDesc(o.create_time); // 默认排序 } return ew; } }注意默认排序的处理如果前端没传排序字段给一个默认的o.create_time DESC。这很重要否则分页时数据顺序不稳定翻页可能出现重复或遗漏。4.4 生成的 SQL 长什么样假设前端传了keyword张、status1、startTime2024-01-01排序按createTime降序页码 1每页 10 条。最终执行的 SQL 大致是SELECT o.order_no, o.create_time, c.customer_name, c.phone, p.product_name, i.price, i.quantity, (i.price * i.quantity) AS total_amount FROM t_order o LEFT JOIN t_customer c ON o.customer_id c.id LEFT JOIN t_order_item i ON i.order_id o.id LEFT JOIN t_product p ON i.product_id p.id WHERE (c.customer_name LIKE ? AND o.status ? AND o.create_time ?) ORDER BY o.create_time DESC LIMIT 10count 查询会被分页插件自动改写为SELECT COUNT(*) FROM t_order o LEFT JOIN t_customer c ON o.customer_id c.id LEFT JOIN t_order_item i ON i.order_id o.id LEFT JOIN t_product p ON i.product_id p.id WHERE (c.customer_name LIKE ? AND o.status ? AND o.create_time ?)注意 count 查询里 SELECT 部分被替换成了COUNT(*)但 JOIN 和 WHERE 都保留了。这就是为什么 count 可能慢——它要扫描所有关联表。如果数据量大可以考虑给 count 单独写一个简化 SQL去掉不必要的 JOIN。4.5 分页参数与返回结构Page对象的构造是new Page(pageNum, pageSize)页码从 1 开始。返回的IPage里包含records当前页数据、total总条数、pages总页数、current、size。前端需要的通常是records和total。我习惯在 Controller 层再包一层统一响应结构把IPage转成{ list, total, pageNum, pageSize }避免把 MyBatis-Plus 的内部结构暴露给前端。GetMapping(/order/detail) public ResultPageResultOrderDetailVO queryOrderDetail(OrderQueryDTO query) { IPageOrderDetailVO page orderService.queryOrderDetail(query); PageResultOrderDetailVO result new PageResult( page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize()); return Result.ok(result); }4.6 多表查询的另一种变体子查询 Wrapper有些场景下条件需要作用在子查询上。比如查询购买了某类商品的订单商品类别在t_product表里但订单和商品是多对多关系。这时候可以在Select里写子查询Wrapper 只作用于外层。Select(SELECT o.order_no, o.create_time, c.customer_name FROM t_order o LEFT JOIN t_customer c ON o.customer_id c.id WHERE o.id IN ( SELECT oi.order_id FROM t_order_item oi LEFT JOIN t_product p ON oi.product_id p.id WHERE p.category_id #{categoryId} ) ${ew.customSqlSegment}) IPageOrderDetailVO selectByCategory(IPageOrderDetailVO page, Param(categoryId) Long categoryId, Param(ew) QueryWrapperOrderDetailVO ew);注意这里外层已经有 WHERE 了所以${ew.customSqlSegment}生成的条件会变成AND (...)追加在后面。MyBatis-Plus 会自动处理这个衔接生成的条件片段以AND开头而不是WHERE。这个行为很贴心但前提是外层 WHERE 后面要有内容否则会变成WHERE AND (...)。注意如果外层 WHERE 是空的而 Wrapper 有条件SQL 会出错。解决办法是外层 WHERE 里放一个恒真条件比如WHERE 11或者把子查询条件也放进 Wrapper。5. 常见问题与排查技巧实录5.1 分页失效问题速查表现象可能原因排查方法解决方案返回全部数据不分页方法签名缺 IPage 参数检查 Mapper 方法加上 IPage 参数并放第一位分页插件未生效未配置拦截器检查是否有 MybatisPlusInterceptor Bean添加分页拦截器配置count 查询报错SQL 含 UNION 或复杂子查询看日志里的 count SQL单独写 count 方法或简化 SQL分页数据重复排序字段不唯一检查 ORDER BY加唯一字段如 id做次级排序单页超过 500 条未设 maxLimit检查分页配置setMaxLimit(500L)5.2 字段名写错导致的报错最常见的报错是Unknown column xxx in where clause。原因几乎都是 Wrapper 里写了 Java 字段名而不是数据库列名。排查方法很简单把日志级别调到 DEBUG看 MyBatis 打印的最终 SQL一眼就能看出哪个字段名不对。logging: level: com.example.mapper: debug我习惯在开发阶段一直开着 Mapper 包的 DEBUG 日志上线前再关掉。这样每次查询的 SQL 和参数都能看到排查问题效率翻倍。5.3 count 查询慢的优化思路多表 JOIN 的 count 慢是常态。我总结了几种优化手段按优先级排列。第一开启optimizeJoin。分页插件会尝试去掉 count SQL 里不影响结果的 LEFT JOIN。比如某个 LEFT JOIN 的表在 WHERE 里没用到且不影响行数就可以安全去掉。第二给 WHERE 条件涉及的字段加索引。c.customer_name如果要做 LIKE 查询前缀匹配LIKE 张%能用索引全模糊LIKE %张%用不上。如果业务允许尽量用前缀匹配。第三数据量特别大时考虑用延迟关联。先只查主表 ID 和分页再用 ID 去关联其他表。SELECT o.order_no, c.customer_name, ... FROM (SELECT id FROM t_order WHERE ... LIMIT 0, 10) t JOIN t_order o ON t.id o.id LEFT JOIN t_customer c ON o.customer_id c.id这种写法 count 只扫主表快很多。但实现起来要拆成两次查询复杂度上升只在性能瓶颈明显时才用。5.4 Wrapper 条件不生效的几种情况有时候明明加了条件SQL 里却没有。常见原因有三个。一是condition参数传了 false。比如ew.eq(status ! null, o.status, status)如果status是 null条件就不加。这是预期行为但容易忘。二是字段名拼写错误但恰好数据库里有同名列条件加上了却作用在错误的表上。这种最隐蔽只能靠仔细核对。三是 Wrapper 被复用了。QueryWrapper 是有状态的同一个实例多次传入不同方法条件会累积。我见过有人在循环里复用同一个 Wrapper结果条件越加越多。正确做法是每次查询都 new 一个新的。5.5 排序字段注入的防护前面提过排序字段白名单这里再强调一下。orderBy方法接收的是字符串列名如果直接拼接用户输入就是 SQL 注入漏洞。比如用户传sortField1; DROP TABLE t_order后果不堪设想。白名单是最简单有效的防护。如果字段太多维护不过来可以用正则校验只允许字母、数字、下划线且必须以字母开头。private static final Pattern SAFE_COLUMN Pattern.compile(^[a-zA-Z][a-zA-Z0-9_]*$); if (SAFE_COLUMN.matcher(column).matches()) { ew.orderBy(true, isAsc, column); }但正则只能防注入防不了排序字段不存在的报错。所以白名单还是首选。5.6 多数据源下的注意事项如果项目用了多数据源${ew.customSqlSegment}的行为不受影响但分页插件的 DbType 要配对。MySQL 和 Oracle 的分页语法不同配错了会生成错误的 SQL。多数据源场景下每个数据源要配独立的MybatisPlusInterceptor或者用动态数据源插件自动识别。我在一个同时用 MySQL 和 PostgreSQL 的项目里踩过这个坑分页插件配了 MySQL查 PostgreSQL 时生成的LIMIT语法虽然能用但 count 查询的优化策略不对性能很差。后来改成按数据源分别配置才解决。6. 几个进阶玩法与扩展思路6.1 条件复用把 Wrapper 构建抽成工具类如果多个接口共用同一套筛选条件可以把 Wrapper 构建逻辑抽成静态方法。public class OrderWrapperBuilder { public static QueryWrapperOrderDetailVO build(OrderQueryDTO query) { QueryWrapperOrderDetailVO ew new QueryWrapper(); // ... 条件组装 return ew; } }这样不同 Service 调用同一个构建器条件逻辑只有一份改一处全生效。但要注意构建器返回的是新实例不能缓存复用。6.2 与 XML 混用的场景有些团队规范要求复杂 SQL 必须写 XML。这种情况下${ew.customSqlSegment}在 XML 里同样能用。select idselectOrderDetailPage resultTypeOrderDetailVO SELECT o.order_no, c.customer_name, ... FROM t_order o LEFT JOIN t_customer c ON o.customer_id c.id ${ew.customSqlSegment} /selectMapper 接口方法签名不变。这样既满足了团队规范又保留了 Wrapper 的灵活性。我现在的项目就是注解和 XML 混用简单的多表查询用注解超过 30 行的用 XML。6.3 动态表名的处理极少数场景下表名也需要动态。比如按月分表t_order_202401、t_order_202402。这种需求Select注解搞不定因为表名在编译期就固定了。解决办法是用 MyBatis 的动态 SQL 插件或者用${tableName}占位符配合Param传入。但表名拼接有注入风险必须严格校验只允许白名单里的表名。Select(SELECT ... FROM ${tableName} o LEFT JOIN t_customer c ON o.customer_id c.id ${ew.customSqlSegment}) IPageOrderDetailVO selectFromTable(IPageOrderDetailVO page, Param(tableName) String tableName, Param(ew) QueryWrapperOrderDetailVO ew);这种写法我一般不用除非分表是硬需求。能用分区表解决的优先用分区表。6.4 性能监控与慢 SQL 记录多表查询上线后一定要有慢 SQL 监控。MyBatis-Plus 自带性能分析插件但功能有限。我一般用 p6spy 或者 Druid 的监控功能把执行超过 1 秒的 SQL 记录下来。# p6spy 配置示例 decorator: datasource: p6spy: log-slow-sql: true slow-sql-millis: 1000记录下来的慢 SQL 定期 review看是否需要加索引或改写。多表查询的性能问题往往在数据量涨到百万级后才暴露提前监控能避免线上事故。6.5 单元测试怎么写这套方案的测试重点是验证生成的 SQL 是否符合预期。我一般用 H2 内存数据库跑集成测试或者用 MyBatis-Plus 的 SQL 日志断言。Test public void testBuildWrapper() { OrderQueryDTO query new OrderQueryDTO(); query.setKeyword(张); query.setStatus(1); QueryWrapperOrderDetailVO ew OrderWrapperBuilder.build(query); String sql ew.getCustomSqlSegment(); assertTrue(sql.contains(c.customer_name LIKE)); assertTrue(sql.contains(o.status )); }getCustomSqlSegment()方法可以直接拿到生成的 SQL 片段不用真的连数据库。这样测试跑得快也能覆盖各种条件组合。7. 我在实际项目中的几点体会这套方案我从 2021 年开始用前后在三个项目里落地。最大的感受是它把多表查询的复杂度从SQL 维护转移到了Wrapper 构建。SQL 骨架稳定后基本不用动所有变化都在 Java 代码里配合 IDE 的重构和单元测试改起来很踏实。踩过的坑里最值得说的是字段名问题。团队新人第一次用十有八九会写 Java 字段名而不是数据库列名。我的解决办法是在 VO 类里加注释并且在 Code Review 时重点看 Wrapper 构建部分。后来干脆写了个小工具扫描 Wrapper 里的字符串和数据库元数据比对提前发现拼写错误。另一个体会是分页的 count 查询。数据量小的时候无所谓一旦上百万count 就成了瓶颈。我现在的习惯是新接口上线前用生产数据量的十分之一跑一次压测看 count 的耗时。如果超过 200ms就考虑优化。最后分享一个小技巧${ew.customSqlSegment}生成的条件片段可以用ew.getTargetSql()方法在日志里打印出来方便调试。但注意这个方法返回的是带?的 SQL参数值要另外从ew.getParamNameValuePairs()里取。我一般只在排查问题时用平时靠 MyBatis 的 DEBUG 日志就够了。
返回列表