手把手教你開發 MyBatis 插件

小夥伴們元宵節快樂,記得吃元宵哦~java

在平常開發中,小夥伴們多多少少都有用過 MyBatis 插件,鬆哥猜想你們用的最多的就是 MyBatis 的分頁插件!不知道小夥伴們有沒有想過有一天本身也來開發一個 MyBatis 插件?sql

其實本身動手擼一個 MyBatis 插件並不難,今天鬆哥就把手帶你們擼一個 MyBatis 插件!數據庫

1.MyBatis 插件接口

即便你沒開發過 MyBatis 插件,估計也能猜出來,MyBatis 插件是經過攔截器來起做用的,MyBatis 框架在設計的時候,就已經爲插件的開發預留了相關接口,以下:數組

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
  }

}

這個接口中就三個方法,第一個方法必須實現,後面兩個方法都是可選的。三個方法做用分別以下:mybatis

  1. intercept:這個就是具體的攔截方法,咱們自定義 MyBatis 插件時,通常都須要重寫該方法,咱們插件所完成的工做也都是在該方法中完成的。
  2. plugin:這個方法的參數 target 就是攔截器要攔截的對象,通常來講咱們不須要重寫該方法。Plugin.wrap 方法會自動判斷攔截器的簽名和被攔截對象的接口是否匹配,若是匹配,纔會經過動態代理攔截目標對象。
  3. setProperties:這個方法用來傳遞插件的參數,能夠經過參數來改變插件的行爲。咱們定義好插件以後,須要對插件進行配置,在配置的時候,能夠給插件設置相關屬性,設置的屬性能夠經過該方法獲取到。插件屬性設置像下面這樣:
<plugins>
    <plugin interceptor="org.javaboy.mybatis03.plugin.CamelInterceptor">
        <property name="xxx" value="xxx"/>
    </plugin>
</plugins>

2.MyBatis 攔截器簽名

攔截器定義好了後,攔截誰?app

這個就須要攔截器簽名來完成了!框架

攔截器簽名是一個名爲 @Intercepts 的註解,該註解中能夠經過 @Signature 配置多個簽名。@Signature 註解中則包含三個屬性:ide

  • type: 攔截器須要攔截的接口,有 4 個可選項,分別是:Executor、ParameterHandler、ResultSetHandler 以及 StatementHandler。
  • method: 攔截器所攔截接口中的方法名,也就是前面四個接口中的方法名,接口和方法要對應上。
  • args: 攔截器所攔截方法的參數類型,經過方法名和參數類型能夠鎖定惟一一個方法。

一個簡單的簽名可能像下面這樣:學習

@Intercepts(@Signature(
        type = ResultSetHandler.class,
        method = "handleResultSets",
        args = {Statement.class}
))
public class CamelInterceptor implements Interceptor {
    //...
}

3.被攔截的對象

根據前面的介紹,被攔截的對象主要有以下四個:測試

Executor

public interface Executor {

  ResultHandler NO_RESULT_HANDLER = null;

  int update(MappedStatement ms, Object parameter) throws SQLException;

  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;

  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;

  <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;

  List<BatchResult> flushStatements() throws SQLException;

  void commit(boolean required) throws SQLException;

  void rollback(boolean required) throws SQLException;

  CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql);

  boolean isCached(MappedStatement ms, CacheKey key);

  void clearLocalCache();

  void deferLoad(MappedStatement ms, MetaObject resultObject, String property, CacheKey key, Class<?> targetType);

  Transaction getTransaction();

  void close(boolean forceRollback);

  boolean isClosed();

  void setExecutorWrapper(Executor executor);

}

各方法含義分別以下:

  • update:該方法會在全部的 INSERT、 UPDATE、 DELETE 執行時被調用,若是想要攔截這些操做,能夠經過該方法實現。
  • query:該方法會在 SELECT 查詢方法執行時被調用,方法參數攜帶了不少有用的信息,若是須要獲取,能夠經過該方法實現。
  • queryCursor:當 SELECT 的返回類型是 Cursor 時,該方法會被調用。
  • flushStatements:當 SqlSession 方法調用 flushStatements 方法或執行的接口方法中帶有 @Flush 註解時該方法會被觸發。
  • commit:當 SqlSession 方法調用 commit 方法時該方法會被觸發。
  • rollback:當 SqlSession 方法調用 rollback 方法時該方法會被觸發。
  • getTransaction:當 SqlSession 方法獲取數據庫鏈接時該方法會被觸發。
  • close:該方法在懶加載獲取新的 Executor 後會被觸發。
  • isClosed:該方法在懶加載執行查詢前會被觸發。

ParameterHandler

public interface ParameterHandler {

  Object getParameterObject();

  void setParameters(PreparedStatement ps) throws SQLException;

}

