當前位置: 妍妍網 > 碼農

PageHelper 又給我上了一課!

2024-03-08碼農

作者:我犟不過你
連結:https://juejin.cn/post/7125356642366914596

多年不用PageHelper了,最近新入職的公司,采用了此工具整合的框架,作為一個獨立緊急計畫開發的基礎。計畫開發起來,還是手到擒來的,但是沒想到,最終測試的時候,深深的給我上了一課。

# 我的計畫發生了哪些奇葩現象?

一切的問題都要從我接受的計畫開始說起, 在開發這個計畫的過程中,發生了各種奇葩的事情, 下面我簡單說給你們聽聽:

帳號重復註冊?

你肯定在想這是什麽意思? 就是字面意思,已經註冊的帳號,可以再次註冊成功!!!

elseif (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username))||"匿名使用者".equals(username)){// 註冊使用者已存在 msg = "註冊使用者'" + username + "'失敗";}

如上所示: checkUserNameUnique(username)用來驗證資料庫是否存在使用者名稱:

<select id="checkUserNameUnique" parameterType="String" resultType="int">selectcount(1) from sys_user where user_name = #{userName} limit 1</select>

正常來說,是不會有問題的,那麽原因我們後面講,接著看下一個問題。

查詢全部份類的下拉選單只能查出5條數據?

如上所示,明明有十多個結果,怎麽只能返回5個?我也沒有添加分頁參數啊?

相信用過PageHelper的同學已經知道問題出在哪裏了。

修改使用者密碼報錯?

當管理員在後台界面重設使用者的密碼的時候,居然報錯了??

報錯資訊清晰的告訴了我:sql語句異常,update語句不認識 「Limit 5」

到此為止,報錯資訊已經告訴了我,我的sql被拼接了該死的「limit」分頁參數。

小結

上面提到的幾個只是冰山一角,在我使用的過程中,還有各種涉及到sql的地方,會因為這個分頁參數導致的問題,我可以分為兩種:

  • 1)直接導致報錯的:明確報錯原因的

    比如insert、update語句等,不支持limit,會直接報錯。

  • 2)導致業務邏輯錯誤,但是程式碼沒有錯誤提示

  • 如我上面提到的使用者可以重復註冊,卻沒有報錯,實際在程式碼當中是有報錯的,但是當前方法對異常進行了throw,最終被全域異常捕獲了。

  • 不分頁的sql被拼接了limit,導致沒有報錯,但是數據返回量錯誤。

  • 註意:異常不是每次出現,是有一定紀律的,但是觸發機率較高,原因在後面會逐漸脫出。

    # PageHelper是怎麽做到上面的問題的?


    PageHelper使用

    我這裏只講解計畫基於的框架的使用方式。

    程式碼如下:

    @GetMapping("/cms/cmsEssayList")public TableDataInfo cmsEssayList(CmsBlog cmsBlog){//狀態為釋出 cmsBlog.setStatus("1"); startPage(); List<CmsBlog> list = cmsBlogService.selectCmsBlogList(cmsBlog);return getDataTable(list);}

    使用起來還是很簡單的,透過 startPage()指定分頁參數,透過getDataTable(list)對結果數據封裝成分頁的格式。

    有些同學會問,這也沒沒傳分頁參數啊,並且實體類當中也沒有,這就是比較有意思的點,下一小結就來聊聊源碼。

    startPage()幹啥了?

    protectedvoid startPage(){// 透過request去獲取前端傳遞的分頁參數,不需控制器要顯示接收 PageDomain pageDomain = TableSupport.buildPageRequest(); Integer pageNum = pageDomain.getPageNum(); Integer pageSize = pageDomain.getPageSize();if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize)) {String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());Boolean reasonable = pageDomain.getReasonable();// 真正使用pageHelper進行分頁的位置 PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable); }}

    PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable)的參數分別是:

  • pageNum:頁數

  • pageSize:每頁數據量

  • orderBy:排序

  • reasonable:分頁合理化,對於不合理的分頁參數自動處理,比如傳遞pageNum是小於0,會預設設定為1.

  • 繼續跟蹤,連續點選startpage構造方法到達如下位置:

    /** * 開始分頁 * * @param pageNum 頁碼 * @param pageSize 每頁顯示數量 * @param count 是否進行count查詢 * @param reasonable 分頁合理化,null時用預設配置 * @param pageSizeZero true且pageSize=0時返回全部結果,false分時頁,null時用預設配置 */publicstatic <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);// 1、獲取本地分頁 Page<E> oldPage = getLocalPage();if (oldPage != null && oldPage.isOrderByOnly()) { page.setOrderBy(oldPage.getOrderBy()); }// 2、設定本地分頁 setLocalPage(page);return page;}

    到達終點位置了,分別是:getLocalPage()和setLocalPage(page),分別來看下:


    getLocalPage()

    進入方法:

    /** * 獲取 Page 參數 * * @return */publicstatic <T> Page<T> getLocalPage() {return LOCAL_PAGE.get();}

    看看常量LOCAL_PAGE是個什麽路數?

    protectedstaticfinal ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

    好家夥,是ThreadLocal,學過java基礎的都知道吧,獨屬於每個執行緒的本地緩存物件。

    當一個請求來的時候,會獲取持有當前請求的執行緒的ThreadLocal,呼叫LOCAL_PAGE.get(),檢視當前執行緒是否有未執行的分頁配置。


    setLocalPage(page)

    此方法顯而易見,設定執行緒的分頁配置:

    protectedstaticvoidsetLocalPage(Page page) { LOCAL_PAGE.set(page);}


    小結

    經過前面的分析,我們發現,問題似乎就是這個ThreadLocal導致的。

    是否在使用完之後沒有進行清理?導致下一次此執行緒再次處理請求時,還在使用之前的配置?

    我們帶著疑問,看看mybatis時如何使用pageHelper的。

    # mybatis使用pageHelper分析

    我們需要關註的就是mybatis在何時使用的這個ThreadLocal,也就是何時將分頁餐數獲取到的。

    前面提到過,透過PageHelper的startPage()方法進行page緩存的設定,當程式執行sql介面mapper的方法時,就會被攔截器PageInterceptor攔截到。

    PageHelper其實就是mybatis的分頁外掛程式,其實作原理就是透過攔截器的方式,pageHelper通PageInterceptor實作分頁效果,我們只關註intercept方法:

    @OverridepublicObject intercept(Invocation invocation) throws Throwable {try {Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0];Object parameter = args[1]; RowBounds rowBounds = (RowBounds) args[2]; ResultHandler resultHandler = (ResultHandler) args[3]; Executor executor = (Executor) invocation.getTarget(); CacheKey cacheKey; BoundSql boundSql;// 由於邏輯關系,只會進入一次if (args.length == 4) {//4 個參數時 boundSql = ms.getBoundSql(parameter); cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql); } else {//6 個參數時 cacheKey = (CacheKey) args[4]; boundSql = (BoundSql) args[5]; } checkDialectExists();//對 boundSql 的攔截處理if (dialect instanceof BoundSqlInterceptor.Chain) { boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey); } 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); } finally {if(dialect != null){ dialect.afterAll(); } }}

    如上所示是intecept的全部程式碼,我們下面只關註幾個終點位置:

    設定分頁:dialect.skip(ms, parameter, rowBounds)

    此處的skip方法進行設定分頁參數,內部呼叫方法:

    Page page = pageParams.getPage(parameterObject, rowBounds);

    繼續跟蹤getPage(),發現此方法的第一行就獲取了ThreadLocal的值:

    Page page = PageHelper.getLocalPage();

    統計數量:dialect.beforeCount(ms, parameter, rowBounds)

    我們都知道,分頁需要獲取記錄總數,所以,這個攔截器會在分頁前先進行count操作。

    如果count為0,則直接返回,不進行分頁:

    //處理查詢總數,返回 true 時繼續分頁查詢,false 時直接返回if (!dialect.afterCount(count, parameter, rowBounds)) {//當查詢總數為 0 時,直接返回空的結果return dialect.afterPage(new ArrayList(), parameter, rowBounds);}

    afterPage其實是對分頁結果的封裝方法,即使不分頁,也會執行,只不過返回空列表。

    分頁:ExecutorUtil.pageQuery

    在處理完count方法後,就是真正的進行分頁了:

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

    此方法在執行分頁之前,會判斷是否執行分頁,依據就是前面我們透過ThreadLocal的獲取的page。

    當然,不分頁的查詢,以及新增和更新不會走到這個方法當中。

    非分頁:executor.query

    而是會走到下面的這個分支:

    resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);

    我們可以思考一下,如果ThreadLoad在使用後沒有被清除,當執行非分頁的方法時,那麽就會將Limit拼接到sql後面。

    為什麽不分也得也會拼接?我們回頭看下前面提到的dialect.skip(ms, parameter, rowBounds):

    如上所示,只要page被獲取到了,那麽這個sql,就會走前面提到的ExecutorUtil.pageQuery分頁邏輯,最終導致出現不可預料的情況。

    其實PageHelper對於分頁後的ThreaLocal是有清除處理的。

    清除TheadLocal

    在intercept方法的最後,會在sql方法執行完成後,清理page緩存:

    finally {if(dialect != null){ dialect.afterAll(); }}

    看看這個afterAll方法():

    @OverridepublicvoidafterAll() {//這個方法即使不分頁也會被執行,所以要判斷 null AbstractHelperDialect delegate = autoDialect.getDelegate();if (delegate != null) {delegate.afterAll(); autoDialect.clearDelegate(); } clearPage();}

    只關註 clearPage():

    /** * 移除本地變量 */publicstaticvoidclearPage() { LOCAL_PAGE.remove();}


    小結

    到此為止,關於PageHelper的使用方式就講解完了。

    整體看下來,似乎不會存在什麽問題,但是我們可以考慮集中極端情況:

  • 如果使用了startPage(),但是沒有執行對應的sql,那麽就表明,當前執行緒ThreadLocal被設定了分頁參數,可是沒有被使用,當下一個使用此執行緒的請求來時,就會出現問題。

  • 如果程式在執行sql前,發生異常了,就沒辦法執行finally當中的clearPage()方法,也會造成執行緒的ThreadLocal被汙染。

  • 所以,官方給我們的建議,在使用PageHelper進行分頁時,執行sql的程式碼要緊跟startPage()方法。

    除此之外,我們可以手動呼叫clearPage()方法,在存在問題的方法之前。

    需要註意:不要分頁的方法前手動呼叫clearPage,將會導致你的分頁出現問題。

    # 還有人問為什麽不是每次請求都出錯?

    這個其實取決於我們啟動服務所使用的容器,比如tomcat,在其內部處理請求是透過執行緒池的方式。甚至現在的很多容器是基於netty的,都是透過執行緒池,復用執行緒來增加服務的並行量。

    假設執行緒1持有沒有被清除的page參數,不斷呼叫同一個方法,後面兩個請求使用的是執行緒2和執行緒3沒有問題,再一個請求輪到執行緒1了,此時就會出現問題了。

    # 總結

    關於PageHelper的介紹就這麽多,真的是折磨我好幾天,要不是計畫緊急,來不及替換,我一定不會使用這個元件。

    莫名其妙的就會有個方法出現問題,一通排查,發現都是這個PageHelper導致的。雖然我已經全域搜尋使用的地方,保證startPage()後緊跟sql命令,但是仍然有嫌犯潛逃,只能在有問題的方法使用clearPage()來打修補程式。

    雖然PageHelper給我帶來一些困擾,耗費了一定的時間,但是定位問題的過程中,也學習了mybatis和pagehepler的實作方式,對於熱愛源碼閱讀的同學來說還是有一定的提升的。

    熱門推薦