Mybatis自定義攔截器與插件開發

在Spring中我們經常會使用到攔截器,在登錄驗證、日志記錄、性能監控等場景中,通過使用攔截器允許我們在不改動業務代碼的情況下,執行攔截器的方法來增強現有的邏輯。在mybatis中,同樣也有這樣的業務場景,有時候需要我們在不侵入原有業務代碼的情況下攔截sql,執行特定的某些邏輯。那么這個過程應該怎么實現呢,同樣,在mybatis中也為開發者預留了攔截器接口,通過實現自定義攔截器這一功能,可以實現我們自己的插件,允許用戶在不改動mybatis的原有邏輯的條件下,實現自己的邏輯擴展。

那么,在實現攔截器之前,首先看一下攔截器的攔截目標對象是什么,以及攔截器的工作流程是怎樣的?

攔截器核心對象

mybatis攔截器可以對下面4種對象進行攔截:

1、Executor:mybatis的內部執行器,作為調度核心負責調用StatementHandler操作數據庫,并把結果集通過ResultSetHandler進行自動映射

2、StatementHandler: 封裝了JDBC Statement操作,是sql語法的構建器,負責和數據庫進行交互執行sql語句

3、ParameterHandler:作為處理sql參數設置的對象,主要實現讀取參數和對PreparedStatement的參數進行賦值

4、ResultSetHandler:處理Statement執行完成后返回結果集的接口對象,mybatis通過它把ResultSet集合映射成實體對象

工作流程

在mybatis中提供了一個Interceptor接口,通過實現該接口就能夠自定義攔截器,接口中定義了3個方法:

public interface Interceptor {
  Object intercept(Invocation invocation) throws Throwable;
  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }
  default void setProperties(Properties properties) {
    // NOP
  }
}
  • intercept:在攔截目標對象的方法時,實際執行的增強邏輯,我們一般在該方法中實現自定義邏輯

  • plugin:用于返回原生目標對象或它的代理對象,當返回的是代理對象的時候,會調用intercept方法

  • setProperties:可以用于讀取配置文件中通過property標簽配置的一些屬性,設置一些屬性變量

看一下plugin方法中的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;
}

可以看到,在wrap方法中,通過使用jdk動態代理的方式,生成了目標對象的代理對象,在執行實際方法前,先執行代理對象中的邏輯,來實現的邏輯增強。以攔截Executorquery方法為例,在實際執行前會執行攔截器中的intercept方法:

image

在mybatis中,不同類型的攔截器按照下面的順序執行:

Executor -> StatementHandler -> ParameterHandler -> ResultSetHandler

以執行query 方法為例對流程進行梳理,整體流程如下:

1、Executor執行query()方法,創建一個StatementHandler對象

2、StatementHandler 調用ParameterHandler對象的setParameters()方法

3、StatementHandler 調用 Statement對象的execute()方法

4、StatementHandler 調用ResultSetHandler對象的handleResultSets()方法,返回最終結果

攔截器能實現什么

在對mybatis攔截器有了初步的認識后,來看一下攔截器被普遍應用在哪些方面:

  • sql 語句執行監控

    可以攔截執行的sql方法,可以打印執行的sql語句、參數等信息,并且還能夠記錄執行的總耗時,可供后期的sql分析時使用

  • sql 分頁查詢

    mybatis中使用的RowBounds使用的內存分頁,在分頁前會查詢所有符合條件的數據,在數據量大的情況下性能較差。通過攔截器,可以做到在查詢前修改sql語句,提前加上需要的分頁參數

  • 公共字段的賦值

    在數據庫中通常會有createTimeupdateTime等公共字段,這類字段可以通過攔截統一對參數進行的賦值,從而省去手工通過set方法賦值的繁瑣過程

  • 數據權限過濾

    在很多系統中,不同的用戶可能擁有不同的數據訪問權限,例如在多租戶的系統中,要做到租戶間的數據隔離,每個租戶只能訪問到自己的數據,通過攔截器改寫sql語句及參數,能夠實現對數據的自動過濾

除此之外,攔截器通過對上述的4個階段的介入,結合我們的實際業務場景,還能夠實現很多其他功能。

插件定義與注冊

