MyBatis-編寫自定義分頁插件

 一、基礎知識

本文測試和源碼分析參考版本: Mybatis-version:3.5.5
本文相關測試源程式碼://github.com/wuya11/mybatis_demo

1.1 參考方向

自定義實現分頁插件,參考方向如下:
  1. 編寫一個分頁(Page)基礎對象;
  2. 基於插件原理,自定義一個分頁攔截插件;
  3. 基於攔截器,獲取BoundSql對象 ,獲取動態生成的SQL語句以及相應的參數資訊;
  4. 根據參數資訊,判斷是否需要分頁查詢;
  5. 生成統計總數的sql,並查詢出總條數;
  6. 更新BoundSql對象的數據,設置查詢明細sql,加上分頁標識;
  7. 寫好的分頁插件配置到MyBatis中;

1.2 思考維度

  1. 生成統計總數語句時,如何保證select count(1)的性能更好;參考方向:詳解分頁組件中查count總記錄優化
  2. 當查詢出統計總數為零時,有何優雅的辦法,不再去查詢一次明細資訊;

二、編碼實現

2.1 創建Page對象

  1. 設置常用分頁的基礎屬性欄位;
  2. 當不想使用框架默認的自動分頁,設置一個可變參數autoCount,可單獨查詢總數,查詢明細組合處理。

/**
 * 分頁類
 *
 * @author wl
 */
@Data
public class Page implements Serializable {
    /**
     * 每頁顯示數量
     */
    @JsonProperty("per_page")
    private int pageSize;
    /**
     * 當前頁碼
     */
    @JsonProperty("current_page")
    private int curPage;
    /**
     * 總頁數
     */
    @JsonProperty("total_pages")
    private int pages;
    /**
     * 總記錄數
     */
    private int total;
    /**
     * 當前頁數量
     */
    private int count;
    /**
     * 鏈接
     */
    private Link links;

    /**
     * 自動統計分頁總數
     */
    private boolean autoCount;

    /**
     * 默認無參構造器,初始化各值
     */
    public Page() {
        this.pageSize = 20;
        this.curPage = 1;
        this.pages = 0;
        this.total = 0;
        this.count = 0;
        this.autoCount = true;
    }

    public Page(Page page) {
        this.pageSize = page.pageSize;
        this.curPage = page.curPage;
        this.pages = page.pages;
        this.total = page.total;
        this.count = page.count;
        this.links = page.links;
        this.autoCount = page.autoCount;
    }

    public void calculate(int total) {
        this.setTotal(total);
        this.pages = (total / pageSize) + ((total % pageSize) > 0 ? 1 : 0);
        // 如果當前頁碼超出總頁數,自動更改為最後一頁
        //this.curPage = this.curPage > pages ? this.pages : this.curPage;
        if (curPage > pages) {
            throw new IllegalStateException("超出查詢範圍");
        }
    }

    /**
     * 獲取分頁起始位置和偏移量
     *
     * @return 分頁起始位置和偏移量數組
     */
    public int[] paginate() {
        // 數量為零時,直接從0開始
        return new int[]{total > 0 ? (curPage - 1) * pageSize : 0, pageSize};
    }
 }

View Code

2.2 創建分頁插件

先構建一個普通插件,在準備prepare sql時,設置攔截。也可以在query sql時設置攔截。
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
  // 或者:
@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class})
攔截的方法不同,在攔截時獲取的參數不同。邏輯會存在細微的區別。

/**
 * 分頁SQL插件
 *
 * @author wl
 * @date 2021-5-26
 */
@Intercepts(
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
)
public class PagePlugin implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 分頁插件攔截處理
        useMetaObject(invocation);
        return invocation.proceed();
    }

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

    @Override
    public void setProperties(Properties properties) {
    }
}

View Code

2.3 分頁攔截函數

