假裝是小白之重學MyBatis(二)

北冥有隻魚發表於2022-05-07

前言

本篇我們來介紹MyBatis外掛的開發,這個也是來源於我之前的一個面試經歷,面試官為我如何統計Dao層的慢SQL,我當時的回答是藉助於Spring的AOP機制,攔截Dao層所有的方法,但面試官又問,這事實上不完全是SQL的執行時間,這其中還有其他程式碼的時間,問我還有其他思路嗎? 我想了想說沒有,面試官接著問,有接觸過MyBatis外掛的開發嗎? 我說沒接觸過。 但後面也給我過了,我認為這個問題是有價值的問題,所以也放在了我的學習計劃中。

看本篇之前建議先看:

  • 《代理模式-AOP緒論》
  • 《假裝是小白之重學MyBatis(一)》

如果有人問上面兩篇文章在哪裡可以找的到,可以去掘金或者思否翻翻,目前公眾號還沒有,預計年中會將三個平臺的文章統一一下。

概述

翻閱官方文件的話,MyBatis並沒有給處外掛的具體定義,但基本上還是攔截器,MyBatis的外掛就是一些能夠攔截某些MyBats核心元件方法,增強功能的攔截器。官方文件中列出了四種可供增強的切入點:

  • Executor
執行SQL的核心元件。攔截Executor 意味著要干擾或增強底層執行的CRUD操作
  • ParameterHandler
攔截該ParameterHandler,意味著要干擾SQL引數注入、讀取的動作。
  • ResultSetHandler
攔截該ParameterHandler, 要干擾/增強封裝結果集的動作
  • StatementHandler
攔截StatementHandler ,則意味著要干擾/增強Statement的建立和執行的動作

當然還是從HelloWorld開始

要做MyBatis的外掛,首先要實現MyBatis的Interceptor 介面 , 注意類不要導錯了,Interceptor很搶手,該類位於org.apache.ibatis.plugin.Interceptor下。實現該介面,MyBatis會將該實現類當作MyBatis的攔截器,那攔截哪些方法,該怎麼指定呢? 通過@Intercepts註解來實現,下面是使用示例:

@Intercepts(@Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class MyBatisPluginDemo implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("into invocation ..........");
        System.out.println(invocation.getTarget());
        System.out.println(invocation.getMethod().getName());
        System.out.println(Arrays.toString(invocation.getArgs()));
        return invocation.proceed();
    }
}
@Intercepts可以填多個@Signature,@Signature是方法簽名,type用於定位類,method定位方法名,args用於指定方法的引數型別。三者加在一起就可以定位到具體的方法。注意寫完還需要將此外掛註冊到MyBatis的配置檔案中,讓MyBatis載入該外掛。

注意這個標籤一定要放在environments上面,MyBatis嚴格限制住了標籤的順序。

<plugins>
    <plugin interceptor="org.example.mybatis.MyBatisPluginDemo"></plugin>
</plugins>

我們來看下執行結果:

MyBatis外掛

效能分析外掛走起

那攔截誰呢? 目前也只有Executor 和StatementHandler 供我們選擇,我們本身是要看SQL耗時,Executor 離SQL執行還有些遠,一層套一層才走到SQL執行,MyBatis中標籤的執行過程在《MyBatis原始碼學習筆記(一) 初遇篇》已經講述過了,這裡不再贅述,目前來看StatementHandler 是離SQL最近的, 它的實現類就直接走到JDBC了,所以我們攔截StatementHandler ,那有的插入插了很多值,我們要不要攔截,當然也要攔截, 我們的外掛方法如下:

@Intercepts({@Signature(type = StatementHandler.class, method = "query",
        args = {Statement.class, ResultHandler.class}), @Signature(type = StatementHandler.class,method =  "update" ,args = Statement.class )})
public class MyBatisSlowSqlPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("-----開始進入效能分析外掛中----");
        long startTime = System.currentTimeMillis();
        Object result = invocation.proceed();
        long endTime = System.currentTimeMillis();
       // query方法入參是statement,所以我們可以將其轉為Statement
        if (endTime - startTime > 1000){

        }
        return result;
    }
}

那對應的SQL該怎麼拿? 我們還是到StatementHandler去看下:

MyBatis的StementHandler

我們還是得通過Statement這個入參來拿, 我們試試看, 你會發現在日誌級別為DEBUG之上,會輸出SQL,像下面這樣:

日誌級別為INFO

如果日誌級別為DEBUG輸出會是下面這樣:

日誌級別為DEBUG