在我們自定義的攔截器類實現了Interceptor接口后,還需要在類上添加@Intercepts 注解,標識該類是一個攔截器類。注解中的內容是一個@Signature對象的數組,指明自定義攔截器要攔截哪一個類型的哪一個具體方法。其中type指明攔截對象的類型,method是攔截的方法,argsmethod執行的參數。通過這里可以了解到 mybatis 攔截器的作用目標是在方法級別上進行攔截,例如要攔截Executorquery方法,就在類上添加:

@Intercepts({
        @Signature(type = Executor.class,method = "query", args = { MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class })
})

如果要攔截多個方法,可以繼續以數組的形式往后追加。這里通過添加參數可以確定唯一的攔截方法,例如在Executor中存在兩個query方法,通過上面的參數可以確定要攔截的是下面的第2個方法:

<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql);
<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler);

當編寫完成我們自己的插件后,需要向mybatis中注冊插件,有兩種方式可以使用,第一種直接在SqlSessionFactory中配置:

@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
    SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
    sqlSessionFactoryBean.setDataSource(dataSource);
    sqlSessionFactoryBean.setPlugins(new Interceptor[]{new ExecutorPlugin()});
    return sqlSessionFactoryBean.getObject();
}

第2種是在mybatis-config.xml中對自定義插件進行注冊:

<configuration>
    <plugins>
        <plugin interceptor="com.cn.plugin.interceptor.MyPlugin">
            <property name="text" value="hello"/>
        </plugin>
        <plugin interceptor="com.cn.plugin.interceptor.MyPlugin2"></plugin>
        <plugin interceptor="com.cn.plugin.interceptor.MyPlugin3"></plugin>
    </plugins>
</configuration>

在前面我們了解了不同類型攔截器執行的固定順序,那么對于同樣類型的多個自定義攔截器,它們的執行順序是怎樣的呢?分別在plugin方法和intercept中添加輸出語句,運行結果如下:

image

從結果可以看到,攔截順序是按照注冊順序執行的,但代理邏輯的執行順序正好相反,最后注冊的會被最先執行。這是因為在mybatis中有一個類InterceptorChain,在它的pluginAll()方法中,會對原生對象target進行代理,如果有多個攔截器的話,會對代理類再次進行代理,最終實現一層層的增強target對象,因此靠后被注冊的攔截器的增強邏輯會被優先執行。從下面的圖中可以直觀的看出代理的嵌套關系:

image

xml中注冊完成后,在application.yml中啟用配置文件,這樣插件就可以正常運行了:

mybatis:
  config-location: classpath:mybatis-config.xml

在了解了插件的基礎概念與運行流程之后,通過代碼看一下應用不同的攔截器能夠實現什么功能。

攔截器使用示例

Executor

通過攔截Executorqueryupdate方法實現對sql的監控,在攔截方法中,打印sql語句、執行參數、實際執行時間:

@Intercepts({
        @Signature(type = Executor.class,method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class,method = "query", args = { MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class })})
public class ExecutorPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("Executor Plugin 攔截 :"+invocation.getMethod());
        Object[] queryArgs = invocation.getArgs();
        MappedStatement mappedStatement = (MappedStatement) queryArgs[0];
        //獲取 ParamMap
        MapperMethod.ParamMap paramMap = (MapperMethod.ParamMap) queryArgs[1];
        // 獲取SQL
        BoundSql boundSql = mappedStatement.getBoundSql(paramMap);
        String sql = boundSql.getSql();
        log.info("==> ORIGIN SQL: "+sql);
        long startTime = System.currentTimeMillis();
        Configuration configuration = mappedStatement.getConfiguration();
        String sqlId = mappedStatement.getId();

        Object proceed = invocation.proceed();
        long endTime=System.currentTimeMillis();
        long time = endTime - startTime;
        printSqlLog(configuration,boundSql,sqlId,time);
        return proceed;
    }

    public static void printSqlLog(Configuration configuration, BoundSql boundSql, String sqlId, long time){
        Object parameterObject = boundSql.getParameterObject();
        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        String sql= boundSql.getSql().replaceAll("[\\s]+", " ");
        StringBuffer sb=new StringBuffer("==> PARAM:");
        if (parameterMappings.size()>0 && parameterObject!=null){
            TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
            if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                sql = sql.replaceFirst("\\?", parameterObject.toString());
            } else {
                MetaObject metaObject = configuration.newMetaObject(parameterObject);
                for (ParameterMapping parameterMapping : parameterMappings) {
                    String propertyName = parameterMapping.getProperty();
                    if (metaObject.hasGetter(propertyName)) {
                        Object obj = metaObject.getValue(propertyName);
                        String parameterValue = obj.toString();
                        sql = sql.replaceFirst("\\?", parameterValue);
                        sb.append(parameterValue).append("(").append(obj.getClass().getSimpleName()).append("),");
                    } else if (boundSql.hasAdditionalParameter(propertyName)) {
                        Object obj = boundSql.getAdditionalParameter(propertyName);
                        String parameterValue = obj.toString();
                        sql = sql.replaceFirst("\\?", parameterValue);
                        sb.append(parameterValue).append("(").append(obj.getClass().getSimpleName()).append("),");
                    }
                }
            }
            sb.deleteCharAt(sb.length()-1);
        }
        log.info("==> SQL:"+sql);
        log.info(sb.toString());
        log.info("==> SQL TIME:"+time+" ms");
    }
}

