1. 程式人生 > 其它 >阿里p7大神詳細講解Mybatis外掛機制(植入、執行外掛邏輯,分頁外掛)

阿里p7大神詳細講解Mybatis外掛機制(植入、執行外掛邏輯,分頁外掛)

技術標籤:javamybatis資料庫spring程式設計師

目錄

外掛機制

一般情況下,開源框架都會提供外掛或其他形式的拓展點,供開發者自行拓展。這樣的
好處是顯而易見的,一是增加了框架的靈活性。二是開發者可以結合實際需求,對框架進行
拓展,使其能夠更好的工作。以 MyBatis 為例,我們可基於 MyBatis 外掛機制實現分頁、
分表,監控等功能。由於外掛和業務無關,業務也無法感知外掛的存在。因此可以無感植入
外掛,在無形中增強功能。
開發 MyBatis 外掛需要對 MyBatis 比較深瞭解才行,一般來說最好能夠掌握 MyBatis

的原始碼,門檻相對較高。本篇文章在分析完 MyBatis 外掛機制後,會手寫一個簡單的分頁
外掛,以幫助大家更好的掌握 MyBatis 外掛的編寫。

1、外掛機制原理

我們在編寫外掛時,除了需要讓外掛類實現 Interceptor 介面外,還需要通過註解標註
該外掛的攔截點。所謂攔截點指的是外掛所能攔截的方法,MyBatis 所允許攔截的方法如
下:

  1. Executor: update, query, flushStatements, commit, rollback,
    getTransaction, close, isClosed
  2. ParameterHandler: getParameterObject, setParameters
  3. ResultSetHandler: handleResultSets, handleOutputParameters
  4. StatementHandler: prepare, parameterize, batch, update, query

如果我們想要攔截 Executor 的 query 方法,那麼可以這樣定義外掛。

@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args ={MappedStatement.class, Object.class, RowBounds.class, 
ResultHandler.
class} ) }) public class ExamplePlugin implements Interceptor { // 省略邏輯 }

除此之外,我們還需將外掛配置到相關檔案中。這樣 MyBatis 在啟動時可以載入外掛,
並儲存外掛例項到相關物件(InterceptorChain,攔截器鏈)中。待準備工作做完後,MyBatis
處於就緒狀態。我們在執行 SQL 時,需要先通過 DefaultSqlSessionFactory 創 建
SqlSession 。Executor 例項會在建立 SqlSession 的過程中被建立,Executor 例項建立完畢
後,MyBatis 會通過 JDK 動態代理為例項生成代理類。這樣,外掛邏輯即可在 Executor 相
關方法被呼叫前執行。以上就是 MyBatis 外掛機制的基本原理。接下來,我們來看一下原
理背後對應的原始碼是怎樣的。

1.1 植⼊外掛邏輯

本節,我將以 Executor 為例,分析 MyBatis 是如何為 Executor 例項植入外掛邏輯的。
Executor 例項是在開啟 SqlSession 時被建立的,因此,下面我們從源頭進行分析。先來看
一下 SqlSession 開啟的過程。

// -☆- DefaultSqlSessionFactory
public SqlSession openSession() {
return openSessionFromDataSource(
configuration.getDefaultExecutorType(), null, false);
}
private SqlSession openSessionFromDataSource(ExecutorType execType, 
TransactionIsolationLevel level, boolean autoCommit) {
Transaction tx = null;
try {
// 省略部分邏輯
// 建立 Executor
final Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);
 } 
catch (Exception e) {...} 
finally {...}
}

Executor 的建立過程封裝在 Configuration 中,我們跟進去看看看。

// -☆- Configuration
public Executor newExecutor(Transaction transaction, 
ExecutorType executorType) {
executorType = executorType == null ?
defaultExecutorType : executorType;
executorType = executorType == null ?
ExecutorType.SIMPLE : executorType;
Executor executor;
// 根據 executorType 建立相應的 Executor 例項
if (ExecutorType.BATCH == executorType) {...} 
else if (ExecutorType.REUSE == executorType) {...} 
else {
executor = new SimpleExecutor(this, transaction);
 }
if (cacheEnabled) {
executor = new CachingExecutor(executor);
 }
// 植入外掛
executor = (Executor) interceptorChain.pluginAll(executor);
return executor; }