這是為什麼呢? 如果看過《MyBatis原始碼學習筆記(一) 初遇篇》這篇的可能會想到,MyBatis架構中的日誌模組,為了接入日誌框架,就會用到代理,那麼這個肯定就是代理類,我們打斷點來驗證一下我們的想法:

Statement概述

代理分析

我原本的想法是PreparedStatementLogger的代理類,仔細一想,感覺不對,感覺自己還是對代理模式瞭解不大透,於是我就又把之前的文章《代理模式-AOP緒論》看了一下,動態代理模式的目標:

  • 我們有一批類,然後我們想在不改變它們的基礎之上,增強它們, 我們還希望只著眼於編寫增強目標物件程式碼的編寫。
  • 我們還希望由程式來編寫這些類,而不是由程式設計師來編寫,因為太多了。

在《代理模式-AOP緒論》中我們做的是很簡單的代理:

public interface IRentHouse {
    void rentHouse();
    void study();
}
public class RentHouse implements IRentHouse{
    @Override
    public void rentHouse() {
        System.out.println("sayHello.....");
    }
    @Override
    public void study() {
        System.out.println("say Study");
    }
}

我們現在的需求是增強IRentHouse中的方法,用靜態代理就是為IRentHouse再做一個實現類,相當於在RentHouse上再包裝一層。但如果我有很多想增強的類呢,這樣去包裝,事實上對程式碼的侵入性是很大的。對於這種狀況,我們最終的選擇是動態代理,在執行時產生介面實現類的代理類,我們最終產生代理物件的方法是:

/**
   * @param target 為需要增強的類
   * @return 返回的物件在呼叫介面中的任意方法都會走到Lambda回撥中。
*/
private static  Object getProxy(Object  target){
        Object proxy = Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy1, method, args) -> {
            System.out.println("方法開始執行..........");
            Object obj = method.invoke(target, args);
            System.out.println("方法執行結束..........");
            return obj;
        });
        return proxy;
  }

接下來我們來看下MyBatis是怎麼包裝的,我們還是從PreparedStatementLogger開始看:

PreparedStatementLogger

InvocationHandler是動態代理的介面,BaseJdbcLogger這個先不關注。值得關注的是:

public static PreparedStatement newInstance(PreparedStatement stmt, Log statementLog, int queryStack) {
  InvocationHandler handler = new PreparedStatementLogger(stmt, statementLog, queryStack);
  ClassLoader cl = PreparedStatement.class.getClassLoader();
  return (PreparedStatement) Proxy.newProxyInstance(cl, new Class[]{PreparedStatement.class, CallableStatement.class}, handler);
}

可能有同學會問newProxyInstance為什麼給了兩個引數, 因為CallableStatement繼承了PreparedStatement。 這裡是一層,事實上還能點出來另外一層,在ConnectionLogger的回撥中(ConnectionLogger也實現了InvocationHandler,所以這個也是個代理回撥類),ConnectionLogger的例項化在BaseExecutor這個類裡面完成,如果你還能回憶JDBC產生SQL的話,當時的流程事實上是這樣的:

    public static boolean execute(String sql, Object... param) throws Exception {
        boolean result = true;
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        try {
            //獲取資料庫連線
            connection = getConnection();
            connection.setAutoCommit(false);
            preparedStatement = connection.prepareStatement(sql);
            // 設定引數 
            for (int i = 0; i < param.length; i++) {
                preparedStatement.setObject(i, param[i]);
                preparedStatement.addBatch();
            }
            preparedStatement.executeBatch();
            //提交事務
            connection.commit();
        } catch (SQLException e) {
            e.printStackTrace();
            if (connection != null) {
                try {
                    connection.rollback();
                } catch (SQLException ex) {
                    ex.printStackTrace();
                    // 日誌記錄事務回滾失敗
                    result = false;
                    return result;
                }
            }
            result = false;
        } finally {
            close(preparedStatement, connection);
        }
        return result;
    }

我們來捋一下,ConnectionLogger是讀Connection的代理,但是Connection介面中有許多方法, 所以ConnectionLogger在回撥的時候做了判斷:

@Override
public Object invoke(Object proxy, Method method, Object[] params)
    throws Throwable {
  try {
    if (Object.class.equals(method.getDeclaringClass())) {
      return method.invoke(this, params);
    }
    if ("prepareStatement".equals(method.getName()) || "prepareCall".equals(method.getName())) {
      if (isDebugEnabled()) {
        debug(" Preparing: " + removeExtraWhitespace((String) params[0]), true);
      }
      // Connection 的prepareStatement方法、prepareCall會產生PreparedStatement
      PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
      // 然後PreparedStatementLogger產生的還是stmt的代理類
      // 我們在plugin中拿到的就是  
      stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
      return stmt;
    } else if ("createStatement".equals(method.getName())) {
      Statement stmt = (Statement) method.invoke(connection, params);
      stmt = StatementLogger.newInstance(stmt, statementLog, queryStack);
      return stmt;
    } else {
      return method.invoke(connection, params);
    }
  } catch (Throwable t) {
    throw ExceptionUtil.unwrapThrowable(t);
  }
}

