還在手動寫分頁程式碼?看看 PageHelper 如何實現自動化..

語言: CN / TW / HK

記得之前在參加面試的時候,有個面試官給我提了一個問題:請說一下PageHelper分頁外掛的底層原理。當聽到這個問題的時候既熟悉又陌生,熟悉是因為平時都在使用它,熟的不能再熟了;陌生是因為只停留在用的階段,卻沒有沉下心來仔細研究,以至於手足無措。不知道手機前的你是否能準確地描述出來呢?今天就讓我們來認識一下它吧( 此處附上官網地址: https://pagehelper.github.io/

首先我們來說一下如何整合和使用它吧(以 Springboot 為例)

pom.xml中引入依賴

<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>

application.yml中引入配置

pagehelper:
helperDialect: mysql
reasonable: true
supportMethodsArguments: true
params: count=countSql

引數解釋

1. helperDialect : 分頁外掛會自動檢測當前的資料庫連結,自動選擇合適的分頁方式。 你可以配置  helperDialect  屬性來指定分頁外掛使用哪種方言。 配置時,可以使用下面的縮寫值: oracle , mysql , mariadb , sqlite , hsqldb , postgresql , db2 , sqlserver , informix , h2 , sqlserver2012 , derby

特別注意: 使用  SqlServer2012  資料庫時,需要手動指定為  sqlserver2012 ,否則會使用  SqlServer2005  的方式進行分頁。 你也可以實現  AbstractHelperDialect  ,然後配置該屬性為實現類的全限定名稱即可使用自定義的實現方法。
2. reasonable : 分頁合理化引數,預設值為  false  。 當該引數設定為  true  時,  pageNum<=0  時會查詢第一頁,  pageNum>pages  (超過總數時),會查詢最後一頁。 預設  false  時,直接根據引數進行查詢。

    3. supportMethodsArguments : 支援通過  Mapper  介面引數來傳遞分頁引數,預設值  false  ,分頁外掛會從查詢方法的引數值中,自動根據上面  params  配置的欄位中取值,查詢到合適的值時就會自動分頁。     4. params : 為了支援  startPage(Object params) 方法,增加了該引數來配置引數對映,用於從物件中根據屬性名取值, 可以配置  pageNum,pageSize,count,pageSizeZero,reasonable  ,不配置對映的用預設值, 預設值為  pageNum=pageNum;pageSize=pageSize;count=countSql;reasonable=reasonable;pageSizeZero= pageSizeZero  。

其他引數

offsetAsPageNum :預設值為  false ,該引數對使用  RowBounds 作為分頁引數時有效。當該引數設定為  true 時,會將  RowBounds 中的  offset 引數當成  pageNum 使用,可以用頁碼和頁面大小兩個引數進行分頁。 rowBoundsWithCount :預設值為  false ,該引數對使用  RowBounds  作為分頁引數時有效。當該引數設定為  true  時,使用  RowBounds  分頁會進行 count 查詢。 pageSizeZero :預設值為  false  ,當該引數設定為  true  時,如果  pageSize=0  或者  RowBounds.limit = 0  就會查詢出全部的結果(相當於沒有執行分頁查詢,但是返回結果仍然是  Page  型別)。 autoRuntimeDialect :預設值為  false  。設定為  true  時,允許在執行時根據多資料來源自動識別對應方言的分頁 (不支援自動選擇  sqlserver2012  ,只能使用  sqlserver  ) closeConn :預設值為  true  。當使用執行時動態資料來源或沒有設定  helperDialect  屬性自動獲取資料庫型別時,會自動獲取一個數據庫連線, 通過該屬性來設定是否關閉獲取的這個連線,預設  true  關閉,設定為  false  後,不會關閉獲取的連線,這個引數的設定要根據自己選擇的資料來源來決定。

使用方法

@PostMapping("/list")
public PageInfo<ProductInfo> list(@RequestBody BasePage basePage){
PageHelper.startPage(basePage.getPageNum(),basePage.getPageSize());
List<ProductInfo> list = productInfoService.list(Wrappers.emptyWrapper());
PageInfo<ProductInfo> productInfoPageInfo = new PageInfo<>(list);
return productInfoPageInfo;
}

返回結果

{
"total": 3,
"list": [
{
"id": 1,
"name": "從你的全世界路過",
"price": 32.0000,
"createDate": "2020-11-21T21:26:12",
"updateDate": "2021-03-27T22:17:39"
},
{
"id": 2,
"name": "喬布斯傳",
"price": 25.0000,
"createDate": "2020-11-21T21:26:42",
"updateDate": "2021-03-27T22:17:42"
}
],
"pageNum": 1,
"pageSize": 2,
"size": 2,
"startRow": 1,
"endRow": 2,
"pages": 2,
"prePage": 0,
"nextPage": 2,
"isFirstPage": true,
"isLastPage": false,
"hasPreviousPage": false,
"hasNextPage": true,
"navigatePages": 8,
"navigatepageNums": [
1,
2
],
"navigateFirstPage": 1,
"navigateLastPage": 2
}

接下來讓我們來看看它是如何實現分頁的

一、先說一個小的知識點: ThreadLocal

ThreadLocal  是什麼?有哪些使用場景?

ThreadLocalJava 提供的用來儲存執行緒中區域性變數的類,執行緒區域性變數是侷限於執行緒內部的變數,屬於執行緒自身所有,不被多個執行緒間共享,通過 getset 方法就可以得到當前執行緒對應的值。

Java 提供 ThreadLocal 類來支援執行緒區域性變數,是一種實現執行緒安全的方式。但是在管理環境下(如  web  伺服器)使用執行緒區域性變數的時候要特別小心,在這種情況下,工作執行緒的生命週期比任何應用變數的生命週期都要長。任何執行緒區域性變數一旦在工作完成後沒有釋放, Java  應用就存在記憶體洩露的風險。

對比

Synchronized 是通過執行緒等待,犧牲時間來解決訪問衝突 ThreadLocal 是通過每個執行緒單獨一份儲存空間,犧牲空間來解決衝突,並且相比於 SynchronizedThreadLocal 具有執行緒隔離的效果,只有在執行緒內才能獲取到對應的值,執行緒外則不能訪問到想要的值。

二、看一下 ThreadLocal PageHelper 中的應用(直接上程式碼)

/**
* 分頁呼叫的最終方法
**/
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;
}