各方法含義分別以下:

  • getParameterObject:在執行存儲過程處理出參的時候該方法會被觸發。
  • setParameters:設置 SQL 參數時該方法會被觸發。

ResultSetHandler

public interface ResultSetHandler {

  <E> List<E> handleResultSets(Statement stmt) throws SQLException;

  <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;

  void handleOutputParameters(CallableStatement cs) throws SQLException;

}

各方法含義分別以下:

  • handleResultSets:該方法會在全部的查詢方法中被觸發(除去返回值類型爲 Cursor<E> 的查詢方法),通常來講,若是咱們想對查詢結果進行二次處理,能夠經過攔截該方法實現。
  • handleCursorResultSets:當查詢方法的返回值類型爲 Cursor<E> 時,該方法會被觸發。
  • handleOutputParameters:使用存儲過程處理出參的時候該方法會被調用。

StatementHandler

public interface StatementHandler {

  Statement prepare(Connection connection, Integer transactionTimeout)
      throws SQLException;

  void parameterize(Statement statement)
      throws SQLException;

  void batch(Statement statement)
      throws SQLException;

  int update(Statement statement)
      throws SQLException;

  <E> List<E> query(Statement statement, ResultHandler resultHandler)
      throws SQLException;

  <E> Cursor<E> queryCursor(Statement statement)
      throws SQLException;

  BoundSql getBoundSql();

  ParameterHandler getParameterHandler();

}

各方法含義分別以下:

  • prepare:該方法在數據庫執行前被觸發。
  • parameterize:該方法在 prepare 方法以後執行,用來處理參數信息。
  • batch:若是 MyBatis 的全劇配置中配置了 defaultExecutorType=」BATCH」,執行數據操做時該方法會被調用。
  • update:更新操做時該方法會被觸發。
  • query:該方法在 SELECT 方法執行時會被觸發。
  • queryCursor:該方法在 SELECT 方法執行時,而且返回值爲 Cursor 時會被觸發。

在開發一個具體的插件時,咱們應當根據本身的需求來決定到底攔截哪一個方法。

4.開發分頁插件

4.1 內存分頁

MyBatis 中提供了一個不太好用的內存分頁功能,就是一次性把全部數據都查詢出來,而後在內存中進行分頁處理,這種分頁方式效率很低,基本上沒啥用,可是若是咱們想要自定義分頁插件,就須要對這種分頁方式有一個簡單瞭解。

內存分頁的使用方式以下,首先在 Mapper 中添加 RowBounds 參數,以下:

public interface UserMapper {
    List<User> getAllUsersByPage(RowBounds rowBounds);
}

而後在 XML 文件中定義相關 SQL:

<select id="getAllUsersByPage" resultType="org.javaboy.mybatis03.model.User">
    select * from user
</select>

能夠看到,在 SQL 定義時,壓根不用管分頁的事情,MyBatis 會查詢到全部的數據,而後在內存中進行分頁處理。

Mapper 中方法的調用方式以下:

@Test
public void test3() {
    UserMapper userMapper = sqlSessionFactory.openSession().getMapper(UserMapper.class);
    RowBounds rowBounds = new RowBounds(1,2);
    List<User> list = userMapper.getAllUsersByPage(rowBounds);
    for (User user : list) {
        System.out.println("user = " + user);
    }
}

構建 RowBounds 時傳入兩個參數,分別是 offset 和 limit,對應分頁 SQL 中的兩個參數。也能夠經過 RowBounds.DEFAULT 的方式構建一個 RowBounds 實例,這種方式構建出來的 RowBounds 實例,offset 爲 0,limit 則爲 Integer.MAX_VALUE,也就至關於不分頁。

這就是 MyBatis 中提供的一個很不實用的內存分頁功能。

瞭解了 MyBatis 自帶的內存分頁以後,接下來咱們就能夠來看看如何自定義分頁插件了。

4.2 自定義分頁插件

首先要聲明一下,這裏鬆哥帶你們自定義 MyBatis 分頁插件,主要是想經過這個東西讓小夥伴們瞭解自定義 MyBatis 插件的一些條條框框,瞭解整個自定義插件的流程,分頁插件並非咱們的目的,自定義分頁插件只是爲了讓你們的學習過程變得有趣一些而已。

接下來咱們就來開啓自定義分頁插件之旅。

首先咱們須要自定義一個 RowBounds,由於 MyBatis 原生的 RowBounds 是內存分頁,而且沒有辦法獲取到總記錄數(通常分頁查詢的時候咱們還須要獲取到總記錄數),因此咱們自定義 PageRowBounds,對原生的 RowBounds 功能進行加強,以下:

public class PageRowBounds extends RowBounds {
    private Long total;

    public PageRowBounds(int offset, int limit) {
        super(offset, limit);
    }

    public PageRowBounds() {
    }

