资讯中心

MyBatis-Plus QueryWrapper动态查询:从Lambda表达式到分页实战

📅 2026/8/15 10:53:19
MyBatis-Plus QueryWrapper动态查询:从Lambda表达式到分页实战
1. 从“硬编码”到“动态构建”的转变在后台开发里数据查询是绕不开的核心操作。回想一下你是不是经常遇到这样的场景一个列表查询页面前端传过来一堆可能为空、可能组合的筛选条件比如用户列表可以根据用户名、手机号、状态、注册时间范围等多个字段进行筛选。如果每个条件都写死代码会变得异常臃肿充斥着大量的if-else判断可维护性极差。这就是MyBatis-Plus的QueryWrapper大显身手的地方。它不是一个简单的语法糖而是一套完整的、面向对象的动态 SQL 构建工具。它的核心价值在于将我们从繁琐的、容易出错的字符串拼接 SQL 中解放出来转而用一种更符合 Java 程序员思维的方式——链式调用和 Lambda 表达式——来“描述”我们的查询意图。简单来说QueryWrapper允许你像搭积木一样根据业务逻辑动态地组装WHERE子句。条件存在就加上不存在就忽略最终生成安全、正确的 SQL 语句。这不仅仅是代码美观的问题更是关乎开发效率、代码健壮性和团队协作规范的大事。尤其是在应对复杂多变的查询需求时一个设计良好的QueryWrapper构建逻辑能让你的Service层代码清晰得像一篇散文。2. QueryWrapper 的核心构造器与基础用法在深入动态查询之前我们必须先打好地基理解QueryWrapper的基本构造方式。MyBatis-Plus提供了两种主流的构建器基于字符串列名的QueryWrapper和基于 Lambda 表达式的LambdaQueryWrapper。我强烈建议在新项目中无脑选择后者原因我们稍后会详细拆解。2.1 两种构造器的选择为什么 Lambda 是更优解让我们先看一个基于字符串列名的传统用法示例QueryWrapperUser queryWrapper new QueryWrapper(); queryWrapper.eq(deleted, 0); // 查询未删除的 queryWrapper.like(user_name, 张); // 模糊查询姓张的 queryWrapper.between(create_time, startTime, endTime); // 创建时间范围 ListUser list userMapper.selectList(queryWrapper);这段代码能工作但它存在几个致命隐患类型不安全“user_name”是一个魔法字符串。如果数据库字段名改成了username或者你手抖打成了“user_naem”编译器不会给你任何错误提示只有在运行时才会报SQL语法错误。重构困难现代IDE的重命名重构功能对字符串无能为力。更改实体类属性名时这些字符串条件不会自动更新极易引入隐蔽的Bug。可读性一般虽然比拼接SQL好但依然不够直观。现在看看使用LambdaQueryWrapper的版本LambdaQueryWrapperUser lambdaQueryWrapper new LambdaQueryWrapper(); lambdaQueryWrapper.eq(User::getDeleted, 0); lambdaQueryWrapper.like(User::getUserName, “张”); lambdaQueryWrapper.between(User::getCreateTime, startTime, endTime); ListUser list userMapper.selectList(lambdaQueryWrapper);高下立判类型安全User::getUserName是方法引用编译器会检查User类是否存在getUserName方法以及返回值类型是否匹配。如果属性名变更IDE的重构功能可以一键全局更新彻底杜绝低级错误。可读性强代码清晰地表达了“查询User实体中userName字段包含‘张’的记录”几乎就是自然语言的直译。编码体验好IDE的代码提示功能可以完美工作输入User::之后所有getter方法都会列出来供你选择。所以除非你在维护一个非常古老的项目否则请将LambdaQueryWrapper作为默认选择。它多写的那几个字母在项目的长期维护中会为你节省数倍的时间和精力。2.2 必须掌握的常用条件构造方法无论是哪种Wrapper其条件方法都是相通的。下面这些方法构成了动态查询的“词汇表”务必熟练掌握eq / ne等于 () / 不等于 (!)。lambdaQueryWrapper.eq(User::getStatus, 1); // status 1 lambdaQueryWrapper.ne(User::getId, 100L); // id ! 100gt / ge / lt / le大于 () / 大于等于 () / 小于 () / 小于等于 ()。lambdaQueryWrapper.gt(User::getAge, 18); // age 18 lambdaQueryWrapper.le(User::getScore, 100); // score 100between / notBetween在某个区间之内 / 之外。lambdaQueryWrapper.between(User::getCreateTime, “2023-01-01”, “2023-12-31”);like / notLike / likeLeft / likeRight模糊查询。这是最常用的之一。like(“%value%”)likeLeft(“%value”)以value结尾。likeRight(“value%”)以value开头。lambdaQueryWrapper.likeRight(User::getPhone, “138”); // 查询138开头的手机号isNull / isNotNull字段为空 / 非空。lambdaQueryWrapper.isNotNull(User::getEmail); // email IS NOT NULLin / notIn在某个集合内 / 外。参数可以是Collection、数组或可变参数。ListInteger statusList Arrays.asList(1, 2, 3); lambdaQueryWrapper.in(User::getStatus, statusList); // status IN (1,2,3)groupBy / orderByAsc / orderByDesc分组和排序。lambdaQueryWrapper.groupBy(User::getDeptId); lambdaQueryWrapper.orderByDesc(User::getCreateTime, User::getId); // 多个字段排序select指定查询字段避免SELECT *。这在关联查询或字段很多时对性能有提升。lambdaQueryWrapper.select(User::getId, User::getUserName, User::getEmail);掌握这些基础“词汇”你就能写出绝大部分简单的查询语句了。但真正的挑战在于如何将它们有机地、动态地组合起来。3. 动态条件构建的实战模式与避坑指南动态查询的精髓在于“动态”即根据运行时条件的有无决定是否添加对应的WHERE子句。这里有几种经典且实用的模式以及一些我踩过坑后才明白的注意事项。3.1 模式一最直接的 if 判断这是最朴素也最可控的方式。直接在链式调用前进行判空。public ListUser queryUserList(String userName, Integer status, LocalDateTime beginTime, LocalDateTime endTime) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getDeleted, 0); // 基础条件永远生效 if (StringUtils.isNotBlank(userName)) { wrapper.like(User::getUserName, userName.trim()); } if (status ! null) { wrapper.eq(User::getStatus, status); } if (beginTime ! null endTime ! null) { wrapper.between(User::getCreateTime, beginTime, endTime); } else if (beginTime ! null) { wrapper.ge(User::getCreateTime, beginTime); // 只有开始时间 } else if (endTime ! null) { wrapper.le(User::getCreateTime, endTime); // 只有结束时间 } wrapper.orderByDesc(User::getCreateTime); return userMapper.selectList(wrapper); }优点逻辑清晰一目了然适合条件数量不多、逻辑不复杂的场景。缺点当条件非常多超过7、8个时代码会显得冗长。并且对于字符串的空格处理trim()、日期范围的边界处理等都需要手动编写容易遗漏。3.2 模式二利用Objects.nonNull与StringUtils的链式优化在Java 8的环境中我们可以利用Optional和工具类让代码更简洁、更函数式。public ListUser queryUserListV2(UserQueryDTO queryDTO) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getDeleted, 0); // 使用 Optional 和工具类进行优雅判空 Optional.ofNullable(queryDTO.getUserName()) .filter(StringUtils::isNotBlank) .map(String::trim) .ifPresent(name - wrapper.like(User::getUserName, name)); Optional.ofNullable(queryDTO.getStatus()) .ifPresent(status - wrapper.eq(User::getStatus, status)); // 处理日期范围逻辑更集中 Optional.ofNullable(queryDTO.getBeginTime()) .ifPresent(begin - { Optional.ofNullable(queryDTO.getEndTime()) .ifPresentOrElse( end - wrapper.between(User::getCreateTime, begin, end), () - wrapper.ge(User::getCreateTime, begin) ); }); // 处理只有结束时间的情况 if (queryDTO.getBeginTime() null queryDTO.getEndTime() ! null) { wrapper.le(User::getCreateTime, queryDTO.getEndTime()); } return userMapper.selectList(wrapper); }优点逻辑内聚特别是对于字符串的处理可以在一行内完成判空、去空格等操作减少了中间变量和嵌套if。Optional的ifPresent让“如果存在则执行”的意图非常明确。缺点对于不熟悉函数式编程的团队成员理解成本稍高。日期范围这种组合条件用Optional写起来反而可能更绕。3.3 模式三封装条件构建器进阶当多个Service方法都需要构建类似的复杂查询条件时重复的判空逻辑会散落在各处。此时可以考虑将Wrapper的构建过程封装到一个专门的工具类或Service的私有方法中。// 在 UserService 中 private LambdaQueryWrapperUser buildUserQueryWrapper(UserQueryDTO queryDTO) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getDeleted, 0); // 将公共的构建逻辑集中在这里 addConditionIfPresent(wrapper, User::getUserName, queryDTO.getUserName(), this::likeTrim); addConditionIfPresent(wrapper, User::getStatus, queryDTO.getStatus(), wrapper::eq); addConditionIfPresent(wrapper, User::getDeptId, queryDTO.getDeptIdList(), wrapper::in); // 处理特殊的日期范围 buildTimeCondition(wrapper, queryDTO.getBeginTime(), queryDTO.getEndTime()); return wrapper; } // 一个通用的条件添加方法使用泛型和函数式接口 private T, R void addConditionIfPresent(LambdaQueryWrapperT wrapper, SFunctionT, ? column, R value, BiConsumerSFunctionT, ?, R conditionFunc) { if (value ! null) { if (value instanceof String) { String strVal ((String) value).trim(); if (!strVal.isEmpty()) { conditionFunc.accept(column, (R) strVal); } } else if (value instanceof Collection ((Collection?) value).isEmpty()) { // 空集合不添加 in 条件这是一个重要技巧 return; } else { conditionFunc.accept(column, value); } } } // 专门处理时间范围的方法 private void buildTimeCondition(LambdaQueryWrapperUser wrapper, LocalDateTime begin, LocalDateTime end) { if (begin ! null end ! null) { if (begin.isAfter(end)) { // 业务逻辑如果开始时间晚于结束时间交换它们 wrapper.between(User::getCreateTime, end, begin); } else { wrapper.between(User::getCreateTime, begin, end); } } else if (begin ! null) { wrapper.ge(User::getCreateTime, begin); } else if (end ! null) { wrapper.le(User::getCreateTime, end); } }优点极大提升了代码的复用性和可维护性。公共逻辑一处修改处处生效。buildTimeCondition方法里对时间范围的校验和交换就是业务规则的集中体现。缺点设计复杂度增加适用于中大型项目。过度设计反而会让简单查询变得繁琐。3.4 高频避坑点与实战技巧空集合IN查询导致语法错误 这是最常见的坑之一。当你构建一个wrapper.in(column, idList)时如果idList为空集合MyBatis-Plus生成的SQL会是... WHERE column IN ()这在所有数据库中都非法。务必在调用in方法前判空。if (CollectionUtils.isNotEmpty(idList)) { wrapper.in(User::getId, idList); }模糊查询的通配符转义 如果用户输入的内容本身包含%或_这些SQL通配符直接进行like查询会导致结果异常。MyBatis-Plus的like方法默认不会转义。安全做法是手动转义String keyword “100%完成”; // 将 % 和 _ 转义 keyword keyword.replace(“%”, “\\%”).replace(“_”, “\\_”); wrapper.like(User::getTitle, keyword);或者更推荐在数据库层面使用特定的转义函数如MySQL的ESCAPE子句但这需要自定义SQL片段。条件覆盖与and/or逻辑 默认情况下链式调用的多个条件是AND关系。如果需要OR需要使用or()方法。wrapper.eq(User::getType, “A”) .or() .eq(User::getType, “B”) .like(User::getName, “Tom”); // 生成的SQL: ... WHERE (type ‘A’ OR type ‘B’) AND name LIKE ‘%Tom%’注意or()之后的条件直到下一个or()或and()出现都属于同一个OR分组。复杂的嵌套逻辑可以使用nested方法或直接使用apply拼接自定义SQL片段。select字段与结果映射 使用wrapper.select(...)指定字段时要确保返回的实体对象有对应的setter方法。如果查询的字段不足以构造完整实体例如缺少TableId注解的字段可能会导致异常。对于复杂的DTO投影查询更推荐使用MyBatis原生的Select注解或XML映射。性能考量避免在循环中构建 Wrapper 我曾见过在for循环中不断调用mapper.selectList(wrapper)的代码这是典型的N1查询问题。对于需要根据一个列表查询关联数据的场景应该先收集所有ID然后通过wrapper.in(...)一次查询出来再在内存中做关联匹配。4. 结合分页插件实现高效数据查询动态查询很少单独使用它通常与分页查询是黄金搭档。MyBatis-Plus提供了强大的分页插件PaginationInnerInterceptor配置后可以无缝与QueryWrapper结合。4.1 分页插件的正确配置首先需要在配置类中声明分页插件 Bean。这里有一个关键点务必设置maxLimit属性防止前端传递过大的size导致内存溢出。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件 PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); paginationInnerInterceptor.setMaxLimit(1000L); // 单页最大1000条非常重要 paginationInnerInterceptor.setOverflow(true); // 页码超出时返回第一页默认false返回空 interceptor.addInnerInterceptor(paginationInnerInterceptor); // 可以继续添加其他插件如乐观锁插件 return interceptor; } }4.2 在 Service 层进行分页查询在Service方法中使用Page对象包装分页参数和QueryWrapper。public PageResultUserVO queryUserByPage(UserQueryDTO queryDTO) { // 1. 构建查询条件复用之前的动态构建方法 LambdaQueryWrapperUser wrapper buildUserQueryWrapper(queryDTO); // 2. 创建分页对象。Page的泛型是实体类不是VO。 // 建议对前端传入的页码和大小做校验和默认值设置 long current Optional.ofNullable(queryDTO.getCurrent()).filter(c - c 0).orElse(1L); long size Optional.ofNullable(queryDTO.getSize()) .filter(s - s 0 s 1000) // 与配置的maxLimit保持一致 .orElse(10L); PageUser pageParam new Page(current, size); // 3. 执行分页查询。selectPage方法会同时查询记录列表和总记录数。 PageUser userPage userMapper.selectPage(pageParam, wrapper); // 4. 转换与返回。总记录数(userPage.getTotal())和分页数据(userPage.getRecords())都在这里。 ListUserVO userVOList convertToVOList(userPage.getRecords()); // 实体转VO return new PageResult(userPage.getCurrent(), userPage.getSize(), userPage.getTotal(), userPage.getPages(), // 总页数 userVOList); }关键点Page对象有两个关键方法getRecords()获取当前页数据列表getTotal()获取总记录数。插件会自动执行一条COUNT语句和一条分页SELECT语句。一定要对前端传入的current和size进行校验防止恶意攻击如size100000拖垮数据库。分页查询返回的是实体 (User) 列表通常我们需要将其转换为前端所需的VO(UserVO) 对象这个过程可能涉及字段转换、关联数据填充等。4.3 自定义 COUNT 语句优化性能对于极其复杂的联表查询MyBatis-Plus自动生成的COUNT语句可能效率低下。你可以通过Select注解或XML自定义一个更高效的COUNT查询并在Page对象中指定。public interface UserMapper extends BaseMapperUser { // 自定义分页查询如果需要联表等复杂操作 Select(“SELECT u.*, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id d.id ${ew.customSqlSegment}“) ListUserDeptVO selectUserPage(Param(”ew“) LambdaQueryWrapperUser wrapper); // 自定义 COUNT 查询 Select(”SELECT COUNT(*) FROM user u ${ew.customSqlSegment}“) Long selectUserCount(Param(”ew“) LambdaQueryWrapperUser wrapper); }在Service中PageUser pageParam new Page(current, size); // 告诉插件不要自动生成COUNT语句 pageParam.setSearchCount(false); ListUserDeptVO records userMapper.selectUserPage(wrapper); Long total userMapper.selectUserCount(wrapper); // 手动设置总数 pageParam.setTotal(total); pageParam.setRecords(records); // 注意Page的泛型可能不匹配可以用自定义的PageResult这种方式给了你最大的灵活性但代价是增加了维护SQL的工作量。我的经验是除非明确遇到性能瓶颈否则优先使用自动分页。在大部分场景下其性能已经足够好。5. 应对复杂场景嵌套查询、自定义SQL与逻辑封装当业务逻辑变得复杂简单的链式调用可能不够用。这时我们需要一些更高级的武器。5.1 使用nested处理复杂嵌套逻辑nested方法允许你将一组条件包装起来作为一个整体参与AND/OR运算。这在构建如(A AND B) OR (C AND D)这样的逻辑时非常有用。// 查询状态为启用且姓名包含“张”或手机号包含“138”的用户 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .and(wp - wp .like(User::getUserName, “张”) .or() .like(User::getPhone, “138”) ); // 生成的SQL: ... WHERE status 1 AND (user_name LIKE ‘%张%’ OR phone LIKE ‘%138%’)nested的写法更清晰wrapper.eq(User::getStatus, 1) .and(wp - wp.nested( innerWp - innerWp.like(User::getUserName, “张”) .or() .like(User::getPhone, “138”) )); // 效果同上但意图更明确。5.2 使用apply注入自定义 SQL 片段apply方法是你最后的“逃生舱口”。当MyBatis-Plus提供的条件方法都无法满足需求时比如需要使用数据库函数、子查询等可以用apply直接拼接SQL片段。// 查询注册时间在3天内的用户 wrapper.apply(“DATE(create_time) DATE_SUB(CURDATE(), INTERVAL 3 DAY)”); // 使用参数占位符防止SQL注入{0}代表第一个参数依此类推 wrapper.apply(“date_format(create_time, ‘%Y-%m-%d’) {0}”, “2023-10-27”);注意apply非常强大但也非常危险。绝对不要直接将用户输入的内容拼接在apply的字符串里一定要使用{0}这样的占位符让MyBatis-Plus进行参数化处理这是防止SQL注入的生命线。5.3 逻辑删除与多租户的隐式条件MyBatis-Plus支持全局的逻辑删除和多租户数据隔离。一旦在实体类上配置了TableLogic或在配置中启用了多租户插件QueryWrapper生成的所有SELECT语句都会自动加上相应的条件如AND deleted 0、AND tenant_id ‘xxx’。这带来了便利也带来了一个常见的“坑”当你需要查询包括已删除数据或者进行跨租户的数据初始化/导出时这些自动添加的条件会成为阻碍。解决方案忽略逻辑删除创建一个不继承BaseMapper的自定义Mapper方法使用Select注解写原生SQL或者使用SqlParser(filter true)注解旧版本或配置InterceptorIgnore注解来忽略插件拦截。更推荐前者因为意图更明确。动态忽略租户条件对于多租户可以在构建Wrapper时使用TenantLineInnerInterceptor提供的ignore方法或类似机制取决于具体插件版本在特定Mapper方法上忽略租户条件。这通常需要你查阅当前使用版本插件的具体文档。核心原则对于这类全局性、隐式添加的条件在设计和编码时要心中有数。在需要“突破”这些限制的场景下要有明确、安全的技术方案而不是去魔改框架的全局配置。6. 对象转 QueryWrapper 的实践与思考“对象转QueryWrapper”是一个常见的需求即前端传递一个对象过来希望自动根据对象中非空的字段生成等值 (eq) 查询条件。MyBatis-Plus的Wrapper本身并没有直接提供这个方法但我们可以通过反射或工具类轻松实现。6.1 简易实现基于反射的自动转换下面是一个简单的工具方法展示了核心思路public class QueryWrapperUtils { public static T LambdaQueryWrapperT buildEqWrapperFromEntity(T entity) { if (entity null) { return new LambdaQueryWrapper(); } LambdaQueryWrapperT wrapper new LambdaQueryWrapper(); Class? clazz entity.getClass(); // 获取所有getter方法 Arrays.stream(clazz.getDeclaredMethods()) .filter(method - method.getName().startsWith(“get”) method.getParameterCount() 0) .forEach(method - { try { Object value method.invoke(entity); if (value ! null !“”.equals(value)) { // 将方法名“getXxx”转换为属性名“xxx” String fieldName Introspector.decapitalize(method.getName().substring(3)); // 这里需要将fieldName转换为SFunction比较复杂简易版用字符串Wrapper // 实际更推荐使用以下方式 } } catch (IllegalAccessException | InvocationTargetException e) { // 忽略异常或记录日志 } }); return wrapper; } }但上述方法无法直接获得Lambda表达式所需的SFunction。更实用的做法是使用MyBatis-Plus的Conditions工具类配合BeanUtilspublic static T QueryWrapperT buildQueryWrapperFromEntity(T entity) { QueryWrapperT wrapper new QueryWrapper(); if (entity null) { return wrapper; } BeanUtils.beanToMap(entity, false, true).forEach((key, value) - { if (value ! null !“”.equals(value)) { wrapper.eq(StrUtil.toUnderlineCase(key), value); // 驼峰转下划线 } }); return wrapper; }6.2 使用第三方库HuTool 的 BeanUtilHuTool是一个非常优秀的工具库它的BeanUtil可以更优雅地处理Bean到Map的转换并且支持丰富的过滤选项。import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.StrUtil; public static T LambdaQueryWrapperT buildLambdaWrapperFromEntity(T entity, ClassT entityClass) { LambdaQueryWrapperT wrapper new LambdaQueryWrapper(); if (entity null) { return wrapper; } // 获取所有非空属性 MapString, Object fieldMap BeanUtil.beanToMap(entity, false, true); fieldMap.forEach((fieldName, value) - { // 这里需要一个从字段名到SFunction的映射通常需要预定义或通过其他方式获取 // 一种妥协方案是使用字符串Wrapper }); return wrapper; }6.3 现实考量为什么我不推荐全自动转换尽管“对象转Wrapper”听起来很美好但在实际复杂业务中我并不推荐大规模使用全自动的、基于反射的转换工具。原因如下业务语义不匹配前端查询对象 (QueryDTO) 的字段和查询条件往往不是简单的eq关系。比如createTime字段可能对应的是between查询开始时间和结束时间两个参数而不是eq。userName字段可能对应的是like而不是eq。全自动转换无法处理这种多样性。安全性问题自动转换可能会将一些不应作为查询条件的字段如pageSize,currentPage或一些内部状态字段也加入到WHERE子句中导致查询结果错误或性能问题。性能损耗反射调用毕竟有一定的性能开销在高频接口中需要谨慎。失去控制与清晰度业务逻辑隐藏在自动转换的规则里不如在Service层显式地构建Wrapper来得清晰、可控、易于调试和后续修改。更合理的做法是将QueryDTO作为参数在Service方法内部根据明确的业务规则手动调用Wrapper的方法来构建查询条件。这样代码的意图最清晰也最灵活。你可以把前面提到的buildUserQueryWrapper方法看作是一种受控的、定制化的“转换”它比全自动转换要好得多。7. 总结QueryWrapper 的最佳实践心法用了这么多年MyBatis-Plus的QueryWrapper我总结出几条核心心法希望能帮你避开我走过的弯路无脑用LambdaQueryWrapper类型安全带来的收益远大于那一点点额外的编码量。这是对项目未来负责。动态构建的核心是“判空”所有动态条件添加的前提都是对输入参数的严谨校验。字符串要trim()集合要检查isEmpty()日期范围要处理单边情况。封装但不要过度封装对于重复3次以上的条件构建逻辑果断抽取成方法。但对于简单的、一次性的查询直接if判断反而更直白。在清晰度和复用性之间找到平衡。分页查询必做参数校验size参数必须设置上限这是防止系统被无意或恶意拖垮的防火墙。慎用apply严防注入自定义SQL片段是终极武器但使用时要像对待炸药一样小心。参数化是铁律。理解框架的“隐式”行为清楚逻辑删除、多租户等插件对SQL的自动修改。在需要突破这些限制时使用框架提供的标准方式如自定义Mapper方法而不是 hack 框架。保持 Service 层的“业务感”QueryWrapper的构建是数据访问层的事情但构建的逻辑哪些字段要like哪些字段要between是业务逻辑。把这些逻辑放在Service层让Mapper层保持单纯的数据操作。这样当你需要从MyBatis-Plus切换到其他ORM时业务代码的改动会最小。说到底QueryWrapper是一个极其高效的工具它把我们从SQL字符串的泥潭中拉了出来。但再好的工具也需要使用者对业务、对数据、对SQL本身有深刻的理解。当你不再纠结于Wrapper的某个方法怎么用而是开始思考“我这个查询的业务边界是什么”、“怎样的索引能支持这个动态查询”时你就真正掌握了它。