參考MyBatis插件原理,基於Plugin實現,要獲取sql相關的資訊,可通過MyBatis自帶的MetaObject去獲取屬性和設置屬性。(MetaObject內部基於反射獲取屬性值,設置屬性值)。基於MetaObject獲取StatementHandler對象資訊,參考如圖:
MetaObject獲取MyBatis運行對象資訊參考文檔:Mybatis3詳解(十四)—-Mybatis的分頁Mybatis分頁攔截原理
編寫一個函數,實現更新查詢明細sql的功能,當返回數量<1時,不查詢明細sql,指定查詢一個特殊sql的功能。程式碼如下:
private void useMetaObject(Invocation invocation) throws Throwable {
    StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
    MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
    // 調用MetaObject 反射類處理
    //分離代理對象鏈
    while (metaObject.hasGetter("h")) {
        Object obj = metaObject.getValue("h");
        metaObject = SystemMetaObject.forObject(obj);
    }
    while (metaObject.hasGetter("target")) {
        Object obj = metaObject.getValue("target");
        metaObject = SystemMetaObject.forObject(obj);
    }
    BoundSql boundSql = (BoundSql) metaObject.getValue("delegate.boundSql");
    // 存在分頁標識
    Page page = getPage(boundSql);
    if (Objects.nonNull(page)) {
        int total = getTotalSize(statementHandler, (Connection) invocation.getArgs()[0]);
        if (total <= 0) {
            // 返回數量小於零,查詢一個簡單的sql,不去執行明細查詢 【基於反射,重新設置boundSql】
            metaObject.setValue("delegate.boundSql.sql", "select * from (select 0 as id) as temp where  id>0");
            metaObject.setValue("delegate.boundSql.parameterMappings", Collections.emptyList());
            metaObject.setValue("delegate.boundSql.parameterObject", null);
        } else {
            page.calculate(total);
            String sql = boundSql.getSql() + " limit " + (page.getCurPage() - 1) * page.getPageSize() + ", " + page.getPageSize();
            metaObject.setValue("delegate.boundSql.sql", sql);
        }
    }
}

2.4 輔助函數

判斷是否存在page

/***
 * 獲取分頁的對象
 * @param boundSql 執行sql對象
 * @return 分頁對象
 */
private Page getPage(BoundSql boundSql) {
    Object obj = boundSql.getParameterObject();
    if (Objects.isNull(obj)) {
        return null;
    }
    Page page = null;
    if (obj instanceof Page) {
        page = (Page) obj;
    } else if (obj instanceof Map) {
        // 如果Dao中有多個參數,則分頁的註解參數名必須是page
        try {
            page = (Page) ((Map) obj).get("page");
        } catch (Exception e) {
            return null;
        }
    }
    // 不存在分頁對象,則忽略下面的分頁邏輯
    if (Objects.nonNull(page) && page.isAutoCount()) {
        return page;
    }
    return null;
}

獲取統計總數的sql

/***
 * 獲取統計sql
 * @param originalSql 原始sql
 * @return 返回統計加工的sql
 */
private String getCountSql(String originalSql) {
     // 統一轉換為小寫
    originalSql = originalSql.trim().toLowerCase();
    // 判斷是否存在 limit 標識
    boolean limitExist = originalSql.contains("limit");
    if (limitExist) {
        originalSql = originalSql.substring(0, originalSql.indexOf("limit"));
    }
    boolean distinctExist = originalSql.contains("distinct");
    boolean groupExist = originalSql.contains("group by");
    if (distinctExist || groupExist) {
        return "select count(1) from (" + originalSql + ") temp_count";
    }
    // 去掉 order by
    boolean orderExist = originalSql.contains("order by");
    if (orderExist) {
        originalSql = originalSql.substring(0, originalSql.indexOf("order by"));
    }
    // todo   left join還可以考慮優化
    int indexFrom = originalSql.indexOf("from");
    return "select count(*)  " + originalSql.substring(indexFrom);
}

查詢總數

/**
 * 查詢總記錄數
 *
 * @param statementHandler mybatis sql 對象
 * @param conn             鏈接資訊
 */