    public Long getTotal() {
        return total;
    }

    public void setTotal(Long total) {
        this.total = total;
    }
}

能夠看到,咱們自定義的 PageRowBounds 中增長了 total 字段,用來保存查詢的總記錄數。

接下來咱們自定義攔截器 PageInterceptor,以下:

@Intercepts(@Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
))
public class PageInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameterObject = args[1];
        RowBounds rowBounds = (RowBounds) args[2];
        if (rowBounds != RowBounds.DEFAULT) {
            Executor executor = (Executor) invocation.getTarget();
            BoundSql boundSql = ms.getBoundSql(parameterObject);
            Field additionalParametersField = BoundSql.class.getDeclaredField("additionalParameters");
            additionalParametersField.setAccessible(true);
            Map<String, Object> additionalParameters = (Map<String, Object>) additionalParametersField.get(boundSql);
            if (rowBounds instanceof PageRowBounds) {
                MappedStatement countMs = newMappedStatement(ms, Long.class);
                CacheKey countKey = executor.createCacheKey(countMs, parameterObject, RowBounds.DEFAULT, boundSql);
                String countSql = "select count(*) from (" + boundSql.getSql() + ") temp";
                BoundSql countBoundSql = new BoundSql(ms.getConfiguration(), countSql, boundSql.getParameterMappings(), parameterObject);
                Set<String> keySet = additionalParameters.keySet();
                for (String key : keySet) {
                    countBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
                }
                List<Object> countQueryResult = executor.query(countMs, parameterObject, RowBounds.DEFAULT, (ResultHandler) args[3], countKey, countBoundSql);
                Long count = (Long) countQueryResult.get(0);
                ((PageRowBounds) rowBounds).setTotal(count);
            }
            CacheKey pageKey = executor.createCacheKey(ms, parameterObject, rowBounds, boundSql);
            pageKey.update("RowBounds");
            String pageSql = boundSql.getSql() + " limit " + rowBounds.getOffset() + "," + rowBounds.getLimit();
            BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameterObject);
            Set<String> keySet = additionalParameters.keySet();
            for (String key : keySet) {
                pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
            }
            List list = executor.query(ms, parameterObject, RowBounds.DEFAULT, (ResultHandler) args[3], pageKey, pageBoundSql);
            return list;
        }
        //不須要分頁,直接返回結果
        return invocation.proceed();
    }

    private MappedStatement newMappedStatement(MappedStatement ms, Class<Long> longClass) {
        MappedStatement.Builder builder = new MappedStatement.Builder(
                ms.getConfiguration(), ms.getId() + "_count", ms.getSqlSource(), ms.getSqlCommandType()
        );
        ResultMap resultMap = new ResultMap.Builder(ms.getConfiguration(), ms.getId(), longClass, new ArrayList<>(0)).build();
        builder.resource(ms.getResource())
                .fetchSize(ms.getFetchSize())
                .statementType(ms.getStatementType())
                .timeout(ms.getTimeout())
                .parameterMap(ms.getParameterMap())
                .resultSetType(ms.getResultSetType())
                .cache(ms.getCache())
                .flushCacheRequired(ms.isFlushCacheRequired())
                .useCache(ms.isUseCache())
                .resultMaps(Arrays.asList(resultMap));
        if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) {
            StringBuilder keyProperties = new StringBuilder();
            for (String keyProperty : ms.getKeyProperties()) {
                keyProperties.append(keyProperty).append(",");
            }
            keyProperties.delete(keyProperties.length() - 1, keyProperties.length());
            builder.keyProperty(keyProperties.toString());
        }
        return builder.build();
    }
}

