Mybatis 攔截器 及 PageHelper分析

Mybatis 提供了 插件 的機制,使得開發者可以侵入 Mybatis 工作流程,讀完前幾篇文章,相信大家已經對於 Mybatis,已經有了大致介紹了認識。

本文將從以下幾個問題出發:

  1. Mybatis 可以實現哪幾種攔截器?
  2. Mybatis 中攔截器的使用。
  3. 這幾種攔截器是如何工作的?
  4. PageHelper 怎麼用的?
  5. PageHelper 如何基於攔截器進行工作的?

用法

由前面文章分析可知,使用Mybatis ,有下面幾個流程:

  1. 構建SqlSession
  2. 填充參數
  3. 執行查詢
  4. 封裝結果

而對於Mybatis 的攔截器,就是可以利用 提供的攔截器機制對 這四個過程做文章,即攔截這幾個過程,實現自己代碼邏輯。

  1. 在 xml 中配置:
    <plugins>
        <plugin interceptor="anla.learn.mybatis.interceptor.config.SqlStatementHandlerInterceptor">
            <property name="dialect" value="mysql"/>
        </plugin>
    </plugins>
  1. 使用類實現 org.apache.ibatis.plugin.Interceptor,並使用相應註解說明攔截的類別:
// 說明攔截器是 StatementHandler,攔截方法爲 prepare
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class SqlStatementHandlerInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = statementHandler.getBoundSql();
        String sql = boundSql.getSql();
        log.info("mybatis intercept sql:{}", sql);
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        String dialect = properties.getProperty("dialect");
        log.info("mybatis intercept dialect:{}", dialect);
    }
}

上面代碼有以下要點:

  1. 增加 plugin 標籤
  2. 實現 Interceptor,並增加 @Intercepts 註解,並使用 @Signature 說明攔截類型
    @Signature 有以下可選參數
  • type:只攔截器類型,可選 ExecutorResultSetHandlerStatementHandlerParameterHandler
  • method:指的是 上面四個類型裏面的方法,當然不同的類可以有不同。
  • args:指 攔截的方法,裏面的參數類型。
    上述簡單攔截器在 setProperties 中,設置了相關方言並打印,而主 攔截器 intercept 方法 僅僅打印sql,而後執行 invocation.proceed(); 繼續執行Mybatis 自有邏輯。
    具體可攔截對象如下:

Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
ParameterHandler (getParameterObject, setParameters)
ResultSetHandler (handleResultSets, handleOutputParameters)
StatementHandler (prepare, parameterize, batch, update, query)

具體使用例子可以看博主項目例子:https://github.com/anLA7856/mybatislearn

攔截器分析

下面來具體分析攔截器原理,先看看 Interceptor 定義:

public interface Interceptor {
  Object intercept(Invocation invocation) throws Throwable;
  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }
  default void setProperties(Properties properties) {
  }
}

Interceptor 本身只是 一個接口,提供了 三個方法 interceptpluginsetProperties

  • intercept : 攔截過濾器,對於被攔截方法,都會先執行intercept,然後再會執行確定方法
  • plugin:提供了默認實現方法,主要是對下一層過濾器或者具體攔截對象進一步封裝
  • setProperties:設置屬性,即 <plugin> 標籤中 <property> 子標籤

分析一個類原理,首先從該類初始化,而後再從其調用上來分析。
本文將從以下幾個點分析攔截器:

  1. 攔截器初始化:攔截器何時被Mybatis 加載
  2. 四種攔截器使用點:具體攔截器以怎樣方式被初始化並調用?

攔截器初始化

