通過PageHelper源碼學習MyBatis插件的開發

        最近工作中遇到了一些sql語句寫法上的問題(具體的問題會在下一篇文章中說明),想了一些辦法來解決總覺得太麻煩,後來想到了用mybatis的插件方式來實現,但是不太清楚插件的原理以及如何書寫,正好項目中用到了pageHelper,並且在生產中也發現了pageHelper的一些性能問題,於是帶着這兩個目的,我研究了一下pageHelper的源碼,加上大致百度了一下mybatis插件的寫法,在此分享一下經驗。

        與其說是插件,說成是攔截器好像更容易理解一些。本文使用的pageHelper版本爲5.1.10

        當我們在項目中引入pageHelper時,需要在mybatis的配置文件中加一個配置,只說核心吧,就是下面這個

    <plugin interceptor="com.github.pagehelper.PageInterceptor"></plugin>

        這個配置就是爲了告訴mybatis,在執行一條SQL語句的時候,需要調用的插件類是哪一個,於是我們進入到這個類裏看一下。發現他實現了mybatis的Interceptor接口。

        Interceptor接口有三個方法,分別是setProperties、plugin和Intercept。

        setProperties主要是將外部的配置文件載入,就是我們在配置pageHelper時,其他的xml配置。

        plugin是mybatis插件的入口方法,主要是告知mybatis需要用哪個方法進行攔截sql,如果不需要攔截,那麼直接return object;即可,不過這樣就沒意義了。一般而言,都是通過本類的Intercept方法,所以只需要寫return Plugin.wrap(object, this);即可。

        於是,Intercept就成了寫插件的核心方法了。這裏先暫停一下。

        在看這個源碼的時候,會發現它最開頭有兩個註解@Intercepts和@Signature,這是因爲mybatis在處理每一個sql時,提供了4個對象,分別作用於四個時期,分別是executor(全流程),statementHandler(sql準備階段),parameterHandler(參數配置階段),resultHandler(結果處理階段),而我們在執行一個插件執行時,需要明確在什麼時期進行攔截。而每一個對象,又會有很多方法,需要指定攔截的具體方法,並將參數傳入。

        pageHelper主要是攔截了executor的query方法,查看mybatis源碼後發現,query方法有兩個重載方法,所以註解裏會有兩個@Signature,將每個方法參數列出來。關於這一點,我覺得可以當成規定,想寫插件就得這麼寫,原理的話,我才應該是爲了方便反射吧。

        因爲Intercept方法是攔截了mybatis的執行器,所以它的入參invocation其實就是mybatis在執行sql時的入參,它有有4個方法:
        getTarget()  獲取mybatis的執行器
        getMethod()  獲取mybatis的具體方法,裏面包含很多方法,比如獲取註解等等
        getArgs:獲取本次執行mybatis的所有參數。
                0  MappedStatement  維護了一條<select|update|delete|insert>節點的封裝,它的方法ms.getId()可以獲取對應的mapper方法名
                1  parameter  mybatis所有的參數  ms.getBoundSql(parameter).getSql()  可以獲取到本次執行的真正的SQL語句,參數用?代替
                2  RowBounds  mybatis的分頁類,主要有兩個方法,getLimit(默認最大)和getOffset(默認0),這個可以從mybatis的源碼中看到:org.apache.ibatis.session.RowBounds
                3  ResultHandler  mybatis執行結果的封裝

                4  CacheKey  mybatis的緩存

                5  boundSql  mybatis本次執行綁定的sql

        可以看到query方法的兩個重載的區別就是有沒有後兩個參數,說實話,這塊我還沒弄懂,等以後理解了再來補充吧,本次就只涉及前四個參數。

        在程序啓動時,就會調用setProperties ,將我們配置的參數載入。當攔截器剛開始執行時,會判斷配置的參數是否有dialect,如果沒有配置,那麼就採用默認的方言類:com.github.pagehelper.PageHelper,這個類也是pageHelper的核心類。

	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();//判斷用戶是否指定了方言類

        在獲取到參數mybatis所有的參數以後,就要開始進行分頁了,具體代碼如下:

 	//調用方法判斷是否需要進行分頁,如果不需要,直接返回結果
    if (!dialect.skip(ms, parameter, rowBounds)) {
        //判斷是否需要進行 count 查詢
        if (dialect.beforeCount(ms, parameter, rowBounds)) {
            //查詢總數
            Long count = count(executor, ms, parameter, rowBounds, resultHandler, 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);

        就流程而言,看以上這段代碼足以。

        首先就是判斷用戶當前是否需要分頁,會調用pageHelper類的skip方法進行判斷,點進skip方法中,就會知道爲什麼我們只要在執行sql之前加上一句PageHelper.startPage(1,10);就能實現分頁了。在執行完這句話以後,pageHelper會將分頁參數存入一個map中,然後在skip方法中獲取這個map的值,判斷如果存在,那就需要分頁,不存在就放棄分頁。
        當然,爲了保證多線程環境,數據不會錯亂,這個map採用了ThreadLocal方法:

    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

        在這裏我們還會發現一個很奇怪的報錯,skip方法的第一行就直接拋錯:

    if (ms.getId().endsWith(MSUtils.COUNT)) {
        throw new RuntimeException("在系統中發現了多個分頁插件,請檢查系統配置!");
    }

        很奇怪爲什麼sql的名稱不能以_COUNT結尾。這裏先打個問號。
        skip方法裏還有一個優化點,就是允許用戶自定義需要count的列,如果沒配置,那麼最終的count語句就是count(0),就是配置xml的時候加上<property name="countColumn value="id"/>這樣

        如果最終不需要分頁,就調用executor.query執行mybatis本身的功能,就相當於在攔截器裏空跑了一遍
        如果需要分頁,就要判斷是否需要執行count語句了。在使用pageHelper的時候,我們應該都用過這個重載方法PageHelper.startPage(1,10,false),第三個參數默認是true,就是需要進行count操作,獲取本次sql的總數,如果改爲false,就不會執行count語句,只會執行分頁,所以這一點也是我們要注意的。就我的工作經驗而言,對於app端的分頁,由於是那種無限下滑的模式,所以不存在返回總條數的情況,這裏一般都要採用false;而在CMS系統中,前端控件一般都要求返回總條數,所以這裏可以用true。
        在執行count的時候,會首先判斷用戶是否有自定義的count語句,就是在你自己的sqlName後面加上_COUNT,走到這裏,終於知道爲什麼它在skip中第一行就不允許_COUNT結尾的sql,因爲該SQL只是用來給pageHelper被動調用的,不可以主動調用。如果該方法不存在,就會自己創建一個count語句,利用mybatis自帶的MappedStatement.Builder方法構建一個新的sqlSession,最終採用jSqlParser工具類生成一個count語句,就是在頭尾增加一個select count(0) from (原SQL) tmp_count
獲取count總數後會判斷一下該數量是否大於0,只有大於0纔會執行真正的查詢語句。
        在執行完count語句後,會繼續封裝sql,按照startPage傳遞的參數,依然是將原sql最外層套上了limit 0,10這樣的語句實現分頁,最後依然通過mybatis的executor.query方法,將組裝好的sql語句執行,然後將結果封裝成Page對象返回。

        這裏多說兩句,通常所說的pageHelper的性能問題,大部分都是指的這部分,有兩點:

        1、由於我們不知道有“_COUNT”這種寫法的SQL存在,導致每次需要獲取一條sql的總數時,都是pageHelper自動生成的,不管sql有多複雜,它都會在最外層套一個count(0)。我們在生產上抓到的慢查詢語句中,大多是這種,如果只執行原來的複雜sql,只需要81ms,但是套上這個count(0)以後,會急劇下降到6.5s

        2、同樣的,分頁也是原sql外面套一層limit語句,也會導致性能急劇降低。所以在使用pageHelper時,一般都要求sql語句儘量簡單,並且PageHelper.startPage的第三個參數爲false,如果需要爲true,那麼就需要手動增加一個count語句供pageHelper調用;如果不得不寫複雜sql的話,要麼不建議分頁,要麼不建議使用pageHelper,數據量不多的話,一次查出所有數據,然後在內存中自己分頁。

        這樣pageHelper的源碼就讀完了。其實它的內部實現還是蠻複雜的,使用了緩存,支持多種sql語言,採用jsqlparse生成新的sql等等,不過它的基本執行方式我是瞭解了,並且我即將要寫的插件功能並不複雜,瞭解了插件原理即可。下一步,寫一個自定義的mybatis插件進行sql攔截判斷。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章