SQL執行鏈

PreparedStatementLogger是回撥類,這個PreparedStatementLogger有對應的Statement,我們通過Statement就可以拿到對應的SQL。那回撥類和代理類是什麼關係呢, 我們來看下Proxy類的大致構造:

代理類是如何產生的

所以我最初的想法是JDK為我們產生的類裡面有回撥類例項這個物件會有InvocationHandler成員變數,但是如果你用getClass().getDeclaredField("h")去獲取發現獲取不到,那麼代理類就沒有這個回撥類例項,那我們研究一下getProxyClass0這個方法:

private static Class<?> getProxyClass0(ClassLoader loader,
                                       Class<?>... interfaces) {
    if (interfaces.length > 65535) {
        throw new IllegalArgumentException("interface limit exceeded");
    }

    // If the proxy class defined by the given loader implementing
    // the given interfaces exists, this will simply return the cached copy;
    // otherwise, it will create the proxy class via the ProxyClassFactory
    // proxyClassCache 是 new WeakCache<>(new KeyFactory(), new ProxyClassFactory()) 的例項
    // 最終會呼叫ProxyClassFactory的apply方法。
    // 在ProxyClassFactory的apply方法中有 ProxyGenerator.generateProxyClass() 
    // 答案就在其中,最後呼叫的是ProxyGenerator的generateClassFile方法
    // 中產生代理類時,讓代理類繼承Proxy類。
    return proxyClassCache.get(loader, interfaces);
}

動態代理類淺析

所以破案了,在Proxy裡的InvocationHandler是protected,所以我們取變數應當這麼取:

@Intercepts({@Signature(type = StatementHandler.class, method = "query",
        args = {Statement.class, ResultHandler.class}), @Signature(type = StatementHandler.class,method =  "update" ,args = Statement.class )})
public class MyBatisSlowSqlPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("-----開始進入效能分析外掛中----");
        long startTime = System.currentTimeMillis();
        Object result = invocation.proceed();
        long endTime = System.currentTimeMillis();
       // query方法入參是statement,所以我們可以將其轉為Statement
        Statement statement = (Statement)invocation.getArgs()[0];
        if (Proxy.isProxyClass(statement.getClass())){
            Class<?> statementClass = statement.getClass().getSuperclass();
            Field targetField = statementClass.getDeclaredField("h");
            targetField.setAccessible(true);
            PreparedStatementLogger  loggerStatement  = (PreparedStatementLogger) targetField.get(statement);
            PreparedStatement preparedStatement = loggerStatement.getPreparedStatement();
            if (endTime - startTime > 1){
                System.out.println(preparedStatement.toString());
            }
        }else {
            if (endTime - startTime > 1){
                System.out.println(statement.toString());
            }
        }
        return result;
    }
}

最後輸出如下:

慢SQL監控

但是這個外掛還不是那麼完美,就是這個慢SQL查詢時間了,我們現在是寫死的

這兩個問題在MyBatis 裡面都可以得到解決,我們可以看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) {
    // NOP
  }
}

setProperties用於從配置檔案中取值, plugin將當前外掛加入,intercept是真正增強方法。那上面的兩個問題已經被解決了:

  • 硬編碼

首先在配置檔案裡面配置

 <plugins>
        <plugin interceptor="org.example.mybatis.MyBatisSlowSqlPlugin">
            <property name = "maxTolerate" value = "10"/>
        </plugin>
 </plugins>

然後重寫:

@Override
public void setProperties(Properties properties) {
    //maxTolerate 是MyBatisSlowSqlPlugin的成員變數
    this.maxTolerate = Long.parseLong(properties.getProperty("maxTolerate"));
}

回憶一下JDBC我們執行SQl事實上有兩種方式:

  • Connection中的prepareStatement方法
  • Connection中的createStatement

在MyBatis中這兩種方法對應不同的StatementType, 上面的PreparedStatementLogger對應 Connection中的prepareStatement方法, 如果說你在MyBatis中將語句宣告為Statement,則我們的SQL監控語句就會出錯,所以這裡我們還需要在單獨適配一下Statement語句型別。

