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