一、基礎知識
本文測試和原始碼分析參考版本: Mybatis-version:3.5.5
本文相關測試原始碼:https://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};
}
}
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) {
}
}
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物件?
- 編寫函式時,儘量抽象出通用的輔助函式,每一個輔助函式只做單一的功能。上述三種分頁攔截函式實現調整了,都可以使用輔助函式。改動量小。
- 關於編碼規範,強烈推薦書籍:《重構-改善既有程式碼的設計結構》,《程式碼整潔之道》。