Mybatis 提供了 插件 的機制,使得開發者可以侵入 Mybatis 工作流程,讀完前幾篇文章,相信大家已經對於 Mybatis,已經有了大致介紹了認識。
本文將從以下幾個問題出發:
- Mybatis 可以實現哪幾種攔截器?
- Mybatis 中攔截器的使用。
- 這幾種攔截器是如何工作的?
- PageHelper 怎麼用的?
- PageHelper 如何基於攔截器進行工作的?
用法
由前面文章分析可知,使用Mybatis ,有下面幾個流程:
- 構建SqlSession
- 填充參數
- 執行查詢
- 封裝結果
而對於Mybatis 的攔截器,就是可以利用 提供的攔截器機制對 這四個過程做文章,即攔截這幾個過程,實現自己代碼邏輯。
- 在 xml 中配置:
<plugins>
<plugin interceptor="anla.learn.mybatis.interceptor.config.SqlStatementHandlerInterceptor">
<property name="dialect" value="mysql"/>
</plugin>
</plugins>
- 使用類實現
org.apache.ibatis.plugin.Interceptor
,並使用相應註解說明攔截的類別:
// 說明攔截器是 StatementHandler,攔截方法爲 prepare
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class SqlStatementHandlerInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
log.info("mybatis intercept sql:{}", sql);
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
String dialect = properties.getProperty("dialect");
log.info("mybatis intercept dialect:{}", dialect);
}
}
上面代碼有以下要點:
- 增加
plugin
標籤 - 實現
Interceptor
,並增加@Intercepts
註解,並使用@Signature
說明攔截類型
@Signature
有以下可選參數
- type:只攔截器類型,可選
Executor
、ResultSetHandler
、StatementHandler
、ParameterHandler
。 - method:指的是 上面四個類型裏面的方法,當然不同的類可以有不同。
- args:指 攔截的方法,裏面的參數類型。
上述簡單攔截器在setProperties
中,設置了相關方言並打印,而主 攔截器intercept
方法 僅僅打印sql,而後執行invocation.proceed();
繼續執行Mybatis 自有邏輯。
具體可攔截對象如下:
Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
ParameterHandler (getParameterObject, setParameters)
ResultSetHandler (handleResultSets, handleOutputParameters)
StatementHandler (prepare, parameterize, batch, update, query)
具體使用例子可以看博主項目例子:https://github.com/anLA7856/mybatislearn
攔截器分析
下面來具體分析攔截器原理,先看看 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) {
}
}
Interceptor
本身只是 一個接口,提供了 三個方法 intercept
、plugin
、setProperties
。
intercept
: 攔截過濾器,對於被攔截方法,都會先執行intercept
,然後再會執行確定方法plugin
:提供了默認實現方法,主要是對下一層過濾器或者具體攔截對象進一步封裝setProperties
:設置屬性,即<plugin>
標籤中<property>
子標籤
分析一個類原理,首先從該類初始化,而後再從其調用上來分析。
本文將從以下幾個點分析攔截器:
- 攔截器初始化:攔截器何時被Mybatis 加載
- 四種攔截器使用點:具體攔截器以怎樣方式被初始化並調用?
攔截器初始化
當 Mybatis 機制被加載時,<plugins>
節點內容會被加載並放到 Configuration
中,具體就是加載到 變量 InterceptorChain
中:
protected final InterceptorChain interceptorChain = new InterceptorChain();
在 XMLConfigBuilder
中 pluginElement
方法將xml中配置的插件加載到 InterceptorChain
:
private void pluginElement(XNode parent) throws Exception {
if (parent != null) {
for (XNode child : parent.getChildren()) {
String interceptor = child.getStringAttribute("interceptor");
Properties properties = child.getChildrenAsProperties();
Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).newInstance();
interceptorInstance.setProperties(properties);
configuration.addInterceptor(interceptorInstance);
}
}
}
當Mybatis
進行插件各個流程時,會執行 interceptorChain.pluginAll
對組件進行進一步封裝:
所以在 四個組件初始化時候,進行一步封裝,下面看看pluginAll
方法:
public Object pluginAll(Object target) {
for (Interceptor interceptor : interceptors) {
target = interceptor.plugin(target);
}
return target;
}
將 傳入的 組件執行其 interceptor.plugin
,而 基本上開發者不用重寫這個方法,它在 Interceptor
中有默認的實現,主要目的是 使用 interceptor
包裝一層 target
返回一個代理對象:
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}
wrap 方法
public static Object wrap(Object target, Interceptor interceptor) {
// 獲取註解中的方法以及配置了的攔截簽名
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
// 獲取目標類
Class<?> type = target.getClass();
// 獲取所有的 涉及到的 攔截器 接口名
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
return Proxy.newProxyInstance(
type.getClassLoader(),
interfaces,
new Plugin(target, interceptor, signatureMap));
}
// 返回生成的代理對象
return target;
}
最後一個 return Proxy.newProxyInstance(type.getClassLoader(),interfaces,new Plugin(target, interceptor, signatureMap));
返回一個代理類,由於 Plugin
是InvocationHandler
的子類, 最後每當方法調用時,都會經過其invoke
方法。
實際上 , invoke
方法會攔截對應代理對象所有方法,但是會通過傳入的 signatureMap
進行一層過濾,只有註解配置過得方法纔會被攔截:
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
if (methods != null && methods.contains(method)) {
// 是 signaltureMap 中方法,纔會執行 intercept 方法
return interceptor.intercept(new Invocation(target, method, args));
}
// 如果不是,則會直接 執行對應方法
return method.invoke(target, args);
} catch (Exception e) {
throw ExceptionUtil.unwrapThrowable(e);
}
}
對於 過濾器鏈裝載順序,則是以 棧 的方式進行組裝:
例如如果有如下相同的 基於 Executor
的攔截器
<plugins>
<plugin interceptor="com.anla.learn.ExecutorQueryInterceptor1"/>
<plugin interceptor="com.anla.learn.ExecutorQueryInterceptor2"/>
<plugin interceptor="com.anla.learn.ExecutorQueryInterceptor3"/>
</plugins>
通過 interceptorChain.pluginAll
方法之後,代理結構如下:
Interceptor3:{
Interceptor2: {
Interceptor1: {
target: Executor
}
}
}
而最終執行 則是按照 3>2>1>Executor>1>2>3
順序執行,類似於遞歸式執行。
攔截器調用點
其實攔截器的調用點很多,因爲Mybatis
內置組件的每一個方法都可以是調用點,只要配置了攔截方法。
所以起始只需要瞭解四個內置組件的使用順序,這樣 當組件使用時,就是攔截器被觸發是:
Executor
包裝時,executor = (Executor) interceptorChain.pluginAll(executor)
,而後和SqlSession
一併返回- 在攔截器獲取
StatementHandler
時,同樣會通過statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler)
,進行一層包裝,而後StatementHandler
每一個方法都會進行過濾。 ParameterHandler
初始化,同樣會有過濾器parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
- 以及 最後結果集
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
對於 攔截器方法和 Mybatis
內置方法,是會優先執行 攔截器方法,即你可以只執行攔截器,而不執行 Mybatis
方法,
正如 Plugin
的 invoke
方法:
if (methods != null && methods.contains(method)) {
// 是 signaltureMap 中方法,纔會執行 intercept 方法
return interceptor.intercept(new Invocation(target, method, args));
}
// 如果不是,則會直接 執行對應方法
return method.invoke(target, args);
當然,也可以執行完攔截器後,繼續執行 Mybatis
正常邏輯流程:
return invocation.proceed();
而 invocation 爲調用時 傳入 的 Invocation
:
return interceptor.intercept(new Invocation(target, method, args));
實際上就是調用 method.invoke
即包裝類的目標方法。
PageHelper
PageHelper 是國人寫的一個優秀的Mybatis
分頁插件 ,
簡介:https://github.com/pagehelper/Mybatis-PageHelper
例如 通過下面兩句即可輕鬆完成分頁:
// 設置當前上下文
PageHelper.startPage(1, 10);
List<User> list = mapper.getAllUsers();
比如一個查詢,你並不需要每次查詢都要寫一個 count
和 page
方法,對於 PageHelper 來說,只需要寫一個page
方法即可,PageHelper 會自動幫你完成一次 count
查詢,當查詢出來的 count
有值時,纔會進行第二步的page
操作。
具體例子可以看博主Test:https://github.com/anLA7856/mybatislearn/blob/master/mybatis-interceptor/src/test/java/MybatisPageHelperTest.java
PageHelper 邏輯性原理比較簡單,相信大家看了 PageHelper
測試例子後,估計也就能懂了個大半。
那麼現在就是看看PageHelper 原理
QueryInterceptor
PageHelper
所有騷操作起點都是 PageInterceptor
,它負責攔截 Executor
的 query
方法:
@Intercepts(
{
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
}
)
public class PageInterceptor implements Interceptor {
...
}
由於 不同數據庫的分頁語句不一樣,所以 PageHelper
中存在 Dialect
(數據庫方言)概念,這個選取是從 jdbc url 中獲取:
jdbc:mysql://127.0.0.1/df?useUnicode=true
例如以上 數據庫就是 mysql,這樣一來就可以使用 Mysql
方式進行分頁。
下面主要看看 PageInterceptor
的 intercept
方法:
@Override
public Object intercept(Invocation invocation) throws Throwable {
try {
// 從 invocation 中獲取參數,攔截的方法有幾個參數,就會獲取幾個參數
Object[] args = invocation.getArgs();
// 這樣就能
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
// 獲取攔截的 對象
Executor executor = (Executor) invocation.getTarget();
CacheKey cacheKey;
BoundSql boundSql;
// 判斷參數個數,從而 獲取 boundSql 和 cacheKey
if (args.length == 4) {
//4 個參數時
boundSql = ms.getBoundSql(parameter);
cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
} else {
//6 個參數時
cacheKey = (CacheKey) args[4];
boundSql = (BoundSql) args[5];
}
// 判斷方言是否存在,即是否配置了 dialect 中知道數據庫類型
checkDialectExists();
List resultList;
//調用方法判斷是否需要進行分頁,如果不需要,直接返回結果,其中包括從MappedStatement中獲取數據庫方言類型
if (!dialect.skip(ms, parameter, rowBounds)) {
//判斷是否需要進行 count 查詢,
if (dialect.beforeCount(ms, parameter, rowBounds)) {
//查詢總數
Long count = count(executor, ms, parameter, rowBounds, resultHandler, boundSql);
//處理查詢總數,返回 true 時繼續分頁查詢,false 時直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//當查詢總數爲 0 時,直接返回空的結果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
//rowBounds用參數值,不使用分頁插件處理時,仍然支持默認的內存分頁
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
return dialect.afterPage(resultList, parameter, rowBounds);
} finally {
if(dialect != null){
dialect.afterAll();
}
}
}
以上邏輯有以下邏輯:
- 通過不同的攔截方法,從而定位到
invocation.getArgs
中 參數個數,再通過 數組索引方式獲取參數。 - 攔截器初始配置時,會嘗試去尋找
properties
節點下dialect
配置,如果 有配置則將dialect
設置爲對應方言節點。 - 如果沒有找到
dialect
配置,則會默認使用com.github.pagehelper.PageHelper
來生成初始化 的 dialect 。
PageHelper
類其實類似一個操作類,作爲一個裝飾器模式 + 門面模式,裏面 維護分頁參數以及分頁方言對象PageAutoDialect
。
所有操作都可以基於PageHelper
進行,而裏面實際調用則是調用PageAutoDialect
方法。
上面兩個子類節點分別代表使用PageHelper
進行分頁,還是使用Mybatis
中自帶RowBounds
進行分頁 - 在
!dialect.skip(ms, parameter, rowBounds)
中 會判斷是否需要分頁,這個方法只有在PageHelper
有有效實現,其他兩個僅給出默認實現,而 PageHelper 中 skip 邏輯,就是獲取當前線程的設置的分頁參數,如果有設置,則返回 false,進行分頁。
PageMethod 作爲基礎分頁方法,裏面維護這一個ThreadLocal<Page> LOCAL_PAGE
代表當前線程分頁參數。
另外,在 skip 中,會 嘗試去初始化PageAutoDialect
中維護的 具體 方言delegate
,會嘗試從 MapperStatement 的 url中去尋找,最終通過PageAutoDialect
維護的Map<String, Class<? extends Dialect>>
加載出不同的類實例。 - 如果有有分頁,那麼就嘗試首先查詢初 count(0) 數量,有數量則進行 具體分頁查詢。在
count(executor, ms, parameter, rowBounds, resultHandler, boundSql);
中,會首先判斷當前 mapper 中是否有count 類型的查詢,如果有則直接調用該查詢返回。
如果沒有,則會新建一個_COUNT
結尾的查詢,最後執行查詢並返回。
private Long count(Executor executor, MappedStatement ms, Object parameter,
RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) throws SQLException {
String countMsId = ms.getId() + countSuffix;
Long count;
//先判斷是否存在手寫的 count 查詢
MappedStatement countMs = ExecutorUtil.getExistedMappedStatement(ms.getConfiguration(), countMsId);
if (countMs != null) {
count = ExecutorUtil.executeManualCount(executor, countMs, parameter, boundSql, resultHandler);
} else {
countMs = msCountMap.get(countMsId);
//自動創建
if (countMs == null) {
//根據當前的 ms 創建一個返回值爲 Long 類型的 ms
countMs = MSUtils.newCountMappedStatement(ms, countMsId);
msCountMap.put(countMsId, countMs);
}
count = ExecutorUtil.executeAutoCount(dialect, executor, countMs, parameter, boundSql, rowBounds, resultHandler);
}
return count;
}
- 通過
dialect.afterCount
判斷是否需要返回,如果爲 0 時候,則直接返回查詢結果,不進行下一次具體分頁查詢。 - 如果需要分頁,且否則直接執行
ExecutorUtil.pageQuery(dialect, executor,ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
返回分頁查詢結果。 - 如果最終配置的不需要非呢也,則直接調用
Mybatis
的executor.query
進行下面查詢操作。 - 在本次查詢完後,會執行
dialect.afterPage(resultList, parameter, rowBounds);
用於清除當次查詢遺留的本地線程信息。
總結
PageHelper 是一款優秀的分頁插件,我們可以不用去編寫 多餘的 count 查詢以及count 判斷,也不用考慮不同數據庫分頁之間差別,這些 PageHelper 都可以幫我們解決。
另外,PageInterceptor 在 intercept 最後,並沒有調用 invocation.proceed
,實際上就是 走完這個intercept方法,就會返回結果,但是實際上,PageInterceptor
裏面查詢邏輯,都是通過 Invocation
中傳遞過來參數,進行對Mybatis
流程調用,是使用的 executor.query
,所以是很好的從Mybatis
插件切入,並且再一次無縫對接 入 Mybatis
的。
但是缺點就是學習成本以及對業務的侵入性,開發者往往不願意去接納一個不廣泛的框架,而更願意根據業務去造一個輪子。
後話
總體來說, Mybatis
攔截器 是 Mybatis
提供給開發者侵入 Mybatis
內部執行邏輯的方法,如果操作不當,侵入後可能會影響其本身邏輯。當然這就是見仁見智了。
覺得博主寫的有用,不妨關注博主公衆號: 六點A君。
哈哈哈,一起研究Mybatis: