大家好,我是风筝
作为一个 Java 程序员,想必一定对 MyBatis 非常熟悉,尤其在国内来看,只要是 Java 项目并且涉及到数据库操作,绝大多数都会使用 MyBatis,或者是 MyBatis 的各个变种。
那在查询数据库的场景中,分页是无法避免的,不管前端是按钮翻页还是下拉加载,对应到数据库上都是一样的,都是利用数据库的条数限制,例如 MySQL 中的 Limit。
而在完成分页需求时,不知道有多少同学是自己实现的,还有多少同学使用 PageHelper 。刚开始自学 Java 的时候,我都是古法手工撸 SQL 语句,在 Mapper 层传分页参数,然后在 SQL 中分页。直到后来我发现了 PageHelper ,害,早直到有这家伙,还自己写啥呀,交给它就完事儿了。
后来的很多项目中都使用它,有从 Spring Boot 最基础的脚手架从 0 搭建的项目,也有直接使用的成熟脚手架,例如若依,都在使用 PageHelper,从此分页就变得异常简单了。
前几天,有个工作不就的 Java 小哥问我说问题,说是 PageHelper 本来好好的,结果加了几行代码,分页数据都失效了。
图片
当他还没有亮出代码的时候,我基本上已经猜到问题原因了。倒不是我厉害,恰恰相反,因为我之前很菜的时候也碰到过类似的问题,而且不止一次。也就是菜了一次,没有吸取教训,又菜了第二次。直到我研究了一下 PageHelper 的原理,之后才没有出现类似的问题。
失效原因分析及解决
当小哥给我发来代码后,死去的以及开始攻击我,基本就是当初我写的代码的格式,不光是我,我 Google 了一下,出现问题的基本都是这么用的。
我简化了一下这个逻辑:
- 设置分页参数 PageHelper.startPage(1,10);
- 通过一个 Mapper 查询出结果集;
- 通过上一步的结果集构造 PageInfo
- 这时候,构造出的 PageInfo 是没问题的。
如果你的业务比较单纯,这样也就没问题了,但是有些情况下不是这样的。
public PageInfo search(String keyword) {
List voList = new ArrayList();
// 1.设置分页,第1页,10条
PageHelper.startPage(1,10);
// 2.查询结果集
List dataVos = xxxMapper.searchDataList(keyword);
// 3.通过上一步的结果集构造 PageInfo
PageInfo pageSuccess = new PageInfo(dataVos);
// 结果是对的
log.info("pageSuccess:" + JSON.toJSONString(pageSuccess));
// 4.真实情况,还要对结果集进行加工,将结果集转变了类型
for (DataVo dataVo : dataVos) {
DataDetailVo vo = new DataDetailVo();
BeanUtils.copyProperties(dataVo, vo);
voList.add(vo);
}
// 5.这时候,通过新的结果集构造 PageInfo,分页信息就是错误的
PageInfo pageFail = new PageInfo(voList);
log.info("pageFail:" + JSON.toJSONString(pageFail));
return pageFail;
}
- 真实情况,还要对结果集进行加工,将结果集转变了类型;
- 这时候,通过新的结果集构造 PageInfo,分页信息就是错误的
很多时候会像第4步那样,对初始结果集进行进一步再加工,而这些加工的数据没办法通过 SQL 直接获取到,或者用 SQL 获取代价太大。
甚至有时候会像上面的代码那样,从数据库查询出来的实体类型和实际返回给调方的实体类型都不一样。有的同学说,难道就不能在 mapper 层直接返回需要类型吗?当然可以,不过很多时候不可能都这么完美。
问题原因
原因很明显,稍有经验的同学可能已经看出来了,就是因为第5步构造 PageInfo 时使用了一个新的 List,才导致分页失效的。
这只是表现出来的原因,但是 Mapper 查出来的是一个 List(dataVos),经过加工的也是 List(voList),怎么就一个正常,一个不正常呢,难道这两个 List 有什么不一样的吗?还是 PageHelper 只认第一个 List?
下面介绍原理的时候再说这个问题。
解决方式
解决这个问题也很简单。
方式1:不要加工了嘛,mapper 返回啥,就直接给调用方返回啥。
也不是不可以,你要是产品经理+老板的话,可以直接改需求,让需求来适应代码,但是基本上行不通;
方式2:前面也说了,直接让 mapper 返回最终返回给调用方的类型,不要在加工的时候生成新的 List 了。
这种也可以,但是改动可能比较大,因为有的 Mapper 层的方法是供很多其他方法调用的,Mapper 层基本上只需要返回最通用的类型。不能为了某个方法调用方,而让其他调用方也做出改变。
当然了,你可以为这种特殊的需求新加一个 Mapper 方法,只是比较麻烦而已。
方式3:在构造 PageInfo 的时候稍加修改就可以了
只需要将原本构造错误的 PageInfo
PageInfo pageFail = new PageInfo(voList);
log.info("pageFail:" + JSON.toJSONString(pageFail));
改为下面这样既可,还是用 Mapper 层返回的dataVos 集合来构造 PageInfo,只不过稍后将加工后的新的List 赋值给 PageInfo 的 list 属性即可。
PageInfo pageSuccess2 = new PageInfo(dataVos);
pageSuccess2.setList(voList);
原理分析
前面查找原因的时候提到这样一个问题:Mapper 查出来的是一个 List(dataVos),经过加工的也是 List(voList),怎么就一个正常,一个不正常呢,难道这两个 List 有什么不一样的吗?
我们就顺着这个问题思考就可以了,我先说结论,这俩 List 确实不一样,确切的说,Mapper 查出来的那个 List 是被 PageHelper 包装后的List,再确切的说是 PageHelper 里的 Page 对象。
PageHelper.startPage(1,10);
// 2.查询结果集
List dataVos = xxxMapper.searchDataList(keyword);
通过调试代码可以看出来,dataVos 就是一个披着 List 外衣的 Page 对象,你可以直接在这个对象上调用 Page 中的方法,比如 getTotal(),可以直接返回数量的。
图片
而你自己加工后的集合,就真的是个单纯的 ArrayList 了,所以在使用 PageInfo 构造分页对象的时候,是绝对不可能获取到真实的分页参数的,比如总条数、总页数等。
简要概括一下这个过程,不过多解释源码,整个流程大致如下。
图片
通过 ThreadLocal 存储 Page 初始参数
首先通过代码 PageHelper.startPage(1,10);设置分页参数,这个过程很简单,就是初始化 Page 对象,然后存到 ThreadLocal 中。
关于 ThreadLocal 可以参考我之前的一篇文章 我还是不懂 ThreadLocal,不要没标题迷惑,看完就懂了。
public static Page startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page page = new Page(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
//当已经执行过orderBy的时候
Page oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}
其中 setLocalPage(page)就是像 ThreadLocal 中存 Page 对象,一会儿还有地方用到它。
利用 MyBatis 拦截器机制
然后就是利用了 MyBatis 的拦截器机制,拦截器主要做两件事,第一件就是在查询数据集合前先count一下,把数量查出来。第二件就是将查询出来的数据集包装成 Page 对象,当然了 Page 是继承自 ArrayList 的,要不然它也不能伪装的这么好。
在 PageHelper 源码中有 PageInterceptor.java这个拦截器,主要是里面的 intercept 方法。这里面就是实现核心逻辑的主战场。
public Object intercept(Invocation invocation) throws Throwable {
try {
// ...
List resultList;
//调用方法判断是否需要进行分页,如果不需要,直接返回结果
if (!dialect.skip(ms, parameter, rowBounds)) {
//判断是否需要进行 count 查询
if (dialect.beforeCount(ms, parameter, rowBounds)) {
//查询总数
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
//rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
// 处理加工后的结果集
return dialect.afterPage(resultList, parameter, rowBounds);
}
}
先判断是否需要进行分页,如果不需要,直接返回结果。也就是这行代码,你可以点进去看一下 skip 这个方法,就是获取 ThreadLocal 中的Page对象,看是不是存在,是不是有分页参数,有的话就是需要分页,没有就直接按照正常的查询走了。
if (!dialect.skip(ms, parameter, rowBounds))
如果需要分页的话,先查询一下数量。
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
然后根据分页参数,查询分页结果集。
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
之后对结果集加工并返回。
return dialect.afterPage(resultList, parameter, rowBounds);
最终加工成 Page 的方法,看到没,还是先从 ThreadLocal中拿,然后将原始结果集放进去。
public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
Page page = getLocalPage();
if (page == null) {
return pageList;
}
page.addAll(pageList);
if (!page.isCount()) {
page.setTotal(-1);
} else if ((page.getPageSizeZero() != null && page.getPageSizeZero()) && page.getPageSize() == 0) {
page.setTotal(pageList.size());
} else if (page.isOrderByOnly()) {
page.setTotal(pageList.size());
}
return page;
}
最后 PageInfo 构造
最后,构造 PageInfo 的时候,判断 List 类型,如果类型是 Page ,也就是我们说的生效的情况,那就能正常的返回分页信息。如果单纯就是个Collection,则分页信息就按照传入的这个集合给你返回,这就是为什么在分页不生效的时候,返回的total就是你传入的 List 的size。
public PageInfo(List