MyBatis-編寫自定義分頁插件
一、基礎知識
本文測試和源碼分析參考版本: Mybatis-version:3.5.5
本文相關測試源程式碼://github.com/wuya11/mybatis_demo
1.1 參考方向
自定義實現分頁插件,參考方向如下:
- 編寫一個分頁(Page)基礎對象;
- 基於插件原理,自定義一個分頁攔截插件;
- 基於攔截器,獲取BoundSql對象 ,獲取動態生成的SQL語句以及相應的參數資訊;
- 根據參數資訊,判斷是否需要分頁查詢;
- 生成統計總數的sql,並查詢出總條數;
- 更新BoundSql對象的數據,設置查詢明細sql,加上分頁標識;
- 寫好的分頁插件配置到MyBatis中;
1.2 思考維度
- 生成統計總數語句時,如何保證select count(1)的性能更好;參考方向:詳解分頁組件中查count總記錄優化
- 當查詢出統計總數為零時,有何優雅的辦法,不再去查詢一次明細資訊;
二、編碼實現
2.1 創建Page對象
- 設置常用分頁的基礎屬性欄位;
- 當不想使用框架默認的自動分頁,設置一個可變參數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對象
- 若查詢條件中,本身就包括page對象,如何獲取page對象?
- 若查詢對象本身繼承自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 插件程式碼說明
- PageAnnotationExecutorPlugin:表示結合註解,基於Executor.class的query方法做攔截,實現分頁功能。
- PageAnnotationPlugin:表示結合註解,基於StatementHandler.class的prepare方法做攔截,實現分頁。該方案主要是調用MetaObject,反射獲取對象和設置對象,在不同的代理時,獲取到對應對象的模式存在差異(h,target嵌套層不同),存在基於本例獲取不到對象的情況。
- PagePlugin:基於StatementHandler.class的prepare方法做攔截。
四、思考總結
- 應當去了解一下比較優秀的MyBatis分頁插件,查看源碼,學習參考。
- 若項目允許,還是集成成熟的分頁插件,自定義的分頁插件難免存在一些不足。
- 獲取類屬性時,可基於對象,通過反射獲取到對應的類,若對象是基於代理(jdk,cglb)生成的,又該如何獲取?
- 反射可以獲取具體執行方法上的註解,獲取方法名稱,獲取參數類型,等具體參考反射的提供的api介面。
- 當統計總數<1時,是否可以讓MyBatis返回一個空集合?暫未找到辦法,默認一個簡單sql的模式,是一種非主流的方式。
- 自定義插件的兩個關鍵知識點:MappedStatement,BoundSql。
- 基於Executor.class和StatementHandler.class在不同的點做攔截時,攔截到的參數不同,獲取MappedStatement,BoundSql的方式不同,需查看源碼具體分析。
- 為什麼在StatementHandler.class的prepare方法做攔截時,反射重新設置BoundSql對象,就可以更新後續執行的sql資訊了,但在Executor.class的query方法做攔截時,反射重新設置BoundSql對象不行,需要重新更新MappedStatement對象?
- 編寫函數時,盡量抽象出通用的輔助函數,每一個輔助函數只做單一的功能。上述三種分頁攔截函數實現調整了,都可以使用輔助函數。改動量小。
- 關於編碼規範,強烈推薦書籍:《重構-改善既有程式碼的設計結構》,《程式碼整潔之道》。