MyBatis-Plus 项目为什么越写越复杂:从一行 Wrapper 说起
每个 MyBatis-Plus 项目的第一行查询代码,大概都是这样开始的:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, 1);
List<User> users = userMapper.selectList(wrapper);
三行。没有 XML,没有 SQL,字段引用还是类型安全的。那一刻的感受是:这东西真好用。
这篇文章想聊的是,从这三行到"这个项目怎么这么难改",中间到底发生了什么。
先说清楚,不是 MP 不好用,单表 CRUD 它确实是目前国内最好用的。我想复盘的是一条演化路径:每一步都合理,每一步都是当下成本最低的选择,加起来就是复杂度失控。
一、一个查询的十八个月
第 1 周:三行
就是开头那三行。查"状态正常的用户",完美。
第 3 个月:来了列表页
产品要用户列表:姓名模糊、状态、年龄区间、注册时间范围,按创建时间倒序,分页。
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(User::getName, name)
.eq(User::getStatus, status)
.ge(User::getAge, minAge)
.le(User::getAge, maxAge)
.ge(User::getCreateTime, startTime)
.le(User::getCreateTime, endTime)
.orderByDesc(User::getCreateTime);
Page<User> page = userMapper.selectPage(new Page<>(current, size), wrapper);
八行链式。这时候没人觉得有问题,它确实比等价的 XML 短。
第 8 个月:条件变成动态的
然后发现:用户不填姓名时,like '%null%';不选状态时,status = null 查不出任何东西。
于是知道了 MP 的 condition 重载:
public Page<User> pageUsers(String name, Integer status, Integer minAge, Integer maxAge,
LocalDateTime startTime, LocalDateTime endTime, Long deptId,
String code, Integer current, Integer size) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(name), User::getName, name)
.eq(status != null, User::getStatus, status)
.eq(code != null, User::getCode, code)
.eq(deptId != null, User::getDeptId, deptId)
.ge(minAge != null, User::getAge, minAge)
.le(maxAge != null, User::getAge, maxAge)
.ge(startTime != null, User::getCreateTime, startTime)
.le(endTime != null, User::getCreateTime, endTime)
.orderByDesc(User::getCreateTime);
return userMapper.selectPage(new Page<>(current, size), wrapper);
}
方法签名 11 个参数,链式 9 行。还是能接受,毕竟 if 是真的省掉了。
第 12 个月:出现"或"
搜索框要同时匹配姓名和工号;部门筛选要"本部门,或已停用用户"。链式调用里开始出现嵌套的 lambda:
wrapper.and(w -> w.like(User::getName, keyword).or().like(User::getCode, keyword))
.and(w -> w.eq(User::getDeptId, deptId).or().eq(User::getStatus, 2))
// ……上面那 9 行动态条件还在
从这一刻起,读这段代码的人必须在脑子里维护一个栈:哪层是 and,哪层是 or,括号从哪到哪。SQL 里一眼能看出来的结构,在链式调用里要人肉解析。
第 15 个月:出现 .apply()
"按天筛选创建日期"要用日期函数,Wrapper 写不了;"薪资高于本部门平均"是子查询,能写但没人愿意读。于是:
wrapper.apply("date_format(create_time, '%Y-%m-%d') = {0}", date)
.apply("salary > (select avg(salary) from user where dept_id = {0})", deptId);
字符串 SQL 回来了。而且是没有排版的字符串 SQL,不能像 XML 里那样分行、对齐、加注释。写它的人在一行 Java 里塞了半条 SQL。
第 18 个月:要显示部门名了
列表页要展示部门名称,join 一张 dept 表。
Wrapper 表达不了 join。回 XML:写 resultMap、起列别名、配 association。于是项目进入双轨制,单表查询用 Wrapper,多表查询用 XML,同一个 Service 里两种风格并存。
回头看一遍,没有一步是错的。每一步都是当时成本最低的写法,code review 时每一步都过得去。
但十八个月后,这个项目里躺着的是:
- 一个查询方法 11 个参数、12 行链式、外加 2 段
.apply()字符串; - 同样的筛选条件在列表页、导出、计数三处各复制一份,Wrapper 是局部变量,没法复用;
- code review 时没人真的逐行审那段链式调用,它没有结构,审不动;
- 新人想改一个条件,得先把 12 行链式在脑子里还原成 SQL。
这就是"越写越复杂"的机制:复杂度不是一次错误决策引入的,是十几次"当下合理"一点点累加上来的。
二、根因:Wrapper 解决的是"少写 XML",不是"表达查询"
把锅甩给"需求变复杂了"很容易,但需求复杂是常态,关键看工具怎么消化复杂度。拆开看,有几件事是确定的。
1. SQL 没有消失,只是换了宿主
条件数量是需求决定的,Wrapper 一条都省不掉。区别只在表达介质:SQL 有天然的结构,WHERE、AND、OR 各自成行,可排版、可对齐、可注释;链式调用是一维的,.a().b().c() 没有给结构留位置。
条件少的时候链式更短,条件多的时候链式先崩。可读性随长度的衰减是非线性的。第 3 个月那八行是优势,第 12 个月那二十行是负债。
2. 查询是"即用即弃"的语句,不是资产
Wrapper 是方法体里的局部变量,构造完即丢弃:没有名字,没有签名,没有可以沉淀的位置。"活跃用户的搜索条件"这个查询语义,在列表页、导出、计数三处各写一遍链式;需求一变,三处都要改,三处都可能漏。
对比一下,原生 MyBatis 的 Mapper 方法至少是个资产:findActiveUsers 有名字、有入参对象,可以被复用、被讨论、被测试。Wrapper 实际上把查询从 DAO 层的"接口",降级成了 Service 层的"语句"。Service 越来越胖、DAO 层越来越薄直到只剩 selectPage,根子在这里。(Service 层被查询逻辑污染这个话题,我今年三月那篇里聊过一次。)
3. 表达力边界之外没有过渡带
嵌套 or、函数条件、子查询、join,Wrapper 表达力的边界来得比想象中早。而边界之外的逃生舱是 .apply():从类型安全的一端,直接掉进无排版的字符串 SQL 一端,中间没有渐变。
多数项目的真实终态就是:一半链式、一半字符串。前者没有结构,后者没有安全感和排版,两边的好处都没拿到。
三、常见解法,以及它们为什么止不住
解法 A:抽工具方法封装 Wrapper。把常用条件包成静态方法复用。能解决一部分重复,但方法签名继续膨胀(本质还是把条件当参数一个个传),封来封去还是在拼链。
解法 B:参数收敛成 Query 对象。11 个参数确实该收敛。但链式还是那段链式,查询逻辑依然活在 Service 方法体里,测试和 review 面对的困难原样保留。
解法 C:双轨制,复杂查询回 XML。多数团队的实际选择。代价是两套心智模型、两种代码风格,以及"什么算简单、什么算复杂"的边界永远吵不清,每个人画的那条线都不一样。
解法 D:换一个 DSL。MyBatis-Flex 的 QueryWrapper、jOOQ 的 Condition……换工具不换问题,查询语义还是散落在各个调用点,只是换了一门方言。
四种解法绕来绕去,共同指向同一个空缺:缺一个能承载查询语义的、可复用的"查询定义"。
四、另一种思路:把查询变成"数据"
我自己给出的答案是查询实体(QueryEntity):查询条件不是一段代码,而是一个对象。
还是那个列表需求,MyBatisGX 里先定义查询对象:
@QueryEntity(User.class)
public class UserQuery extends User {
private String nameLike; // → name like '%x%'
private String codeLike; // → code like '%x%'
private List<Integer> statusIn; // → status in (...)
private List<Integer> ageBetween; // → age between ? and ?
private List<Long> idIn; // → id in (...)
}
查询条件长在字段名上:nameLike 是模糊、statusIn 是多值、ageBetween 是区间;继承实体,等值条件直接用实体字段。DAO 不需要声明任何方法:
List<User> users = userDao.findList(userQuery);
Page<User> page = userDao.findPage(userQuery, Pageable.of(1, 10));
findList / findPage 都是内置方法,带 @Dynamic:字段为空自动不参与 WHERE。第 8 个月那个 11 参数的方法,整个消失了。
真正值得对比的不是行数(行数当然也少了),是结构上的变化:
- 查询变成可传递的对象:Controller 把请求参数直接绑成
UserQuery,一路传到 DAO,Service 方法体里没有查询语句; - 查询可以复用:同一个
UserQuery传给findOne、findList、findPage,列表、导出、计数共用一份条件定义,改一处; - 测试断言的是语义:构造
UserQuery断言结果即可,不用去捕获、解析 Wrapper 的内部结构。
边界也要交代清楚:超出这套命名语义的复杂查询,走 @Statement 声明式查询,或者直接手写 XML,用户手写 SQL 优先级最高。查询实体是给日常 80% 的查询一个归宿,不是要吞掉所有场景。
结语
项目的复杂度,最后都会算到每一个"当下合理"的小决策头上。
Wrapper 的问题从来不是不好用。是它让查询语义没有地方沉淀,注定散落在 Service 的方法体里,随着需求一次次被重写。框架把"少写 XML"做透了,但查询长什么样、存在哪里、谁来复用,还是要每个项目自己回答。
评论区聊聊:你维护过最长的 Wrapper 链是多少行?