當 Mybatis 機制被加載時,<plugins> 節點內容會被加載並放到 Configuration 中,具體就是加載到 變量 InterceptorChain 中:
protected final InterceptorChain interceptorChain = new InterceptorChain();
XMLConfigBuilderpluginElement 方法將xml中配置的插件加載到 InterceptorChain

  private void pluginElement(XNode parent) throws Exception {
    if (parent != null) {
      for (XNode child : parent.getChildren()) {
        String interceptor = child.getStringAttribute("interceptor");
        Properties properties = child.getChildrenAsProperties();
        Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).newInstance();
        interceptorInstance.setProperties(properties);
        configuration.addInterceptor(interceptorInstance);
      }
    }
  }

Mybatis 進行插件各個流程時,會執行 interceptorChain.pluginAll 對組件進行進一步封裝:
在這裏插入圖片描述
所以在 四個組件初始化時候,進行一步封裝,下面看看pluginAll方法:

  public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
      target = interceptor.plugin(target);
    }
    return target;
  }

將 傳入的 組件執行其 interceptor.plugin ,而 基本上開發者不用重寫這個方法,它在 Interceptor 中有默認的實現,主要目的是 使用 interceptor 包裝一層 target 返回一個代理對象:

  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }

wrap 方法

  public static Object wrap(Object target, Interceptor interceptor) {
  // 獲取註解中的方法以及配置了的攔截簽名
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    // 獲取目標類
    Class<?> type = target.getClass();
    // 獲取所有的 涉及到的 攔截器 接口名
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    if (interfaces.length > 0) {
      return Proxy.newProxyInstance(
          type.getClassLoader(),
          interfaces,
          new Plugin(target, interceptor, signatureMap));
    }
	// 返回生成的代理對象
    return target;
  }

最後一個 return Proxy.newProxyInstance(type.getClassLoader(),interfaces,new Plugin(target, interceptor, signatureMap));
返回一個代理類,由於 PluginInvocationHandler 的子類, 最後每當方法調用時,都會經過其invoke 方法。

實際上 , invoke 方法會攔截對應代理對象所有方法,但是會通過傳入的 signatureMap 進行一層過濾,只有註解配置過得方法纔會被攔截:

  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      Set<Method> methods = signatureMap.get(method.getDeclaringClass());
      if (methods != null && methods.contains(method)) {
      // 是 signaltureMap 中方法,纔會執行 intercept 方法
        return interceptor.intercept(new Invocation(target, method, args));
      }
	// 如果不是,則會直接 執行對應方法
      return method.invoke(target, args);
    } catch (Exception e) {
      throw ExceptionUtil.unwrapThrowable(e);
    }
  }

對於 過濾器鏈裝載順序,則是以 的方式進行組裝:
例如如果有如下相同的 基於 Executor 的攔截器

<plugins>
    <plugin interceptor="com.anla.learn.ExecutorQueryInterceptor1"/>
    <plugin interceptor="com.anla.learn.ExecutorQueryInterceptor2"/>
    <plugin interceptor="com.anla.learn.ExecutorQueryInterceptor3"/>
</plugins>

通過 interceptorChain.pluginAll 方法之後,代理結構如下:

Interceptor3:{
    Interceptor2: {
        Interceptor1: {
            target: Executor
        }
    }
}

而最終執行 則是按照 3>2>1>Executor>1>2>3 順序執行,類似於遞歸式執行。

攔截器調用點

其實攔截器的調用點很多,因爲Mybatis 內置組件的每一個方法都可以是調用點,只要配置了攔截方法。
所以起始只需要瞭解四個內置組件的使用順序,這樣 當組件使用時,就是攔截器被觸發是:

  1. Executor 包裝時,executor = (Executor) interceptorChain.pluginAll(executor),而後和 SqlSession 一併返回
  2. 在攔截器獲取 StatementHandler時,同樣會通過 statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler),進行一層包裝,而後StatementHandler 每一個方法都會進行過濾。
  3. ParameterHandler 初始化,同樣會有過濾器 parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
  4. 以及 最後結果集 resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);

