作為一個 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<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步那樣,對初始結果集進行進一步再加工,而這些加工的資料沒辦法透過 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<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(),可以直接返回數量的。
而你自己加工後的集合,就真的是個單純的 ArrayList 了,所以在使用 PageInfo 構造分頁物件的時候,是絕對不可能獲取到真實的分頁引數的,比如總條數、總頁數等。
簡要概括一下這個過程,不過多解釋原始碼,整個流程大致如下。
透過 ThreadLocal 儲存 Page 初始引數
首先透過程式碼 PageHelper.startPage(1,10);設定分頁引數,這個過程很簡單,就是初始化 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); //當已經執行過orderBy的時候 Page<E> 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<? 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); } }
怎麼樣,學廢了嗎?