這是咱們今天定義的核心代碼,涉及到的知識點鬆哥來給你們一個一個剖析。

  1. 首先經過 @Intercepts 註解配置攔截器簽名,從 @Signature 的定義中咱們能夠看到,攔截的是 Executor#query 方法,該方法有一個重載方法,經過 args 指定了方法參數,進而鎖定了重載方法(實際上該方法的另外一個重載方法咱們無法攔截,那個是 MyBatis 內部調用的,這裏不作討論)。
  2. 將查詢操做攔截下來以後,接下來咱們的操做主要在 PageInterceptor#intercept 方法中完成,該方法的參數重包含了攔截對象的諸多信息。
  3. 經過 invocation.getArgs() 獲取攔截方法的參數,獲取到的是一個數組,正常來講這個數組的長度爲 4。數組第一項是一個 MappedStatement,咱們在 Mapper.xml 中定義的各類操做節點和 SQL,都被封裝成一個個的 MappedStatement 對象了;數組第二項就是所攔截方法的具體參數,也就是你在 Mapper 接口中定義的方法參數;數組的第三項是一個 RowBounds 對象,咱們在 Mapper 接口中定義方法時不必定使用了 RowBounds 對象,若是咱們沒有定義 RowBounds 對象,系統會給咱們提供一個默認的 RowBounds.DEFAULT;數組第四項則是一個處理返回值的 ResultHandler。
  4. 接下來判斷上一步提取到的 rowBounds 對象是否不爲 RowBounds.DEFAULT,若是爲 RowBounds.DEFAULT,說明用戶不想分頁;若是不爲 RowBounds.DEFAULT,則說明用戶想要分頁,若是用戶不想分頁,則直接執行最後的 return invocation.proceed();,讓方法繼續往下走就好了。
  5. 若是須要進行分頁,則先從 invocation 對象中取出執行器 Executor、BoundSql 以及經過反射拿出來 BoundSql 中保存的額外參數(若是咱們使用了動態 SQL,可能會存在該參數)。BoundSql 中封裝了咱們執行的 Sql 以及相關的參數。
  6. 接下來判斷 rowBounds 是不是 PageRowBounds 的實例,若是是,說明除了分頁查詢,還想要查詢總記錄數,若是不是,則說明 rowBounds 多是 RowBounds 實例,此時只要分頁便可,不用查詢總記錄數。
  7. 若是須要查詢總記錄數,則首先調用 newMappedStatement 方法構造出一個新的 MappedStatement 對象出來,這個新的 MappedStatement 對象的返回值是 Long 類型的。而後分別建立查詢的 CacheKey、拼接查詢的 countSql,再根據 countSql 構建出 countBoundSql,再將額外參數添加進 countBoundSql 中。最後經過 executor.query 方法完成查詢操做,並將查詢結果賦值給 PageRowBounds 中的 total 屬性。
  8. 接下來進行分頁查詢,有了第七步的介紹以後,分頁查詢就很簡單了,這裏就不細說了,惟一須要強調的是,當咱們啓動了這個分頁插件以後,MyBatis 原生的 RowBounds 內存分頁會變成物理分頁,緣由就在這裏咱們修改了查詢 SQL。
  9. 最後將查詢結果返回。

在前面的代碼中,咱們一共在兩個地方從新組織了 SQL,一個是查詢總記錄數的時候,另外一個則是分頁的時候,都是經過 boundSql.getSql() 獲取到 Mapper.xml 中的 SQL 而後進行改裝,有的小夥伴在 Mapper.xml 中寫 SQL 的時候不注意,結尾可能加上了 ;,這會致使分頁插件從新組裝的 SQL 運行出錯,這點須要注意。鬆哥在 GitHub 上看到的其餘 MyBatis 分頁插件也是同樣的,Mapper.xml 中 SQL 結尾不能有 ;

如此以後,咱們的分頁插件就算是定義成功了。

5.測試

接下來咱們對咱們的分頁插件進行一個簡單測試。

首先咱們須要在全局配置中配置分頁插件,配置方式以下:

<plugins>
    <plugin interceptor="org.javaboy.mybatis03.plugin.PageInterceptor"></plugin>
</plugins>

接下來咱們在 Mapper 中定義查詢接口:

public interface UserMapper {
    List<User> getAllUsersByPage(RowBounds rowBounds);
}

接下來定義 UserMapper.xml,以下:

<select id="getAllUsersByPage" resultType="org.javaboy.mybatis03.model.User">
    select * from user
</select>

最後咱們進行測試:

@Test
public void test3() {
    UserMapper userMapper = sqlSessionFactory.openSession().getMapper(UserMapper.class);
    List<User> list = userMapper.getAllUsersByPage(new RowBounds(1,2));
    for (User user : list) {
        System.out.println("user = " + user);
    }
}

這裏在查詢時,咱們使用了 RowBounds 對象,就只會進行分頁,而不會統計總記錄數。須要注意的時,此時的分頁已經不是內存分頁,而是物理分頁了,這點咱們從打印出來的 SQL 中也能看到,以下:

能夠看到,查詢的時候就已經進行了分頁了。

固然,咱們也可使用 PageRowBounds 進行測試,以下:

@Test
public void test4() {
    UserMapper userMapper = sqlSessionFactory.openSession().getMapper(UserMapper.class);
    PageRowBounds pageRowBounds = new PageRowBounds(1, 2);
    List<User> list = userMapper.getAllUsersByPage(pageRowBounds);
    for (User user : list) {
        System.out.println("user = " + user);
    }
    System.out.println("pageRowBounds.getTotal() = " + pageRowBounds.getTotal());
}

此時經過 pageRowBounds.getTotal() 方法咱們就能夠獲取到總記錄數。

6.小結

好啦,今天主要和小夥伴們分享了咱們如何本身開發一個 MyBatis 插件,插件功能其實都是次要的,最主要是但願小夥伴們可以理解 MyBatis 的工做流程。

相關文章
相關標籤/搜索