MyBatis 插件原理與自定義插件:
MyBatis 通過提供插件機制,讓我們可以根據自己的需要去增強MyBatis 的功能。需要注意的是,如果沒有完全理解MyBatis 的運行原理和插件的工作方式,最好不要使用插件,因為它會改變系底層的工作邏輯,給系統帶來很大的影響。
MyBatis 的插件可以在不修改原來的代碼的情況下,通過攔截的方式,改變四大核心對象的行為,比如處理參數,處理SQL,處理結果。
第一個問題:
不修改對象的代碼,怎么對對象的行為進行修改,比如說在原來的方法前面做一點事情,在原來的方法后面做一點事情?
答案:大家很容易能想到用代理模式,這個也確實是MyBatis 插件的原理。
第二個問題:
我們可以定義很多的插件,那么這種所有的插件會形成一個鏈路,比如我們提交一個休假申請,先是項目經理審批,然后是部門經理審批,再是HR 審批,再到總經理審批,怎么實現層層的攔截?
答案:插件是層層攔截的,我們又需要用到另一種設計模式——責任鏈模式。
在之前的源碼中我們也發現了,mybatis內部對於插件的處理確實使用的代理模式,既然是代理模式,我們應該了解MyBatis 允許哪些對象的哪些方法允許被攔截,並不是每一個運行的節點都是可以被修改的。只有清楚了這些對象的方法的作用,當我們自己編寫插件的時候才知道從哪里去攔截。在MyBatis 官網有答案,我們來看一下:http://www.mybatis.org/mybatis-3/zh/configuration.html#plugins。
Executor 會攔截到CachingExcecutor 或者BaseExecutor。因為創建Executor 時是先創建CachingExcecutor,再包裝攔截。從代碼順序上能看到。我們可以通過mybatis的分頁插件來看看整個插件從包裝攔截器鏈到執行攔截器鏈的過程。
在查看插件原理的前提上,我們需要來看看官網對於自定義插件是怎么來做的,官網上有介紹:通過 MyBatis 提供的強大機制,使用插件是非常簡單的,只需實現 Interceptor 接口,並指定想要攔截的方法簽名即可。這里本人踩了一個坑,在Springboot中集成,同時引入了pagehelper-spring-boot-starter 導致RowBounds參數的值被刷掉了,也就是走到了我的攔截其中沒有被設置值,這里需要注意,攔截器出了問題,可以Debug看一下Configuration配置類中攔截器鏈的包裝情況。
@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 MyPageInterceptor implements Interceptor { // 用於覆蓋被攔截對象的原有方法(在調用代理對象Plugin 的invoke()方法時被調用) @Override public Object intercept(Invocation invocation) throws Throwable { System.out.println("將邏輯分頁改為物理分頁"); Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; // MappedStatement BoundSql boundSql = ms.getBoundSql(args[1]); // Object parameter RowBounds rb = (RowBounds) args[2]; // RowBounds // RowBounds為空,無需分頁 if (rb == RowBounds.DEFAULT) { return invocation.proceed(); }// 在SQL后加上limit語句 String sql = boundSql.getSql(); String limit = String.format("LIMIT %d,%d", rb.getOffset(), rb.getLimit()); sql = sql + " " + limit; // 自定義sqlSource SqlSource sqlSource = new StaticSqlSource(ms.getConfiguration(), sql, boundSql.getParameterMappings()); // 修改原來的sqlSource Field field = MappedStatement.class.getDeclaredField("sqlSource"); field.setAccessible(true); field.set(ms, sqlSource); // 執行被攔截方法 return invocation.proceed(); } // target 是被攔截對象,這個方法的作用是給被攔截對象生成一個代理對象,並返回它 @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } // 設置參數 @Override public void setProperties(Properties properties) { } }
插件注冊,在mybatis-config.xml 中注冊插件:
<plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="offsetAsPageNum" value="true"/> ……后面全部省略…… </plugin> </plugins>
攔截簽名跟參數的順序有嚴格要求,如果按照順序找不到對應方法會拋出異常:
org.apache.ibatis.exceptions.PersistenceException: ### Error opening session. Cause: org.apache.ibatis.plugin.PluginException: Could not find method on interface org.apache.ibatis.executor.Executor named query
MyBatis 啟動時掃描<plugins> 標簽, 注冊到Configuration 對象的 InterceptorChain 中。property 里面的參數,會調用setProperties()方法處理。
代理和攔截是怎么實現的?
上面提到的可以被代理的四大對象都是什么時候被代理的呢?Executor 是openSession() 的時候創建的; StatementHandler 是SimpleExecutor.doQuery()創建的;里面包含了處理參數的ParameterHandler 和處理結果集的ResultSetHandler 的創建,創建之后即調用InterceptorChain.pluginAll(),返回層層代理后的對象。代理是由Plugin 類創建。在我們重寫的 plugin() 方法里面可以直接調用returnPlugin.wrap(target, this);返回代理對象。
當個插件的情況下,代理能不能被代理?代理順序和調用順序的關系? 可以被代理。
因為代理類是Plugin,所以最后調用的是Plugin 的invoke()方法。它先調用了定義的攔截器的intercept()方法。可以通過invocation.proceed()調用到被代理對象被攔截的方法。
調用流程時序圖:
PageHelper 原理:
先來看一下分頁插件的簡單用法:
PageHelper.startPage(1, 3); List<Blog> blogs = blogMapper.selectBlogById2(blog); PageInfo page = new PageInfo(blogs, 3);
對於插件機制我們上面已經介紹過了,在這里我們自然的會想到其所涉及的核心類 :PageInterceptor。攔截的是Executor 的兩個query()方法,要實現分頁插件的功能,肯定是要對我們寫的sql進行改寫,那么一定是在 intercept 方法中進行操作的,我們會發現這么一行代碼:
String pageSql = this.dialect.getPageSql(ms, boundSql, parameter, rowBounds, cacheKey);
調用到 AbstractHelperDialect 中的 getPageSql 方法:
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
// 獲取sql String sql = boundSql.getSql();
//獲取分頁參數對象 Page page = this.getLocalPage(); return this.getPageSql(sql, page, pageKey); }
這里可以看到會去調用 this.getLocalPage(),我們來看看這個方法:
public <T> Page<T> getLocalPage() { return PageHelper.getLocalPage(); } //線程獨享 protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal(); public static <T> Page<T> getLocalPage() { return (Page)LOCAL_PAGE.get(); }
可以發現這里是調用的是PageHelper的一個本地線程變量中的一個 Page對象,從其中獲取我們所設置的 PageSize 與 PageNum,那么他是怎么設置值的呢?請看:
PageHelper.startPage(1, 3); public static <E> Page<E> startPage(int pageNum, int pageSize) { return startPage(pageNum, pageSize, true); } public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) { Page<E> page = new Page(pageNum, pageSize, count); page.setReasonable(reasonable); page.setPageSizeZero(pageSizeZero); Page<E> oldPage = getLocalPage(); if (oldPage != null && oldPage.isOrderByOnly()) { page.setOrderBy(oldPage.getOrderBy()); } //設置頁數,行數信息 setLocalPage(page); return page; } protected static void setLocalPage(Page page) {
//設置值 LOCAL_PAGE.set(page); }
在我們調用 PageHelper.startPage(1, 3); 的時候,系統會調用 LOCAL_PAGE.set(page) 進行設置,從而在分頁插件中可以獲取到這個本地變量對象中的參數進行 SQL 的改寫,由於改寫有很多實現,我們這里用的Mysql的實現:
在這里我們會發現分頁插件改寫SQL的核心代碼,這個代碼就很清晰了,不必過多贅述:
public String getPageSql(String sql, Page page, CacheKey pageKey) { StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14); sqlBuilder.append(sql); if (page.getStartRow() == 0) { sqlBuilder.append(" LIMIT "); sqlBuilder.append(page.getPageSize()); } else { sqlBuilder.append(" LIMIT "); sqlBuilder.append(page.getStartRow()); sqlBuilder.append(","); sqlBuilder.append(page.getPageSize()); pageKey.update(page.getStartRow()); } pageKey.update(page.getPageSize()); return sqlBuilder.toString(); }
PageHelper 就是這么一步一步的改寫了我們的SQL 從而達到一個分頁的效果。
關鍵類總結: