PageHelper 分頁(yè)為什么會(huì)失效,含原理分析
大家好,我是風(fēng)箏
作為一個(gè) Java 程序員,想必一定對(duì) MyBatis 非常熟悉,尤其在國(guó)內(nèi)來(lái)看,只要是 Java 項(xiàng)目并且涉及到數(shù)據(jù)庫(kù)操作,絕大多數(shù)都會(huì)使用 MyBatis,或者是 MyBatis 的各個(gè)變種。
那在查詢數(shù)據(jù)庫(kù)的場(chǎng)景中,分頁(yè)是無(wú)法避免的,不管前端是按鈕翻頁(yè)還是下拉加載,對(duì)應(yīng)到數(shù)據(jù)庫(kù)上都是一樣的,都是利用數(shù)據(jù)庫(kù)的條數(shù)限制,例如 MySQL 中的 Limit。
而在完成分頁(yè)需求時(shí),不知道有多少同學(xué)是自己實(shí)現(xiàn)的,還有多少同學(xué)使用 PageHelper 。剛開(kāi)始自學(xué) Java 的時(shí)候,我都是古法手工擼 SQL 語(yǔ)句,在 Mapper 層傳分頁(yè)參數(shù),然后在 SQL 中分頁(yè)。直到后來(lái)我發(fā)現(xiàn)了 PageHelper ,害,早直到有這家伙,還自己寫啥呀,交給它就完事兒了。
后來(lái)的很多項(xiàng)目中都使用它,有從 Spring Boot 最基礎(chǔ)的腳手架從 0 搭建的項(xiàng)目,也有直接使用的成熟腳手架,例如若依,都在使用 PageHelper,從此分頁(yè)就變得異常簡(jiǎn)單了。
前幾天,有個(gè)工作不就的 Java 小哥問(wèn)我說(shuō)問(wèn)題,說(shuō)是 PageHelper 本來(lái)好好的,結(jié)果加了幾行代碼,分頁(yè)數(shù)據(jù)都失效了。
當(dāng)他還沒(méi)有亮出代碼的時(shí)候,我基本上已經(jīng)猜到問(wèn)題原因了。倒不是我厲害,恰恰相反,因?yàn)槲抑昂懿说臅r(shí)候也碰到過(guò)類似的問(wèn)題,而且不止一次。也就是菜了一次,沒(méi)有吸取教訓(xùn),又菜了第二次。直到我研究了一下 PageHelper 的原理,之后才沒(méi)有出現(xiàn)類似的問(wèn)題。
失效原因分析及解決
當(dāng)小哥給我發(fā)來(lái)代碼后,死去的以及開(kāi)始攻擊我,基本就是當(dāng)初我寫的代碼的格式,不光是我,我 Google 了一下,出現(xiàn)問(wèn)題的基本都是這么用的。
我簡(jiǎn)化了一下這個(gè)邏輯:
- 設(shè)置分頁(yè)參數(shù) PageHelper.startPage(1,10);
- 通過(guò)一個(gè) Mapper 查詢出結(jié)果集;
- 通過(guò)上一步的結(jié)果集構(gòu)造 PageInfo
- 這時(shí)候,構(gòu)造出的 PageInfo 是沒(méi)問(wèn)題的。
如果你的業(yè)務(wù)比較單純,這樣也就沒(méi)問(wèn)題了,但是有些情況下不是這樣的。
public PageInfo<DataDetailVo> search(String keyword) {
List<DataDetailVo> voList = new ArrayList<>();
// 1.設(shè)置分頁(yè),第1頁(yè),10條
PageHelper.startPage(1,10);
// 2.查詢結(jié)果集
List<DataVo> dataVos = xxxMapper.searchDataList(keyword);
// 3.通過(guò)上一步的結(jié)果集構(gòu)造 PageInfo
PageInfo<DataVo> pageSuccess = new PageInfo<>(dataVos);
// 結(jié)果是對(duì)的
log.info("pageSuccess:" + JSON.toJSONString(pageSuccess));
// 4.真實(shí)情況,還要對(duì)結(jié)果集進(jìn)行加工,將結(jié)果集轉(zhuǎn)變了類型
for (DataVo dataVo : dataVos) {
DataDetailVo vo = new DataDetailVo();
BeanUtils.copyProperties(dataVo, vo);
voList.add(vo);
}
// 5.這時(shí)候,通過(guò)新的結(jié)果集構(gòu)造 PageInfo,分頁(yè)信息就是錯(cuò)誤的
PageInfo<DataDetailVo> pageFail = new PageInfo<>(voList);
log.info("pageFail:" + JSON.toJSONString(pageFail));
return pageFail;
}
- 真實(shí)情況,還要對(duì)結(jié)果集進(jìn)行加工,將結(jié)果集轉(zhuǎn)變了類型;
- 這時(shí)候,通過(guò)新的結(jié)果集構(gòu)造 PageInfo,分頁(yè)信息就是錯(cuò)誤的
很多時(shí)候會(huì)像第4步那樣,對(duì)初始結(jié)果集進(jìn)行進(jìn)一步再加工,而這些加工的數(shù)據(jù)沒(méi)辦法通過(guò) SQL 直接獲取到,或者用 SQL 獲取代價(jià)太大。
甚至有時(shí)候會(huì)像上面的代碼那樣,從數(shù)據(jù)庫(kù)查詢出來(lái)的實(shí)體類型和實(shí)際返回給調(diào)方的實(shí)體類型都不一樣。有的同學(xué)說(shuō),難道就不能在 mapper 層直接返回需要類型嗎?當(dāng)然可以,不過(guò)很多時(shí)候不可能都這么完美。
問(wèn)題原因
原因很明顯,稍有經(jīng)驗(yàn)的同學(xué)可能已經(jīng)看出來(lái)了,就是因?yàn)榈?步構(gòu)造 PageInfo 時(shí)使用了一個(gè)新的 List,才導(dǎo)致分頁(yè)失效的。
這只是表現(xiàn)出來(lái)的原因,但是 Mapper 查出來(lái)的是一個(gè) List(dataVos),經(jīng)過(guò)加工的也是 List(voList),怎么就一個(gè)正常,一個(gè)不正常呢,難道這兩個(gè) List 有什么不一樣的嗎?還是 PageHelper 只認(rèn)第一個(gè) List?
下面介紹原理的時(shí)候再說(shuō)這個(gè)問(wèn)題。
解決方式
解決這個(gè)問(wèn)題也很簡(jiǎn)單。
方式1:不要加工了嘛,mapper 返回啥,就直接給調(diào)用方返回啥。
也不是不可以,你要是產(chǎn)品經(jīng)理+老板的話,可以直接改需求,讓需求來(lái)適應(yīng)代碼,但是基本上行不通;
方式2:前面也說(shuō)了,直接讓 mapper 返回最終返回給調(diào)用方的類型,不要在加工的時(shí)候生成新的 List 了。
這種也可以,但是改動(dòng)可能比較大,因?yàn)橛械?Mapper 層的方法是供很多其他方法調(diào)用的,Mapper 層基本上只需要返回最通用的類型。不能為了某個(gè)方法調(diào)用方,而讓其他調(diào)用方也做出改變。
當(dāng)然了,你可以為這種特殊的需求新加一個(gè) Mapper 方法,只是比較麻煩而已。
方式3:在構(gòu)造 PageInfo 的時(shí)候稍加修改就可以了
只需要將原本構(gòu)造錯(cuò)誤的 PageInfo
PageInfo<DataDetailVo> pageFail = new PageInfo<>(voList);
log.info("pageFail:" + JSON.toJSONString(pageFail));
改為下面這樣既可,還是用 Mapper 層返回的dataVos 集合來(lái)構(gòu)造 PageInfo,只不過(guò)稍后將加工后的新的List 賦值給 PageInfo 的 list 屬性即可。
PageInfo pageSuccess2 = new PageInfo<>(dataVos);
pageSuccess2.setList(voList);
原理分析
前面查找原因的時(shí)候提到這樣一個(gè)問(wèn)題:Mapper 查出來(lái)的是一個(gè) List(dataVos),經(jīng)過(guò)加工的也是 List(voList),怎么就一個(gè)正常,一個(gè)不正常呢,難道這兩個(gè) List 有什么不一樣的嗎?
我們就順著這個(gè)問(wèn)題思考就可以了,我先說(shuō)結(jié)論,這倆 List 確實(shí)不一樣,確切的說(shuō),Mapper 查出來(lái)的那個(gè) List 是被 PageHelper 包裝后的List,再確切的說(shuō)是 PageHelper 里的 Page 對(duì)象。
PageHelper.startPage(1,10);
// 2.查詢結(jié)果集
List<DataVo> dataVos = xxxMapper.searchDataList(keyword);
通過(guò)調(diào)試代碼可以看出來(lái),dataVos 就是一個(gè)披著 List 外衣的 Page 對(duì)象,你可以直接在這個(gè)對(duì)象上調(diào)用 Page 中的方法,比如 getTotal(),可以直接返回?cái)?shù)量的。
圖片
而你自己加工后的集合,就真的是個(gè)單純的 ArrayList 了,所以在使用 PageInfo 構(gòu)造分頁(yè)對(duì)象的時(shí)候,是絕對(duì)不可能獲取到真實(shí)的分頁(yè)參數(shù)的,比如總條數(shù)、總頁(yè)數(shù)等。
簡(jiǎn)要概括一下這個(gè)過(guò)程,不過(guò)多解釋源碼,整個(gè)流程大致如下。
圖片
通過(guò) ThreadLocal 存儲(chǔ) Page 初始參數(shù)
首先通過(guò)代碼 PageHelper.startPage(1,10);設(shè)置分頁(yè)參數(shù),這個(gè)過(guò)程很簡(jiǎn)單,就是初始化 Page 對(duì)象,然后存到 ThreadLocal 中。
關(guān)于 ThreadLocal 可以參考我之前的一篇文章 我還是不懂 ThreadLocal,不要沒(méi)標(biāo)題迷惑,看完就懂了。
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<E>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
//當(dāng)已經(jīng)執(zhí)行過(guò)orderBy的時(shí)候
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}
其中 setLocalPage(page)就是像 ThreadLocal 中存 Page 對(duì)象,一會(huì)兒還有地方用到它。
利用 MyBatis 攔截器機(jī)制
然后就是利用了 MyBatis 的攔截器機(jī)制,攔截器主要做兩件事,第一件就是在查詢數(shù)據(jù)集合前先count一下,把數(shù)量查出來(lái)。第二件就是將查詢出來(lái)的數(shù)據(jù)集包裝成 Page 對(duì)象,當(dāng)然了 Page 是繼承自 ArrayList 的,要不然它也不能偽裝的這么好。
在 PageHelper 源碼中有 PageInterceptor.java這個(gè)攔截器,主要是里面的 intercept 方法。這里面就是實(shí)現(xiàn)核心邏輯的主戰(zhàn)場(chǎng)。
public Object intercept(Invocation invocation) throws Throwable {
try {
// ...
List resultList;
//調(diào)用方法判斷是否需要進(jìn)行分頁(yè),如果不需要,直接返回結(jié)果
if (!dialect.skip(ms, parameter, rowBounds)) {
//判斷是否需要進(jìn)行 count 查詢
if (dialect.beforeCount(ms, parameter, rowBounds)) {
//查詢總數(shù)
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
//處理查詢總數(shù),返回 true 時(shí)繼續(xù)分頁(yè)查詢,false 時(shí)直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//當(dāng)查詢總數(shù)為 0 時(shí),直接返回空的結(jié)果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
//rowBounds用參數(shù)值,不使用分頁(yè)插件處理時(shí),仍然支持默認(rèn)的內(nèi)存分頁(yè)
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
// 處理加工后的結(jié)果集
return dialect.afterPage(resultList, parameter, rowBounds);
}
}
先判斷是否需要進(jìn)行分頁(yè),如果不需要,直接返回結(jié)果。也就是這行代碼,你可以點(diǎn)進(jìn)去看一下 skip 這個(gè)方法,就是獲取 ThreadLocal 中的Page對(duì)象,看是不是存在,是不是有分頁(yè)參數(shù),有的話就是需要分頁(yè),沒(méi)有就直接按照正常的查詢走了。
if (!dialect.skip(ms, parameter, rowBounds))
如果需要分頁(yè)的話,先查詢一下數(shù)量。
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
然后根據(jù)分頁(yè)參數(shù),查詢分頁(yè)結(jié)果集。
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
之后對(duì)結(jié)果集加工并返回。
return dialect.afterPage(resultList, parameter, rowBounds);
最終加工成 Page 的方法,看到?jīng)],還是先從 ThreadLocal中拿,然后將原始結(jié)果集放進(jìn)去。
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 構(gòu)造
最后,構(gòu)造 PageInfo 的時(shí)候,判斷 List 類型,如果類型是 Page ,也就是我們說(shuō)的生效的情況,那就能正常的返回分頁(yè)信息。如果單純就是個(gè)Collection,則分頁(yè)信息就按照傳入的這個(gè)集合給你返回,這就是為什么在分頁(yè)不生效的時(shí)候,返回的total就是你傳入的 List 的size。
public PageInfo(List<? extends T> list, int navigatePages) {
super(list);
if (list instanceof Page) {
Page page = (Page) list;
this.pageNum = page.getPageNum();
this.pageSize = page.getPageSize();
this.pages = page.getPages();
this.size = page.size();
//由于結(jié)果是>startRow的,所以實(shí)際的需要+1
if (this.size == 0) {
this.startRow = 0;
this.endRow = 0;
} else {
this.startRow = page.getStartRow() + 1;
//計(jì)算實(shí)際的endRow(最后一頁(yè)的時(shí)候特殊)
this.endRow = this.startRow - 1 + this.size;
}
} else if (list instanceof Collection) {
this.pageNum = 1;
this.pageSize = list.size();
this.pages = this.pageSize > 0 ? 1 : 0;
this.size = list.size();
this.startRow = 0;
this.endRow = list.size() > 0 ? list.size() - 1 : 0;
}
if (list instanceof Collection) {
calcByNavigatePages(navigatePages);
}
}
怎么樣,學(xué)廢了嗎?