如上,newExecutor 方法在建立好 Executor 例項後,緊接著通過攔截器鏈 interceptorChain
為 Executor 例項植入代理邏輯。那下面我們看一下 InterceptorChain 的程式碼是怎樣的。

public class InterceptorChain {
private final List<Interceptor> interceptors =
new ArrayList<Interceptor>();
public Object pluginAll(Object target) {
// 遍歷攔截器集合
for (Interceptor interceptor : interceptors) {
// 呼叫攔截器的 plugin 方法植入相應的外掛邏輯
target = interceptor.plugin(target);
 }
return target;
 }
/** 新增外掛例項到 interceptors 集合中 */
public void addInterceptor(Interceptor interceptor) {
interceptors.add(interceptor);
 }
/** 獲取外掛列表 */
public List<Interceptor> getInterceptors() {
return Collections.unmodifiableList(interceptors);
 }
  }

以上是 InterceptorChain 的全部程式碼,比較簡單。它的 pluginAll 方法會呼叫具體外掛的
plugin 方法植入相應的外掛邏輯。如果有多個外掛,則會多次呼叫 plugin 方法,最終生成一
個層層巢狀的代理類。形如下面:
image.png

當 Executor 的某個方法被呼叫的時候,外掛邏輯會先行執行。執行順序由外而內,比如
上圖的執行順序為 plugin3 → plugin2 → Plugin1 → Executor。
plugin 方法是由具體的外掛類實現,不過該方法程式碼一般比較固定,所以下面找個示例
分析一下。

// -☆- ExamplePlugin
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
// -☆- Plugin
public static Object wrap(Object target, Interceptor interceptor) {
// 獲取外掛類 @Signature 註解內容,並生成相應的對映結構。形如下面:
// {
// Executor.class : [query, update, commit],
// ParameterHandler.class : [getParameterObject, setParameters]
// }
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
Class<?> type = target.getClass();
// 獲取目標類實現的介面
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
// 通過 JDK 動態代理為目標類生成代理類
return Proxy.newProxyInstance(
type.getClassLoader(),
interfaces,
new Plugin(target, interceptor, signatureMap));
 }
return target;
 }

如上,plugin 方法在內部呼叫了 Plugin 類的 wrap 方法,用於為目標物件生成代理。Plugin
類實現了InvocationHandler介面,因此它可以作為引數傳給Proxy的newProxyInstance方法。

到這裡,關於外掛植入的邏輯就分析完了。接下來,我們來看看外掛邏輯是怎樣執行
的。

1.2 執⾏外掛邏輯

Plugin 實現了 InvocationHandler 介面,因此它的 invoke 方法會攔截所有的方法呼叫。
invoke 方法會對所攔截的方法進行檢測,以決定是否執行外掛邏輯。該方法的邏輯如下:

// -☆- Plugin
public Object invoke(Object proxy, Method method, Object[] args) 
throws Throwable {
try {
// 獲取被攔截方法列表,比如:signatureMap.get(Executor.class),
// 可能返回 [query, update, commit]
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
// 檢測方法列表是否包含被攔截的方法
if (methods != null && methods.contains(method)) {
// 執行外掛邏輯
return interceptor.intercept(
new Invocation(target, method, args));
 }
// 執行被攔截的方法
return method.invoke(target, args);
 } catch (Exception e) {
throw ExceptionUtil.unwrapThrowable(e);
 } }

invoke 方法的程式碼比較少,邏輯不難理解。首先,invoke 方法會檢測被攔截方法是否配
置在外掛的 @Signature 註解中,若是,則執行外掛邏輯,否則執行被攔截方法。外掛邏輯
封裝在 intercept 中,該方法的引數型別為 Invocation。Invocation 主要用於儲存目標類,方法以及方法引數列表。下面簡單看一下該類的定義。

public class Invocation {
private final Object target;
private final Method method;
private final Object[] args;
public Invocation(Object target, Method method, Object[] args) {
this.target = target;
this.method = method;
this.args = args;
 }
public Object proceed() 
throws InvocationTargetException, IllegalAccessException {
// 呼叫被攔截的方法
return method.invoke(target, args);
 } }