@Intercepts({@Signature(type = StatementHandler.class, method = "query",
        args = {Statement.class, ResultHandler.class}), @Signature(type = StatementHandler.class,method =  "update" ,args = Statement.class )})
public class MyBatisSlowSqlPlugin implements Interceptor {

    private  long  maxTolerate;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("-----開始進入效能分析外掛中----");
        long startTime = System.currentTimeMillis();
        Object result = invocation.proceed();
        SystemMetaObject
        long endTime = System.currentTimeMillis();
       // query方法入參是statement,所以我們可以將其轉為Statement
        Statement statement = (Statement)invocation.getArgs()[0];
        if (Proxy.isProxyClass(statement.getClass())){
            Class<?> statementClass = statement.getClass().getSuperclass();
            Field targetField = statementClass.getDeclaredField("h");
            targetField.setAccessible(true);
            Object object = targetField.get(statement);
            if (object instanceof PreparedStatementLogger) {
                PreparedStatementLogger  loggerStatement  = (PreparedStatementLogger) targetField.get(statement);
                PreparedStatement preparedStatement = loggerStatement.getPreparedStatement();
                if (endTime - startTime > maxTolerate){
                    System.out.println(preparedStatement.toString());
                }
            }else {
                // target 是對應的語句處理器
                // 為什麼不反射拿? Statement 對應的實現類未重寫toString方法
                // 但是在RoutingStatementHandler 中提供了getBoundSql方法
                RoutingStatementHandler handler = (RoutingStatementHandler) invocation.getTarget();
                BoundSql boundSql = handler.getBoundSql();
                if (endTime - startTime > maxTolerate){
                    System.out.println(boundSql);
                }
            }
        }else {
            if (endTime - startTime > maxTolerate){
                System.out.println(statement.toString());
            }
        }
        return result;
    }

    @Override
    public void setProperties(Properties properties) {
        this.maxTolerate = Long.parseLong(properties.getProperty("maxTolerate"));
    }
}

事實上MyBatis裡面寫好了反射工具類,這個就是SystemMetaObject,用法示例如下:

@Intercepts({@Signature(type = StatementHandler.class, method = "query",
        args = {Statement.class, ResultHandler.class}), @Signature(type = StatementHandler.class,method =  "update" ,args = Statement.class )})
public class MyBatisSlowSqlPlugin implements Interceptor {

    private  long  maxTolerate;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("-----開始進入效能分析外掛中----");
        long startTime = System.currentTimeMillis();
        Object result = invocation.proceed();
        long endTime = System.currentTimeMillis();
       // query方法入參是statement,所以我們可以將其轉為Statement
        Statement statement = (Statement)invocation.getArgs()[0];
        MetaObject metaObject = SystemMetaObject.forObject(statement);
        if (Proxy.isProxyClass(statement.getClass())){
            Object object = metaObject.getValue("h");
            if (object instanceof PreparedStatementLogger) {
                PreparedStatementLogger  loggerStatement  = (PreparedStatementLogger) object;
                PreparedStatement preparedStatement = loggerStatement.getPreparedStatement();
                if (endTime - startTime > maxTolerate){
                    System.out.println(preparedStatement.toString());
                }
            }else {
                // target 是對應的語句處理器
                // 為什麼不反射拿? Statement 對應的實現類未重寫toString方法
                // 但是在RoutingStatementHandler 中提供了getBoundSql方法
                RoutingStatementHandler handler = (RoutingStatementHandler) invocation.getTarget();
                BoundSql boundSql = handler.getBoundSql();
                if (endTime - startTime > maxTolerate){
                    System.out.println(boundSql);
                }
            }
        }else {
            if (endTime - startTime > maxTolerate){
                System.out.println(statement.toString());
            }
        }
        return result;
    }

    @Override
    public void setProperties(Properties properties) {
        this.maxTolerate = Long.parseLong(properties.getProperty("maxTolerate"));
    }
}

那我有多個外掛,如何指定順序呢? 在配置檔案中指定,從上往下依次執行

 <plugins>
        <plugin interceptor="org.example.mybatis.MyBatisSlowSqlPlugin01">
            <property name = "maxTolerate" value = "10"/>
        </plugin> 
     <plugin interceptor="org.example.mybatis.MyBatisSlowSqlPlugin02">
            <property name = "maxTolerate" value = "10"/>
        </plugin> 
</plugins>

如上面所配置執行順序就是MyBatisSlowSqlPlugin01、MyBatisSlowSqlPlugin02。 外掛的幾個方法執行順序呢

執行順序

寫在最後

感慨頗深,原本預計兩個小時就能寫完的,然後寫了一下午,頗有種學海無涯的感覺。

參考資料

相關文章