资讯详情

MyBatis Plus动态查询条件组装:从堆if到可复用条件单元

📅 2026/11/3 12:08:44 | 华诺云谱 👁 阅读
MyBatis Plus动态查询条件组装:从堆if到可复用条件单元
做后台管理系统的人大概都遇到过这种场面列表页的筛选条件从三个涨到十几个前端传过来的参数一半是空值你在 Service 层里写了一长串if (StringUtils.isNotBlank(...))往QueryWrapper上挂条件代码越堆越长改一个字段要翻半天。更难受的是同样的筛选逻辑在导出接口里又要复制一遍两处稍微不同步就出 bug。这篇就聊聊我在这类场景里反复折腾之后沉淀下来的一套做法——怎么把动态查询条件的拼接从堆 if变成可维护、可复用、可组合的结构以及QueryWrapper合并时那些文档里不会写的坑。先把范围说清楚这里讨论的是基于 MyBatis Plus 的QueryWrapper/LambdaQueryWrapper做动态条件组装核心诉求是条件按需拼接、多来源条件可合并、空值与默认值可控、代码可复用。适合已经用过 MyBatis Plus、写过几个 CRUD 列表页但被复杂筛选条件折磨过的后端开发。如果你还在用 XML 里手写if test...这套思路同样适用只是载体不同。1. 为什么堆 if的写法迟早会失控1.1 一个典型列表接口的演化过程刚开始做列表查询条件就两三个按名称模糊查、按状态精确查、按创建时间范围查。代码大概长这样LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); if (StringUtils.isNotBlank(query.getName())) { wrapper.like(User::getName, query.getName()); } if (query.getStatus() ! null) { wrapper.eq(User::getStatus, query.getStatus()); } if (query.getStartTime() ! null query.getEndTime() ! null) { wrapper.between(User::getCreateTime, query.getStartTime(), query.getEndTime()); }这时候它没问题甚至挺清晰。问题出在需求增长上。产品陆续加了按部门查可能多选、按角色查多选、按手机号后四位查、按是否激活查、按标签包含查、按数据权限过滤、按关键字跨字段模糊匹配……每加一个条件就往这个方法里塞一段if。半年后这个方法两百行中间还夹着几段注释掉的旧逻辑。真正的痛点不是长而是耦合。所有条件挤在一个方法里意味着导出功能要复用就得复制某个条件逻辑要改比如名称从like改成前后都模糊的likeLeft likeRight组合你得记得改所有出现的地方单元测试没法针对单个条件写只能整段测。1.2 三个具体的失控信号我总结下来出现下面任意一个信号就该重构了同一个筛选条件在超过一个方法里出现。列表、导出、统计三个接口各写一遍改一处漏两处。方法里出现条件之间互相影响的逻辑。比如当选择了部门时状态条件要额外排除已离职这种交叉逻辑用平铺的if表达会非常绕。空值语义开始混乱。前端传status和statusnull到底算不算不筛选传status0是筛选状态为 0还是没传这类问题一旦出现说明条件组装缺少统一的规约。提示判断要不要重构别看代码行数看改一个筛选条件需要动几个文件、几处代码。超过两处基本就该抽了。1.3 动态拼接的本质是什么把这件事抽象一下一次查询 一组条件单元的有序组合。每个条件单元负责回答三个问题——我是否生效条件判断、我往 wrapper 上挂什么拼接动作、我依赖哪些参数输入。所谓动态拼接就是根据运行时参数决定哪些单元生效然后按顺序把它们应用到 wrapper 上。想清楚这一层重构方向就明确了把每个条件单元独立出来用一个统一的容器去收集和合并它们。这也是后面所有方案的共同基础。至于用匿名类、函数式接口还是注解只是表现形式的选择。2. 条件单元化把每个筛选条件变成独立积木2.1 用函数式接口定义条件单元Java 8 之后最轻量的做法是把一个条件单元定义成接收 wrapper、按需拼接的函数。先定义一个函数式接口FunctionalInterface public interface ConditionApplierT { void apply(LambdaQueryWrapperT wrapper); }然后每个条件就是一个ConditionApplier实例内部自己判断是否生效public class UserConditions { public static ConditionApplierUser nameLike(String name) { return wrapper - { if (StringUtils.isNotBlank(name)) { wrapper.like(User::getName, name); } }; } public static ConditionApplierUser statusEq(Integer status) { return wrapper - { if (status ! null) { wrapper.eq(User::getStatus, status); } }; } public static ConditionApplierUser createTimeBetween(LocalDateTime start, LocalDateTime end) { return wrapper - { if (start ! null end ! null) { wrapper.between(User::getCreateTime, start, end); } }; } }调用侧变成LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); Stream.of( UserConditions.nameLike(query.getName()), UserConditions.statusEq(query.getStatus()), UserConditions.createTimeBetween(query.getStartTime(), query.getEndTime()) ).forEach(c - c.apply(wrapper));看起来只是把if挪了个位置但性质变了每个条件现在是独立可测试、可复用、可组合的单元。导出接口直接复用同一批ConditionApplier不用复制逻辑。2.2 为什么用应用函数而不是返回布尔值有人会问为什么不设计成boolean test()加一个单独的拼接方法因为条件和拼接动作是强绑定的——名称模糊查这个条件生效时挂上去的一定是like(User::getName, ...)这两件事拆开没有意义反而增加出错空间。把判断 拼接封在一个函数里调用方不需要知道细节只负责把参数喂进去。这个设计还有个隐性好处条件之间的顺序由调用方决定。SQL 里条件的书写顺序有时会影响索引选择虽然优化器很聪明但between和eq的先后在某些复合索引下确实有差异把顺序控制权留在组装处比藏在某个工具方法里更可控。2.3 多选条件的处理细节多选是最容易写错的地方。前端传deptIds[1,2,3]很多人直接wrapper.in(User::getDeptId, deptIds)但没考虑空集合——in一个空集合会生成IN ()直接语法错误。正确做法是先判空public static ConditionApplierUser deptIn(ListLong deptIds) { return wrapper - { if (CollectionUtils.isNotEmpty(deptIds)) { wrapper.in(User::getDeptId, deptIds); } }; }还有个更隐蔽的坑如果业务语义是多选为空表示查全部那上面的写法对但如果语义是多选为空表示查不到任何数据比如权限过滤没分配部门就该看不到人那就不能跳过得主动拼一个恒假条件if (CollectionUtils.isEmpty(deptIds)) { wrapper.apply(1 0); // 主动制造空结果 return; } wrapper.in(User::getDeptId, deptIds);这两种语义一定要和产品确认清楚我见过因为这里理解偏差导致越权看到数据的案例排查了半天才发现是空集合被当成不筛选。3. QueryWrapper 合并多来源条件的正确拼法3.1 什么时候需要合并单个接口内部的条件单元化解决了复用问题但还有一类场景条件来自多个来源需要合并成一个 wrapper。典型例子基础筛选条件前端传的 数据权限条件后端根据当前用户算的主查询条件 一个可复用的通用过滤比如全局的只看未删除只看当前租户分步构建先按 A 组条件建一个 wrapper再按 B 组条件补充这时候就涉及 wrapper 的合并。MyBatis Plus 的QueryWrapper本身提供了and、or、nested等方法但直接合并两个已构建好的 wrapper 并没有一个官方一行搞定的 API需要点技巧。3.2 用and(Consumer)做嵌套合并最稳妥的合并方式是利用and(ConsumerParam)或or(ConsumerParam)接收一个消费函数在函数里把另一个 wrapper 的条件重放进去。但QueryWrapper的条件是内部状态不能直接遍历。可行的做法是让每个条件来源都暴露成ConditionApplier列表合并时依次 applypublic class WrapperMerger { SafeVarargs public static T LambdaQueryWrapperT merge(ListConditionApplierT... groups) { LambdaQueryWrapperT wrapper new LambdaQueryWrapper(); for (ListConditionApplierT group : groups) { if (group ! null) { group.forEach(c - c.apply(wrapper)); } } return wrapper; } }调用LambdaQueryWrapperUser wrapper WrapperMerger.merge( buildBaseConditions(query), // 前端筛选 buildDataPermissionConditions(currentUser), // 数据权限 buildCommonConditions() // 通用过滤 );这种列表合并比wrapper 合并更可控因为每个条件都是显式的、可审计的。数据权限这种敏感逻辑你能清楚看到它到底挂了哪些条件而不是藏在一个黑盒 wrapper 里。3.3 需要或关系时的小心机上面合并默认是 AND 关系所有条件依次eq/likeMyBatis Plus 默认用 AND 连接。如果某组条件内部需要 OR比如关键字同时匹配名称或手机号就得用嵌套public static ConditionApplierUser keywordLike(String keyword) { return wrapper - { if (StringUtils.isNotBlank(keyword)) { wrapper.and(w - w.like(User::getName, keyword) .or() .like(User::getPhone, keyword)); } }; }注意这里的wrapper.and(w - ...)是必须的。如果直接写wrapper.like(...).or().like(...)OR 会和前面的其他条件平级导致(条件A AND 名称like) OR 手机号like语义完全错了。用and(Consumer)包一层生成的 SQL 是AND (名称 like ? OR 手机号 like ?)括号保证了优先级。这个括号问题是我见过最高频的线上事故来源之一务必记牢。3.4 合并时的字段冲突与覆盖还有一种情况两个来源都想对同一个字段加条件。比如基础筛选传了status1数据权限又要求status ! 9。这时候两个条件都会挂上去SQL 变成status 1 AND status ! 9逻辑上没错取交集但如果你本意是权限条件覆盖业务条件那就得在合并前做去重或优先级处理。我的做法是给条件单元加一个可选的分组标识合并时同组只保留优先级最高的public class PrioritizedConditionT { private final String group; // 如 status private final int priority; // 数字越大优先级越高 private final ConditionApplierT applier; // 构造、getter 略 }合并时用MapString, PrioritizedCondition按 group 去重保留 priority 最大的。这套机制在条件来源多、可能冲突的场景里非常必要否则你会得到一堆看起来都对但结果不对的 SQL。4. 空值、默认值与类型转换动态条件的隐形雷区4.1 空字符串和 null 的语义要统一前端传参有个经典问题清空输入框后传的是空字符串而不是null。如果你的判断是status ! null空字符串会被当成有效值去查status 结果自然查不到。所以判断条件生效时字符串一律用StringUtils.isNotBlank而不是! null。但反过来也有坑某些字段空字符串是有意义的比如备注为空的筛选用户就是要查备注是空串的记录。这种字段就不能用isNotBlank一刀切得单独处理。我的经验是在 Query 对象层面就把语义定死——要么约定空串一律视为未传在参数绑定阶段就转成 null要么对特殊字段单独标注。最怕的是同一个项目里两种约定混用那排查起来是灾难。4.2 数字类型的0 陷阱Integer status传 0status ! null判断为真会去查status 0。这通常是对的但如果 0 在你的业务里表示全部有些老系统这么设计就会出错。更隐蔽的是Long id传 0 表示未选择结果去查id 0。这类问题没法靠通用工具解决只能在 Query 对象的字段注释里写清楚或者在转换层做显式映射。我习惯在 Query 对象里对这类字段加注释并在组装条件时用专门的判断方法// status: null 或 0 均表示全部 public static ConditionApplierUser statusEq(Integer status) { return wrapper - { if (status ! null status ! 0) { wrapper.eq(User::getStatus, status); } }; }4.3 时间范围的边界处理时间范围查询有两个细节常被忽略。第一如果数据库存的是datetime用户选2024-01-01 到 2024-01-31直接between(start, end)会漏掉 1 月 31 日当天有时分秒的记录因为end是2024-01-31 00:00:00。正确做法是把结束时间补到当天最后一刻或者用 次日零点public static ConditionApplierUser createTimeRange(LocalDate start, LocalDate end) { return wrapper - { if (start ! null) { wrapper.ge(User::getCreateTime, start.atStartOfDay()); } if (end ! null) { wrapper.lt(User::getCreateTime, end.plusDays(1).atStartOfDay()); } }; }用gelt而不是between既解决了边界问题又让只传开始或只传结束的半开区间查询自然支持。第二时区问题——如果服务端和数据库时区不一致LocalDateTime直接比较可能差几个小时这种要么统一用 UTC 存储要么在连接串里明确时区别指望框架自动处理。4.4 模糊查询的转义like查询如果用户输入了%或_会被当成通配符导致查询结果异常。比如搜100%like %100%%会匹配所有以 100 开头的内容。MyBatis Plus 的like不会自动转义需要手动处理public static ConditionApplierUser nameLike(String name) { return wrapper - { if (StringUtils.isNotBlank(name)) { String escaped name.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_); wrapper.like(User::getName, escaped); } }; }注意 MySQL 默认的转义符是反斜杠但如果你在LIKE后面加了ESCAPE子句指定了别的转义符这里要对应调整。这个坑在小数据量下不容易发现数据一多、用户输入一杂就冒出来了。5. 复用与组合让一套条件服务多个接口5.1 列表、导出、统计共用条件集条件单元化最大的收益在这里。假设列表接口和导出接口的筛选逻辑完全一致只是导出不分页、可能多查几个字段。做法是把条件集抽成一个方法public class UserConditionSets { public static ListConditionApplierUser fromQuery(UserQuery query) { return Arrays.asList( UserConditions.nameLike(query.getName()), UserConditions.statusEq(query.getStatus()), UserConditions.deptIn(query.getDeptIds()), UserConditions.createTimeRange(query.getStartDate(), query.getEndDate()) ); } }列表接口LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); UserConditionSets.fromQuery(query).forEach(c - c.apply(wrapper)); wrapper.orderByDesc(User::getCreateTime); PageUser page userMapper.selectPage(new Page(pageNo, pageSize), wrapper);导出接口LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); UserConditionSets.fromQuery(query).forEach(c - c.apply(wrapper)); ListUser list userMapper.selectList(wrapper);两处逻辑天然一致改条件只改UserConditionSets一处。导出如果要多加个只导已审核的限制单独 append 一个条件即可不影响列表。5.2 条件集的可选开关有时候同一套条件在不同接口里要部分启用。比如统计接口不需要分页但需要按部门聚合导出接口不需要只看当前页但需要数据权限。与其写多套条件集不如给条件集加开关public static ListConditionApplierUser fromQuery(UserQuery query, ConditionOptions options) { ListConditionApplierUser list new ArrayList(); list.add(UserConditions.nameLike(query.getName())); list.add(UserConditions.statusEq(query.getStatus())); if (options.isWithDept()) { list.add(UserConditions.deptIn(query.getDeptIds())); } if (options.isWithTimeRange()) { list.add(UserConditions.createTimeRange(query.getStartDate(), query.getEndDate())); } return list; }ConditionOptions是个简单的配置对象各接口按需构造。这比复制一份条件集再删几行要稳得多因为公共部分永远同步。5.3 数据权限条件的注入位置数据权限是典型的后端算、前端看不见的条件。它不该混在业务条件集里而应该作为独立的一组在组装时统一注入。我通常把它放在条件列表的最前面ListConditionApplierUser conditions new ArrayList(); conditions.addAll(DataPermissionConditions.forUser(currentUser)); // 先挂权限 conditions.addAll(UserConditionSets.fromQuery(query)); // 再挂业务条件放最前面的原因有两个一是生成的 SQL 里权限条件在前某些数据库的优化器对前置的等值条件更友好二是从可读性上一眼能看出这个查询先受权限约束再叠加业务筛选审计时更清晰。权限条件内部通常用in或apply拼子查询注意别和业务条件的 OR 混在一起——权限条件必须始终是 AND 关系否则会越权。6. 实测中踩过的坑与排查思路6.1 条件顺序导致的 SQL 语义错误前面提过 OR 的括号问题这里给一个完整的踩坑链路。某次需求是按关键字搜名称或手机号同时按状态筛选。初版代码wrapper.like(User::getName, keyword).or().like(User::getPhone, keyword); wrapper.eq(User::getStatus, status);生成的 SQL 是name like ? OR phone like ? AND status ?。由于 AND 优先级高于 OR实际语义是name like ? OR (phone like ? AND status ?)——名称匹配的记录完全无视了状态筛选。测试时用名称能搜到但状态不符的数据一测就露馅。修复就是前面说的and(Consumer)包一层。排查这类问题的通用方法把 MyBatis 的 SQL 日志打开把生成的 SQL 复制到数据库客户端里手动跑一遍对照预期结果。别靠脑补优先级。6.2selectOne与条件不足的冲突动态条件如果全部不生效wrapper 就是空的selectOne会查出全表第一条或直接报期望一条实际多条。这在根据条件查唯一记录的场景里很危险。防御手段是在组装完成后检查条件数量if (wrapper.isEmptyOfWhere()) { throw new IllegalArgumentException(查询条件不能为空); }isEmptyOfWhere()是 MyBatis Plus 提供的方法判断是否没有任何 where 条件。对于必须带条件的查询比如按 ID 查、按唯一键查这个检查应该成为标配。我见过因为条件拼错导致全表扫描甚至误删的案例加一行判断能省很多事。6.3 分页插件与复杂 wrapper 的配合用分页插件时如果 wrapper 里包含apply拼的复杂子查询或existscount 语句可能生成得很慢甚至出错。原因是分页插件默认会把原 SQL 包一层select count(*) from (原SQL)复杂子查询在 count 时被完整执行。优化思路是给 count 语句单独指定简化版PageUser page new Page(pageNo, pageSize); page.setCountId(selectUserCount); // 指定自定义 count 语句或者在业务上接受 count 稍慢但确保apply里的子查询有索引支撑。这个点没有银弹得结合执行计划看。6.4 条件单元里的异常吞没条件单元是函数式接口内部如果抛异常比如类型转换失败默认会往上冒泡整个查询失败。这本身没错但错误信息往往不友好——用户看到的是系统异常你看到的是深层堆栈。建议在组装层包一层把哪个条件出错带出来for (ConditionApplierUser c : conditions) { try { c.apply(wrapper); } catch (Exception e) { throw new IllegalStateException(条件组装失败: c.getClass().getSimpleName(), e); } }如果条件单元是 lambdagetClass().getSimpleName()拿不到有意义的名字那就给条件单元加个name()默认方法或者用带名字的包装类。这点小投入在排查线上问题时回报很高。7. 一套可以直接抄的落地骨架7.1 目录与类职责划分把上面的东西收拢成一套结构实际项目里我一般这么放位置职责query/UserQuery前端参数载体字段注释写清空值语义condition/ConditionApplier条件单元函数式接口condition/UserConditions单条件工厂方法nameLike、statusEq 等condition/UserConditionSets条件集组装fromQuerycondition/DataPermissionConditions权限条件独立成组condition/WrapperMerger多组条件合并入口这套划分的关键是单条件和条件集分离。单条件是原子操作条件集是业务编排。新人改需求时加字段就在UserConditions加一个工厂方法在UserConditionSets里挂上去不用碰其他任何地方。7.2 一个完整的组装示例public PageUserVO pageUsers(UserQuery query, int pageNo, int pageSize) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // 1. 权限条件优先 DataPermissionConditions.forCurrentUser().forEach(c - c.apply(wrapper)); // 2. 业务条件 UserConditionSets.fromQuery(query).forEach(c - c.apply(wrapper)); // 3. 防御至少有一个业务条件避免全表 if (wrapper.isEmptyOfWhere()) { throw new IllegalArgumentException(请至少提供一个筛选条件); } // 4. 排序与分页 wrapper.orderByDesc(User::getCreateTime); PageUser page userMapper.selectPage(new Page(pageNo, pageSize), wrapper); return page.convert(this::toVO); }这段代码的好处是读起来就是需求本身先权限、再业务、再防御、再排序分页。每一块都能单独替换或测试出了问题定位也快——是权限条件错了还是业务条件错了一眼能分。7.3 单元测试怎么写条件单元化之后测试变得非常直接。针对单个条件Test void nameLike_shouldSkipWhenBlank() { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); UserConditions.nameLike( ).apply(wrapper); assertTrue(wrapper.isEmptyOfWhere()); } Test void nameLike_shouldEscapeWildcard() { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); UserConditions.nameLike(100%).apply(wrapper); assertTrue(wrapper.getSqlSegment().contains(\\%)); }针对条件集可以断言给定 query生成的 wrapper 的 SQL 片段包含预期条件。这类测试不需要连数据库跑得飞快是防止条件逻辑回归的利器。我一般要求条件集的核心分支都有对应测试尤其是空值、多选空集合、时间边界这几类。7.4 什么时候不该用这套最后说点反向经验。这套结构不是所有项目都值得上。如果整个系统就三五个列表接口、条件都是简单的等值和模糊查直接写if反而更省事——引入函数式接口和条件集对不熟悉的人有理解成本。判断标准还是前面那条当同一个条件开始在多处重复、或者条件之间出现交叉逻辑时再上这套。过早抽象和过晚抽象一样有害区别在于前者增加阅读负担后者增加维护负担。另外如果团队里有人对 lambda 和函数式接口不熟落地前最好先做一次内部分享把条件单元这个概念讲透。工具再好用不明白就是负担。我在两个团队推过这套第一个团队直接铺开结果有人把带副作用的逻辑塞进条件单元里在 apply 里改外部变量埋了雷第二个团队先拿一个模块试点跑了两周再推广顺畅很多。条件组装这件事说到底是在灵活和可控之间找平衡。动态拼接给了你按需组合的能力但如果没有统一的规约和防御灵活性会变成事故来源。把条件单元化、把合并显式化、把空值和边界处理固化下来剩下的就是业务编排的体力活了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