關於外掛的執行邏輯就分析到這,整個過程不難理解,大家簡單看看即可。

2、實現⼀個分頁外掛

為了更好的向大家介紹 MyBatis 的外掛機制,本節將實現一個 MySQL 資料庫分頁插
件。相關程式碼如下:

@Intercepts({
@Signature(
type = Executor.class, // 目標類
method = "query", // 目標方法
args ={MappedStatement.class, 
Object.class, RowBounds.class, ResultHandler.class}
 )
})
public class MySqlPagingPlugin implements Interceptor {
private static final Integer MAPPED_STATEMENT_INDEX = 0;
private static final Integer PARAMETER_INDEX = 1;
private static final Integer ROW_BOUNDS_INDEX = 2;
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
RowBounds rb = (RowBounds) args[ROW_BOUNDS_INDEX];
// 無需分頁
if (rb == RowBounds.DEFAULT) {
return invocation.proceed();
 }
// 將原 RowBounds 引數設為 RowBounds.DEFAULT,關閉 MyBatis 內建的分頁機制
args[ROW_BOUNDS_INDEX] = RowBounds.DEFAULT;
MappedStatement ms = (MappedStatement) args[MAPPED_STATEMENT_INDEX];
BoundSql boundSql = ms.getBoundSql(args[PARAMETER_INDEX]);
// 獲取 SQL 語句,拼接 limit 語句
String sql = boundSql.getSql();
String limit = String.format(
"LIMIT %d,%d", rb.getOffset(), rb.getLimit());
sql = sql + " " + limit;
// 建立一個 StaticSqlSource,並將拼接好的 sql 傳入
SqlSource sqlSource = new StaticSqlSource(
ms.getConfiguration(), sql, boundSql.getParameterMappings());
// 通過反射獲取並設定 MappedStatement 的 sqlSource 欄位
Field field = MappedStatement.class.getDeclaredField("sqlSource");
field.setAccessible(true);
field.set(ms, sqlSource);
// 執行被攔截方法
return invocation.proceed();
 }
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
 }
@Override
public void setProperties(Properties properties) {
 } }

上面的分頁外掛通過 RowBounds 引數獲取分頁資訊,並生成相應的 limit 語句。之後拼
接 sql,並使用該 sql 作為引數建立 StaticSqlSource。最後通過反射替換 MappedStatement 物件中的 sqlSource 欄位。下面,寫點測試程式碼驗證一下外掛是否可以正常執行。先來看一下Dao 介面與對映檔案的定義:

public interface StudentDao {
List<Student> findByPaging(@Param("id") Integer id, RowBounds rb);
}
<mapper namespace="xyz.coolblog.chapter7.dao.StudentDao">
<select id="findByPaging"
resultType="xyz.coolblog.chapter7.model.Student">
 SELECT
 `id`, `name`, `age`
 FROM
 student
 WHERE
 id > #{id}
</select>
</mapper>

測試程式碼如下:

public class PluginTest {
private SqlSessionFactory sqlSessionFactory;
@Before
public void prepare() throws IOException {
String resource = "chapter7/mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
sqlSessionFactory=new SqlSessionFactoryBuilder().build(inputStream);
inputStream.close();
 }
@Test
public void testPlugin() {
SqlSession session = sqlSessionFactory.openSession();
try {
StudentDao studentDao = session.getMapper(StudentDao.class);
studentDao.findByPaging(1, new RowBounds(20, 10));
 } finally {
session.close();
 }
 } }

上面程式碼執行之後,會列印如下日誌。
image.png

在上面的輸出中,SQL 語句中包含了 LIMIT 字樣,這說明外掛生效了。

3 本章⼩結

到此,關於 MyBatis 外掛機制就分析完了。總體來說,MyBatis 外掛機制比較簡單。
但實現一個外掛卻較為複雜,需要對 MyBatis 比較瞭解才行。因此,若想寫出高效的插
件,還需深入學習原始碼才行。喜歡的可以點個一鍵三連,關注一下博主哦!