//裡邊最重要的就是Page<E> oldPage = getLocalPage();和setLocalPage(page);方法,他倆是看當前執行緒中的
//ThreadLocal.ThreadLocalMap中是否存在該page物件,若存在直接取出,若不存在則設定一個,我們以第一個為例繼續深入


protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
/**
* 獲取 Page 引數
* @return
*/
public static <T> Page<T> getLocalPage() {
return LOCAL_PAGE.get();
}

public T get() {
//獲取當前執行緒
Thread t = Thread.currentThread();
//獲取當前執行緒中的ThreadLocalMap
ThreadLocalMap map = getMap(t);//ThreadLocal.ThreadLocalMap threadLocals = null;
if (map != null) {
//getEntry(ThreadLocal<?> key)原始碼在下邊
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();//=> t.threadLocals = new ThreadLocalMap(this, firstValue);
}

private Entry getEntry(ThreadLocal<?> key) {
//通過hashCode與length位運算確定出一個索引值i,這個i就是被儲存在table陣列中的位置
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}

static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

總結:我們發現在Thread中維護著型別為 ThreadLocal.ThreadLocalMap 的一個引數 threadLocals ,可以把它看作是一個特殊的 map ,它的 keythreadLocalthreadLocalHashCodevalue 是我們設定的page資訊,其實它底下維護了一個大小為16的環形的 table 陣列,它的負載因子為2/3,我們的資料就存在這個 table 中的 Entry 物件中。

知識點:

1、這裡之所以設定為 WeakReference ,是因為如果這裡使用普通的key-value形式來定義儲存結構,實質上就會造成節點的生命週期與執行緒強繫結,只要執行緒沒有銷燬,那麼節點在GC分析中一直處於可達狀態,沒辦法被回收,而程式本身也無法判斷是否可以清理節點。弱引用是Java中四檔引用的第三檔,比軟引用更加弱一些,如果一個物件沒有強引用鏈可達,那麼一般活不過下一次GC。當某個 ThreadLocal 已經沒有強引用可達,則隨著它被垃圾回收,在 ThreadLocalMap 裡對應的 Entry 的鍵值會失效,這為 ThreadLocalMap 本身的垃圾清理提供了便利。

2、對於某一 ThreadLocal 來講,他的索引值i是確定的,在不同執行緒之間訪問時訪問的是不同的 table 陣列的同一位置即都為table[i],只不過這個不同執行緒之間的table是獨立的。

3、對於同一執行緒的不同 ThreadLocal 來講,這些 ThreadLocal 例項共享一個 table 陣列,然後每個 ThreadLocal 例項在 table 中的索引i是不同的。

三、PageHelper實際攔截SQL

一說到sql的攔截功能,大家應該會想到 Mybatis 的攔截器吧。 Mybatis 攔截器可以對下面4種物件進行攔截:

Executor:mybatis的內部執行器,作為排程核心負責呼叫StatementHandler操作資料庫,並把結果集通過ResultSetHandler進行自動對映 StatementHandler:封裝了JDBC Statement操作,是sql語法的構建器,負責和資料庫進行互動執行sql語句 ParameterHandler:作為處理sql引數設定的物件,主要實現讀取引數和對PreparedStatement的引數進行賦值 ResultSetHandler:處理Statement執行完成後返回結果集的介面物件,mybatis通過它把ResultSet集合對映成實體物件

估計你也猜到了, PageHelper 也是用的 mybatis 的攔截器進行分頁的,接下來就讓我們看下程式碼吧:首先進入`PageInterceptor` 攔截器

//只關注關鍵程式碼
@Override
public Object intercept(Invocation invocation) throws Throwable {
try {
...

resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
...
}
}


public static <E> List<E> pageQuery(Dialect dialect, Executor executor, MappedStatement ms, Object parameter,RowBounds rowBounds, ResultHandler resultHandler,BoundSql boundSql, CacheKey cacheKey) throws SQLException {
//判斷是否需要進行分頁查詢
if (dialect.beforePage(ms, parameter, rowBounds)) {
//生成分頁的快取 key
CacheKey pageKey = cacheKey;
//處理引數物件
parameter = dialect.processParameterObject(ms, parameter, boundSql, pageKey);
...
}
}

獲取到 ThreadLocal 中的 page 物件

@Override
public Object processParameterObject(MappedStatement ms, Object parameterObject, BoundSql boundSql, CacheKey pageKey) {
...
return processPageParameter(ms, paramMap, page, boundSql, pageKey);
}

將分頁資料放進引數中,然後執行分頁的邏輯

這樣我們就可以完成分頁了,如果大家想了解 Mybatis 攔截器的具體使用方法,可以後臺私信阿Q。如果覺得這篇文章對你有所幫助,請幫忙點個在看吧。