private int getTotalSize(StatementHandler statementHandler, Connection conn) {
    ParameterHandler parameterHandler = statementHandler.getParameterHandler();
    String countSql = getCountSql(statementHandler.getBoundSql().getSql());
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    try {
        pstmt = (PreparedStatement) conn.prepareStatement(countSql);
        parameterHandler.setParameters(pstmt);
        rs = pstmt.executeQuery();
        if (rs.next()) {
            // 設置總記錄數
            return rs.getInt(1);
        }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        try {
            if (rs != null) {
                rs.close();
            }
            if (pstmt != null) {
                pstmt.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    return 0;
}

2.5 運行效果

參考上一篇文檔中的方式,配置分頁插件到MyBatis中。調用測試程式碼,效果如下:
設置Mapper查詢方法
/**
 * 獲取進項稅資訊
 *
 * @param kid 單號
 * @param page 分頁參數
 * @return 結果
 */
@SelectProvider(type = LifeLogSqlProvider.class, method = "listInputTaxSql")
List<TaxInput> listInputTax(@Param("kid") Integer kid, @Param("page") Page page);
設置具體查詢sql
public String listInputTaxSql(@Param("kid") Integer kid, @Param("page") Page page){
    return new SQL()
            .select("input_tax_id, k_id,sup_id,k_sup_id,org_id,a.tax,invoice_title,remark")
            .from("tx_sup_goods_input_tax a")
            .innerJoin("tx_tax b on a.tax=b.tax")
            .where(kid>0,"a.k_id = #{kid}")
            .orderBy("a.k_id desc")
            .build();
}

 設置查詢介面

/**
 * 獲取測試稅務資訊
 *
 * @return 返回存儲數據
 */
@GetMapping("/tax")
public List<TaxInput> listInputTax(int kid, Page page) {
    page.setAutoCount(true);
    List<TaxInput> taxInputList  = lifeLogMapper.listInputTax(kid, page);
    if(page.getTotal()==0){
        return Collections.emptyList();
    }else{
        return taxInputList;
    }
}
啟動項目,運行效果如圖

2.6 擴展

按照上述方案,自定義分頁插件測試通過,功能開發完成。
要實現分頁,需更新sql,在返回數量為零時,還指定了特殊的sql。一切功能都是基於MetaObject反射類,獲取對象,設置對象。但仔細觀察需要的BoundSql,卻發現與其他對象有一點不同。如圖:為什麼前面是一個綠色的小旗子?
再次去分析StatementHandler這個介面,發現事情並沒有這樣複雜,要獲取BoundSql,源碼本來就提供了get方法。所以,根本不用通過MetaObject獲取BoundSql對象。
查看BoundSql具體對象,發現設置sql,設置參數的變數沒有提供set方法,不允許調用修改。既然這樣,可利用反射重新設置BoundSql屬性。
分頁攔截函數版本2
private void useReflection(Invocation invocation) throws Throwable {
    StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
    BoundSql boundSql = statementHandler.getBoundSql();
    // 存在分頁標識
    Page page = getPage(boundSql);
    if (Objects.nonNull(page)) {
        int total = getTotalSize(statementHandler, (Connection) invocation.getArgs()[0]);
        if (total <= 0) {
            // 返回數量小於零,查詢一個簡單的sql,不去執行明細查詢 【基於反射,重新設置boundSql】
            Field fieldParameterMappings = BoundSql.class.getDeclaredField("parameterMappings");
            fieldParameterMappings.setAccessible(true);
            fieldParameterMappings.set(boundSql, Collections.emptyList());

            Field fieldSql = BoundSql.class.getDeclaredField("sql");
            fieldSql.setAccessible(true);
            fieldSql.set(boundSql, "select * from (select 0 as id) as temp where  id>0");

            Field fieldParameterObject = BoundSql.class.getDeclaredField("parameterObject");
            fieldParameterObject.setAccessible(true);
            fieldParameterObject.set(boundSql, null);
        } else {
            page.calculate(total);
            Field field = BoundSql.class.getDeclaredField("sql");
            field.setAccessible(true);
            // 設置分頁的SQL程式碼
            field.set(boundSql, boundSql.getSql() + " limit " + (page.getCurPage() - 1) * page.getPageSize() + ", " + page.getPageSize());
        }
    }
}
分頁攔截函數版本3
private void useMetaObjectPlus(Invocation invocation) throws Throwable {
    StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
    BoundSql boundSql = statementHandler.getBoundSql();
    // 存在分頁標識
    Page page = getPage(boundSql);
    if (Objects.nonNull(page)) {
        int total = getTotalSize(statementHandler, (Connection) invocation.getArgs()[0]);
        MetaObject metaObject = SystemMetaObject.forObject(boundSql);
        if (total <= 0) {
            // 返回數量小於零,查詢一個簡單的sql,不去執行明細查詢 【基於反射,重新設置boundSql】
            metaObject.setValue("sql", "select * from (select 0 as id) as temp where  id>0");
            metaObject.setValue("parameterMappings", Collections.emptyList());
            metaObject.setValue("parameterObject", null);
        } else {
            page.calculate(total);
            boolean limitExist = boundSql.getSql().trim().toLowerCase().contains("limit");
            if (!limitExist) {
                String sql = boundSql.getSql() + " limit " + (page.getCurPage() - 1) * page.getPageSize() + ", " + page.getPageSize();
                metaObject.setValue("sql", sql);
            }
        }
    }
}
綜合對比,推薦分頁攔截函數版本3

三、功能擴展

3.1 基於註解配置分頁

自定義一個註解,根據方法上是否有註解,來做自動分頁處理。核心功能是如何判斷方法上是否存在註解,參考源碼,攔截獲取MappedStatement,獲取具體執行的方法,基於反射獲取註解資訊,參考程式碼如下:
/***
 * 查看註解的自定義插件是否存在
 * @param mappedStatement 參數
 * @return 返回檢查結果
 * @throws Throwable 拋出異常
 */
private boolean existEnhancer(MappedStatement mappedStatement) throws Throwable {
    //獲取執行方法的位置
    String namespace = mappedStatement.getId();
    //獲取mapper名稱
    String className = namespace.substring(0, namespace.lastIndexOf("."));
    //獲取方法名aClass
    String methodName = namespace.substring(namespace.lastIndexOf(".") + 1);
    Class<?> aClass = Class.forName(className);
    for (Method method : aClass.getDeclaredMethods()) {
        if (methodName.equals(method.getName())) {
            // 暫不考慮方法被重載
            Enhancer enhancer = method.getAnnotation(Enhancer.class);
            if (Objects.nonNull(enhancer) && enhancer.autoPageCount()) {
                // 設置page
                return true;
            }
        }
    }
    return false;
}

3.2 基於查詢參數-判斷參數是否包含page對象

  1. 若查詢條件中,本身就包括page對象,如何獲取page對象?
  2. 若查詢對象本身繼承自Page,如何獲取資訊page對象?
要滿足分頁,必須要存在分頁基本的查詢參數(每一頁數量,當前查詢頁碼),攔截系統中本身的參數對象,主要是通過BoundSql類,獲取參數資訊。參考程式碼如下:
/***
 * 獲取分頁的對象
 * @param boundSql 執行sql對象
 * @return 分頁對象
 */
private Page getPage(BoundSql boundSql) {
    Page page = null;
    // 參考源碼,調試發現為一個map對象
    Map<String, Object> parameterList = (Map<String, Object>) boundSql.getParameterObject();
    if (Objects.isNull(parameterList)) {
        return null;
    }
    for (Map.Entry<String, Object> entry : parameterList.entrySet()) {
        if (entry.getValue() instanceof Page) {
            page = (Page) entry.getValue();
            break;
        }
    }
    if (Objects.nonNull(page)) {
        return page;
    }
    return null;
}

3.3 插件程式碼說明

  1. PageAnnotationExecutorPlugin:表示結合註解,基於Executor.class的query方法做攔截,實現分頁功能。
  2. PageAnnotationPlugin:表示結合註解,基於StatementHandler.class的prepare方法做攔截,實現分頁。該方案主要是調用MetaObject,反射獲取對象和設置對象,在不同的代理時,獲取到對應對象的模式存在差異(h,target嵌套層不同),存在基於本例獲取不到對象的情況。
  3. PagePlugin:基於StatementHandler.class的prepare方法做攔截。

四、思考總結

  1. 應當去了解一下比較優秀的MyBatis分頁插件,查看源碼,學習參考。
  2. 若項目允許,還是集成成熟的分頁插件,自定義的分頁插件難免存在一些不足。
  3. 獲取類屬性時,可基於對象,通過反射獲取到對應的類,若對象是基於代理(jdk,cglb)生成的,又該如何獲取?
  4. 反射可以獲取具體執行方法上的註解,獲取方法名稱,獲取參數類型,等具體參考反射的提供的api介面。
  5. 當統計總數<1時,是否可以讓MyBatis返回一個空集合?暫未找到辦法,默認一個簡單sql的模式,是一種非主流的方式。
  6. 自定義插件的兩個關鍵知識點:MappedStatement,BoundSql。
  7. 基於Executor.class和StatementHandler.class在不同的點做攔截時,攔截到的參數不同,獲取MappedStatement,BoundSql的方式不同,需查看源碼具體分析。
  8. 為什麼在StatementHandler.class的prepare方法做攔截時,反射重新設置BoundSql對象,就可以更新後續執行的sql資訊了,但在Executor.class的query方法做攔截時,反射重新設置BoundSql對象不行,需要重新更新MappedStatement對象?
  9. 編寫函數時,盡量抽象出通用的輔助函數,每一個輔助函數只做單一的功能。上述三種分頁攔截函數實現調整了,都可以使用輔助函數。改動量小。
  10. 關於編碼規範,強烈推薦書籍:《重構-改善既有程式碼的設計結構》,《程式碼整潔之道》。