執行代碼,日志輸出如下:

image

在上面的代碼中,通過Executor攔截器獲取到了BoundSql對象,進一步獲取到sql的執行參數,從而實現了對sql執行的監控與統計。

StatementHandler

下面的例子中,通過改變StatementHandler對象的屬性,動態修改sql語句的分頁:

@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class StatementPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {        
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        MetaObject metaObject = SystemMetaObject.forObject(statementHandler);            
        metaObject.setValue("delegate.rowBounds.offset", 0);
        metaObject.setValue("delegate.rowBounds.limit", 2);
        return invocation.proceed();
    }
}

MetaObject是mybatis提供的一個用于方便、優雅訪問對象屬性的對象,通過將實例對象作為參數傳遞給它,就可以通過屬性名稱獲取對應的屬性值。雖然說我們也可以通過反射拿到屬性的值,但是反射過程中需要對各種異常做出處理,會使代碼中堆滿難看的try/catch,通過MetaObject可以在很大程度上簡化我們的代碼,并且它支持對BeanCollectionMap三種類型對象的操作。

對比執行前后:

image

可以看到這里通過改變了分頁對象RowBounds的屬性,動態的修改了分頁參數。

ResultSetHandler

ResultSetHandler 會負責映射sql語句查詢得到的結果集,如果在生產環境中存在一些保密數據,不想在外部系統中展示,那么可能就需要在查詢到結果后做一下數據的脫敏處理,這時候就可以使用ResultSetHandler對結果集進行改寫。

@Intercepts({
        @Signature(type= ResultSetHandler.class,method = "handleResultSets",args = {Statement.class})})
public class ResultSetPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("Result Plugin 攔截 :"+invocation.getMethod());
        Object result = invocation.proceed();
        if (result instanceof Collection) {
            Collection<Object> objList= (Collection) result;
            List<Object> resultList=new ArrayList<>();
            for (Object obj : objList) {
                resultList.add(desensitize(obj));
            }
            return resultList;
        }else {
            return desensitize(result);
        }
    }
    //脫敏方法,將加密字段變為星號
    private Object desensitize(Object object) throws InvocationTargetException, IllegalAccessException {
        Field[] fields = object.getClass().getDeclaredFields();
        for (Field field : fields) {
            Confidential confidential = field.getAnnotation(Confidential.class);
            if (confidential==null){
                continue;
            }
            PropertyDescriptor ps = BeanUtils.getPropertyDescriptor(object.getClass(), field.getName());
            if (ps.getReadMethod() == null || ps.getWriteMethod() == null) {
                continue;
            }
            Object value = ps.getReadMethod().invoke(object);
            if (value != null) {
                ps.getWriteMethod().invoke(object, "***");
            }
        }
        return object;
    }
}

運行上面的代碼,查看執行結果:

{"id":1358041517788299266,"orderNumber":"***","money":122.0,"status":3,"tenantId":2}

在上面的例子中,在執行完sql語句得到結果對象后,通過反射掃描結果對象中的屬性,如果實體的屬性上帶有自定義的@Confidential注解,那么在脫敏方法中將它轉化為星號再返回結果,從而實現了數據的脫敏處理。

ParameterHandler

mybatis可以攔截ParameterHandler注入參數,下面的例子中我們將結合前面介紹的其他種類的對象,通過組合攔截器的方式,實現一個簡單的多租戶攔截器插件,實現多租戶下的查詢邏輯。

@Intercepts({
        @Signature(type = Executor.class,method = "query", args = { MappedStatement.class, Object.class,RowBounds.class, ResultHandler.class }),
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}),
        @Signature(type = ParameterHandler.class, method = "setParameters", args = PreparedStatement.class),
})
public class TenantPlugin implements Interceptor {
    private static final String TENANT_ID = "tenantId";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object target = invocation.getTarget();
        String methodName = invocation.getMethod().getName();
        if (target instanceof Executor &&  methodName.equals("query") && invocation.getArgs().length==4) {
            return doQuery(invocation);
        }
        if (target instanceof StatementHandler){
            return changeBoundSql(invocation);
        }
        if (target instanceof ParameterHandler){
            return doSetParameter(invocation);
        }
        return null;
    }

    private Object doQuery(Invocation invocation) throws Exception{
        Executor executor = (Executor) invocation.getTarget();
        MappedStatement ms= (MappedStatement) invocation.getArgs()[0];
        Object paramObj = invocation.getArgs()[1];
        RowBounds rowBounds = (RowBounds) invocation.getArgs()[2];

        if (paramObj instanceof Map){
            MapperMethod.ParamMap paramMap= (MapperMethod.ParamMap) paramObj;
            if (!paramMap.containsKey(TENANT_ID)){
                Long tenantId=1L;
                paramMap.put("param"+(paramMap.size()/2+1),tenantId);
                paramMap.put(TENANT_ID,tenantId);
                paramObj=paramMap;
            }
        }
        //直接執行query,不用proceed()方法
        return executor.query(ms, paramObj,rowBounds,null);
    }

    private Object changeBoundSql(Invocation invocation) throws Exception {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
        PreparedStatementHandler preparedStatementHandler = (PreparedStatementHandler) metaObject.getValue("delegate");
        String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
        metaObject.setValue("delegate.boundSql.sql",originalSql+ " and tenant_id=?");
        return invocation.proceed();
    }

    private Object doSetParameter(Invocation invocation) throws Exception {
        ParameterHandler parameterHandler = (ParameterHandler) invocation.getTarget();
        PreparedStatement ps = (PreparedStatement) invocation.getArgs()[0];
        MetaObject metaObject = SystemMetaObject.forObject(parameterHandler);
        BoundSql boundSql= (BoundSql) metaObject.getValue("boundSql");

        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        boolean hasTenantId=false;
        for (ParameterMapping parameterMapping : parameterMappings) {
            if (parameterMapping.getProperty().equals(TENANT_ID)) {
                hasTenantId=true;
            }
        }
        //添加參數
        if (!hasTenantId){
            Configuration conf= (Configuration) metaObject.getValue("configuration");
            ParameterMapping parameterMapping= new ParameterMapping.Builder(conf,TENANT_ID,Long.class).build();
            parameterMappings.add(parameterMapping);
        }
        parameterHandler.setParameters(ps);
        return null;
    }
}

在上面的過程中,攔截了sql執行的三個階段,來實現多租戶的邏輯,邏輯分工如下:

  • 攔截Executorquery方法,在查詢的參數Map中添加租戶的屬性值,這里只是簡單的對Map的情況作了判斷,沒有對Bean的情況進行設置
  • 攔截StatementHandlerprepare方法,改寫sql語句對象BoundSql,在sql語句中拼接租戶字段的查詢條件
  • 攔截ParameterHandlersetParameters方法,動態設置參數,將租戶id添加到要設置到參數列表中

最終通過攔截不同執行階段的組合,實現了基于租戶的條件攔截。

總結

總的來說,mybatis攔截器通過對ExecutorStatementHandlerParameterHandlerResultSetHandler 這4種接口中的方法進行攔截,并生成代理對象,在執行方法前先執行代理對象的邏輯,來實現我們自定義的邏輯增強。從上面的例子中,可以看到通過靈活使用mybatis攔截器開發插件能夠幫助我們解決很多問題,但是同樣它也是一把雙刃劍,在實際工作中也不要濫用插件、定義過多的攔截器,因為通過學習我們知道mybatis插件在執行中使用到了代理模式和責任鏈模式,在執行sql語句前會經過層層代理,如果代理次數過多將會消耗額外的性能,并增加響應時間。

如果文章對您有所幫助,歡迎關注公眾號 碼農參上

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,197評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,415評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,104評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,884評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,647評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,130評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,208評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,366評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,887評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,737評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,478評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,174評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,586評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,827評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,608評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,914評論 2 372

推薦閱讀更多精彩內容