對於 攔截器方法和 Mybatis 內置方法,是會優先執行 攔截器方法,即你可以只執行攔截器,而不執行 Mybatis 方法,
正如 Plugininvoke 方法:

  if (methods != null && methods.contains(method)) {
      // 是 signaltureMap 中方法,纔會執行 intercept 方法
        return interceptor.intercept(new Invocation(target, method, args));
      }
	// 如果不是,則會直接 執行對應方法
	 return method.invoke(target, args);

當然,也可以執行完攔截器後,繼續執行 Mybatis 正常邏輯流程:
return invocation.proceed();
而 invocation 爲調用時 傳入 的 Invocation

return interceptor.intercept(new Invocation(target, method, args));

實際上就是調用 method.invoke 即包裝類的目標方法。

PageHelper

PageHelper 是國人寫的一個優秀的Mybatis分頁插件 ,
簡介:https://github.com/pagehelper/Mybatis-PageHelper

例如 通過下面兩句即可輕鬆完成分頁:

        // 設置當前上下文
        PageHelper.startPage(1, 10);
        List<User> list = mapper.getAllUsers();

比如一個查詢,你並不需要每次查詢都要寫一個 countpage 方法,對於 PageHelper 來說,只需要寫一個page 方法即可,PageHelper 會自動幫你完成一次 count 查詢,當查詢出來的 count 有值時,纔會進行第二步的page 操作。

具體例子可以看博主Test:https://github.com/anLA7856/mybatislearn/blob/master/mybatis-interceptor/src/test/java/MybatisPageHelperTest.java

PageHelper 邏輯性原理比較簡單,相信大家看了 PageHelper 測試例子後,估計也就能懂了個大半。
那麼現在就是看看PageHelper 原理

QueryInterceptor

PageHelper 所有騷操作起點都是 PageInterceptor ,它負責攔截 Executorquery 方法:

@Intercepts(
        {
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
        }
)
public class PageInterceptor implements Interceptor {
	...
}

由於 不同數據庫的分頁語句不一樣,所以 PageHelper 中存在 Dialect (數據庫方言)概念,這個選取是從 jdbc url 中獲取:

jdbc:mysql://127.0.0.1/df?useUnicode=true

例如以上 數據庫就是 mysql,這樣一來就可以使用 Mysql 方式進行分頁。

下面主要看看 PageInterceptorintercept 方法:

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        try {
        // 從 invocation 中獲取參數,攔截的方法有幾個參數,就會獲取幾個參數
            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;
            // 判斷參數個數,從而 獲取 boundSql 和 cacheKey
            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];
            }
            // 判斷方言是否存在,即是否配置了 dialect 中知道數據庫類型
            checkDialectExists();

            List resultList;
            //調用方法判斷是否需要進行分頁,如果不需要,直接返回結果,其中包括從MappedStatement中獲取數據庫方言類型
            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);
        } finally {
            if(dialect != null){
                dialect.afterAll();
            }
        }
    }

以上邏輯有以下邏輯:

  1. 通過不同的攔截方法,從而定位到 invocation.getArgs 中 參數個數,再通過 數組索引方式獲取參數。
  2. 攔截器初始配置時,會嘗試去尋找 properties 節點下dialect 配置,如果 有配置則將 dialect 設置爲對應方言節點。
  3. 如果沒有找到 dialect 配置,則會默認使用 com.github.pagehelper.PageHelper 來生成初始化 的 dialect 。
    PageHelper 類其實類似一個操作類,作爲一個裝飾器模式 + 門面模式,裏面 維護分頁參數以及分頁方言對象 PageAutoDialect
    所有操作都可以基於 PageHelper 進行,而裏面實際調用則是調用 PageAutoDialect 方法。
    在這裏插入圖片描述
    上面兩個子類節點分別代表使用 PageHelper 進行分頁,還是使用 Mybatis 中自帶 RowBounds 進行分頁
  4. !dialect.skip(ms, parameter, rowBounds) 中 會判斷是否需要分頁,這個方法只有在 PageHelper 有有效實現,其他兩個僅給出默認實現,而 PageHelper 中 skip 邏輯,就是獲取當前線程的設置的分頁參數,如果有設置,則返回 false,進行分頁。
    PageMethod 作爲基礎分頁方法,裏面維護這一個 ThreadLocal<Page> LOCAL_PAGE 代表當前線程分頁參數。
    在這裏插入圖片描述
    另外,在 skip 中,會 嘗試去初始化 PageAutoDialect 中維護的 具體 方言 delegate,會嘗試從 MapperStatement 的 url中去尋找,最終通過 PageAutoDialect 維護的 Map<String, Class<? extends Dialect>> 加載出不同的類實例。
  5. 如果有有分頁,那麼就嘗試首先查詢初 count(0) 數量,有數量則進行 具體分頁查詢。在 count(executor, ms, parameter, rowBounds, resultHandler, boundSql); 中,會首先判斷當前 mapper 中是否有count 類型的查詢,如果有則直接調用該查詢返回。
    如果沒有,則會新建一個_COUNT 結尾的查詢,最後執行查詢並返回。
    private Long count(Executor executor, MappedStatement ms, Object parameter,
                       RowBounds rowBounds, ResultHandler resultHandler,
                       BoundSql boundSql) throws SQLException {
        String countMsId = ms.getId() + countSuffix;
        Long count;
        //先判斷是否存在手寫的 count 查詢
        MappedStatement countMs = ExecutorUtil.getExistedMappedStatement(ms.getConfiguration(), countMsId);
        if (countMs != null) {
            count = ExecutorUtil.executeManualCount(executor, countMs, parameter, boundSql, resultHandler);
        } else {
            countMs = msCountMap.get(countMsId);
            //自動創建
            if (countMs == null) {
                //根據當前的 ms 創建一個返回值爲 Long 類型的 ms
                countMs = MSUtils.newCountMappedStatement(ms, countMsId);
                msCountMap.put(countMsId, countMs);
            }
            count = ExecutorUtil.executeAutoCount(dialect, executor, countMs, parameter, boundSql, rowBounds, resultHandler);
        }
        return count;
    }
  1. 通過 dialect.afterCount 判斷是否需要返回,如果爲 0 時候,則直接返回查詢結果,不進行下一次具體分頁查詢。
  2. 如果需要分頁,且否則直接執行 ExecutorUtil.pageQuery(dialect, executor,ms, parameter, rowBounds, resultHandler, boundSql, cacheKey); 返回分頁查詢結果。
  3. 如果最終配置的不需要非呢也,則直接調用 Mybatisexecutor.query 進行下面查詢操作。
  4. 在本次查詢完後,會執行 dialect.afterPage(resultList, parameter, rowBounds); 用於清除當次查詢遺留的本地線程信息。

總結

PageHelper 是一款優秀的分頁插件,我們可以不用去編寫 多餘的 count 查詢以及count 判斷,也不用考慮不同數據庫分頁之間差別,這些 PageHelper 都可以幫我們解決。
另外,PageInterceptor 在 intercept 最後,並沒有調用 invocation.proceed ,實際上就是 走完這個intercept方法,就會返回結果,但是實際上,PageInterceptor 裏面查詢邏輯,都是通過 Invocation 中傳遞過來參數,進行對Mybatis 流程調用,是使用的 executor.query,所以是很好的從Mybatis 插件切入,並且再一次無縫對接 入 Mybatis 的。

但是缺點就是學習成本以及對業務的侵入性,開發者往往不願意去接納一個不廣泛的框架,而更願意根據業務去造一個輪子。

後話

總體來說, Mybatis 攔截器 是 Mybatis 提供給開發者侵入 Mybatis 內部執行邏輯的方法,如果操作不當,侵入後可能會影響其本身邏輯。當然這就是見仁見智了。

覺得博主寫的有用,不妨關注博主公衆號: 六點A君。
哈哈哈,一起研究Mybatis:
在這裏插入圖片描述

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