一、问题的症状
你是否遇到过这样的场景:在若依(RuoYi)框架中开发一个带分页的列表查询接口,数据库里明明有成千上万条数据,但前端分页器上显示的“总记录数”却顽固地停留在10或20——恰好等于你设置的每页数量?
当你检查后端返回的JSON数据时,会发现类似下面的情况:
codeJSON
{
"code": 200,
"msg": "查询成功",
"rows": [ /* ... 10条数据 ... */ ],
"total": 10 // <--- 问题在这里!总数(total)错误
}
列表数据本身是正确的,但分页总数 total 却丢失了,导致前端无法正确显示分页。这是一个典型且隐蔽的PageHelper使用陷阱。
二、深入根源:PageHelper的“魔法”与被打破的“契约”
要理解问题所在,我们必须先明白PageHelper是如何实现自动分页的。
-
设置分页标记:在Controller层,我们通常会调用 startPage() 方法。这个方法并不会立即执行查询,而是在当前线程的 ThreadLocal 中设置一个分页参数(比如页码、每页大小)。可以把它想象成给接下来要执行的SQL任务贴上一个“请帮我分页”的魔法便签。
-
AOP拦截SQL:PageHelper通过AOP切面,会拦截你接下来执行的第一个MyBatis Mapper查询。
-
自动执行两条SQL:当拦截到SQL后,PageHelper会在后台默默地为你做两件事:
- 执行Count查询:它会智能地分析你原始的查询语句,将其改造成一条SELECT count(*)的语句来查询总记录数。这条SQL不包含分页条件(如LIMIT)。
- 执行分页查询:它再次使用你的原始SQL,并在末尾追加上物理分页语句(如MySQL的 LIMIT ?, ?),查询出当前页所需的数据。
-
返回一个特殊的List:这是最关键的一步!当你的Mapper方法返回List时,PageHelper实际返回的不是一个普通的ArrayList,而是一个它自己的内部类——Page。这个Page类本身继承自ArrayList,所以你可以像普通List一样遍历它,但它内部额外持有一个非常重要的属性:total,即上一步查询到的总记录数。
我们可以把这个Page对象想象成一个**“魔法盒子”**:盒子里装着当前页的10件商品(数据),但盒子外面贴着一张标签,写着“仓库里总共有1000件商品”。
现在,让我们回顾一下导致问题的Service层代码:
codeJava
// 这是一个典型的错误示例
public List<ResVideoFinishedProductVo> selectResVideoInfoList(ResVideoFinishedProductBo resVideoInfo) {
// 1. 初始查询,返回的是一个包含了 total 的 Page 对象
List<ResVideoFinishedProductVo> initialVideos = resVideoFinishedProductMapper.selectResVideoInfoList(resVideoInfo);
// ... 中间省略了大量的流处理 ...
// 5. 【致命操作】使用 Stream API 对数据二次加工后,创建了一个全新的 List
return initialVideos.stream()
.filter(...) // 过滤
.peek(...) // 加工
.collect(Collectors.toList()); // <--- 问题根源
}
这段代码的问题就出在最后一步 collect(Collectors.toList())。它的作用是:
- 打开那个装着10件商品、贴着“总数1000”标签的“魔法盒子”(Page对象)。
- 把里面的10件商品一件件拿出来。
- 对这些商品进行过滤、加工。
- 最后,把处理好的商品放进一个全新的、普通的纸箱(一个纯粹的ArrayList)里。
在这个过程中,那个写着“总数1000”的魔法标签被随手丢掉了!
因此,当这个全新的、普通的ArrayList返回给Controller时,它只知道自己内部有10条数据,对于总数是多少一无所知。Controller的getDataTable()方法在封装最终结果时,只能无奈地认为“总数就是这个List的大小”,也就是10。
三、正确的解决方案:原地修改,保留“魔法盒子”
解决问题的核心原则是:永远不要替换掉Mapper返回的那个Page对象,我们应该在它的基础上进行修改。
我们需要将代码从“创建新列表”的模式,重构为“原地修改列表内元素”的模式。
codeJava
public List<ResVideoFinishedProductVo> selectResVideoInfoList(ResVideoFinishedProductBo resVideoInfo) {
// 1. 初始查询,返回的 Page 对象被保存在 videos 变量中
List<ResVideoFinishedProductVo> videos = resVideoFinishedProductMapper.selectResVideoInfoList(resVideoInfo);
if (videos.isEmpty()) {
return videos; // 直接返回空 Page 对象,它也携带了 total=0 的信息
}
// 2. 从已分页的结果中提取ID,用于后续关联查询
List<Long> publishIds = videos.stream()
.map(ResVideoFinishedProductVo::getId)
.collect(Collectors.toList());
if (publishIds.isEmpty()) {
return videos; // 没有ID,直接返回原始分页结果
}
// 3. 批量查询关联数据(例如标签)
Map<Long, List<ResVideoLabelRelatedVo>> labelsMap = ... ; // 查询并处理成Map
// 4. 【核心修正】: 使用 forEach 遍历原始的 Page 对象,直接修改内部元素的属性
videos.forEach(video -> {
// 进行数据组装,这里是直接在原始对象上 set
video.setPublisherName(video.getMediaCreateUserName());
List<ResVideoLabelRelatedVo> labels = labelsMap.getOrDefault(video.getId(), Collections.emptyList());
video.setVideoLabelRelated(buildNestedLabelStructure(labels));
// ... 其他组装逻辑 ...
});
// 5. 【核心修正】: 返回原始的 videos 对象
// 因为我们从未替换过它,所以它仍然是那个包含了正确 total 属性的 Page 对象
return videos;
}
通过使用forEach循环,我们确保了所有的修改都是在原始Page对象内部进行的。当这个对象被返回给Controller时,它依然是那个完好无损的“魔法盒子”,getDataTable()方法可以轻松地从中取出数据列表和正确的总记录数。
四、一个重要的最佳实践:过滤请前置
在最初的错误代码中,还有一个filter()操作。请注意:对分页后的结果在Java内存中进行过滤是一个坏习惯。这会导致你的分页数量不一致,比如请求查询10条,但因为过滤掉了2条,最终只返回8条,这会给前端用户带来困扰。
最佳实践:所有的数据筛选条件,都应该尽可能地在Mapper的XML文件中,通过SQL的WHERE子句来实现,确保数据库层面返回的数据就是最终需要展示的数据。
总结
PageHelper是一个强大而便捷的工具,但它的“魔法”依赖于一个我们必须遵守的“契约”:尊重并保留由Mapper返回的Page对象。当你需要对分页结果进行复杂的二次加工时,请牢记使用原地修改(如forEach)的方式,而不是会创建全新集合的Stream终端操作(如collect),以确保分页信息能够被完整地传递下去。