Spring原始碼剖析6:Spring AOP概述

a724888發表於2019-08-25

原文出處: 五月的倉頡

我們為什麼要使用 AOP

前言
一年半前寫了一篇文章Spring3:AOP,是當時學習如何使用Spring AOP的時候寫的,比較基礎。這篇文章最後的推薦以及回覆認為我寫的對大家有幫助的評論有很多,但是現在從我個人的角度來看,這篇文章寫得並不好,甚至可以說是沒有太多實質性的內容,因此這些推薦和評論讓我覺得受之有愧。

基於以上原因,更新一篇文章,從最基礎的原始程式碼–>使用設計模式(裝飾器模式與代理)–>使用AOP三個層次來講解一下為什麼我們要使用AOP,希望這篇文章可以對網友朋友們有益。

原始程式碼的寫法
既然要透過程式碼來演示,那必須要有例子,這裡我的例子為:

1
有一個介面Dao有insert、delete、update三個方法,在insert與update被呼叫的前後,列印呼叫前的毫秒數與呼叫後的毫秒數
首先定義一個Dao介面:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public interface Dao {
 
    public void insert();
     
    public void delete();
     
    public void update();
     
}

然後定義一個實現類DaoImpl:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class DaoImpl implements Dao {
 
    @Override
    public void insert() {
        System.out.println("DaoImpl.insert()");
    }
 
    @Override
    public void delete() {
        System.out.println("DaoImpl.delete()");
    }
 
    @Override
    public void update() {
        System.out.println("DaoImpl.update()");
    }
     
}

最原始的寫法,我要在呼叫insert()與update()方法前後分別列印時間,就只能定義一個新的類包一層,在呼叫insert()方法與update()方法前後分別處理一下,新的類我命名為ServiceImpl,其實現為:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class ServiceImpl {
 
    private Dao dao = new DaoImpl();
     
    public void insert() {
        System.out.println("insert()方法開始時間:" + System.currentTimeMillis());
        dao.insert();
        System.out.println("insert()方法結束時間:" + System.currentTimeMillis());
    }
     
    public void delete() {
        dao.delete();
    }
     
    public void update() {
        System.out.println("update()方法開始時間:" + System.currentTimeMillis());
        dao.update();
        System.out.println("update()方法結束時間:" + System.currentTimeMillis());
    }
     
}

這是最原始的寫法,這種寫法的缺點也是一目瞭然:

方法呼叫前後輸出時間的邏輯無法複用,如果有別的地方要增加這段邏輯就得再寫一遍
如果Dao有其它實現類,那麼必須新增一個類去包裝該實現類,這將導致類數量不斷膨脹
使用裝飾器模式
接著我們使用上設計模式,先用裝飾器模式,看看能解決多少問題。裝飾器模式的核心就是實現Dao介面並持有Dao介面的引用,我將新增的類命名為LogDao,其實現為:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class LogDao implements Dao {
 
    private Dao dao;
     
    public LogDao(Dao dao) {
        this.dao = dao;
    }
 
    @Override
    public void insert() {
        System.out.println("insert()方法開始時間:" + System.currentTimeMillis());
        dao.insert();
        System.out.println("insert()方法結束時間:" + System.currentTimeMillis());
    }
 
    @Override
    public void delete() {
        dao.delete();
    }
 
    @Override
    public void update() {
        System.out.println("update()方法開始時間:" + System.currentTimeMillis());
        dao.update();
        System.out.println("update()方法結束時間:" + System.currentTimeMillis());
    }
 
}

在使用的時候,可以使用”Dao dao = new LogDao(new DaoImpl())”的方式,這種方式的優點為:

透明,對呼叫方來說,它只知道Dao,而不知道加上了日誌功能
類不會無限膨脹,如果Dao的其它實現類需要輸出日誌,只需要向LogDao的建構函式中傳入不同的Dao實現類即可
不過這種方式同樣有明顯的缺點,缺點為:

輸出日誌的邏輯還是無法複用
輸出日誌的邏輯與程式碼有耦合,如果我要對delete()方法前後同樣輸出時間,需要修改LogDao
但是,這種做法相比最原始的程式碼寫法,已經有了很大的改進。

使用代理模式
接著我們使用代理模式嘗試去實現最原始的功能,使用代理模式,那麼我們就要定義一個InvocationHandler,我將它命名為LogInvocationHandler,其實現為:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class LogInvocationHandler implements InvocationHandler {
 
    private Object obj;
     
    public LogInvocationHandler(Object obj) {
        this.obj = obj;
    }
     
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String methodName = method.getName();
        if ("insert".equals(methodName) || "update".equals(methodName)) {
            System.out.println(methodName + "()方法開始時間:" + System.currentTimeMillis());
            Object result = method.invoke(obj, args);
            System.out.println(methodName + "()方法結束時間:" + System.currentTimeMillis());
             
            return result;
        }
         
        return method.invoke(obj, args);
    }
     
}

其呼叫方式很簡單,我寫一個main函式:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public static void main(String[] args) {
    Dao dao = new DaoImpl();
         
    Dao proxyDao = (Dao)Proxy.newProxyInstance(LogInvocationHandler.class.getClassLoader(), new Class<?>[]{Dao.class}, new LogInvocationHandler(dao));
         
    proxyDao.insert();
    System.out.println("----------分割線----------");
    proxyDao.delete();
    System.out.println("----------分割線----------");
    proxyDao.update();
}

結果就不演示了,這種方式的優點為:

輸出日誌的邏輯被複用起來,如果要針對其他介面用上輸出日誌的邏輯,只要在newProxyInstance的時候的第二個引數增加Class<?>陣列中的內容即可
這種方式的缺點為:

JDK提供的動態代理只能針對介面做代理,不能針對類做代理
程式碼依然有耦合,如果要對delete方法呼叫前後列印時間,得在LogInvocationHandler中增加delete方法的判斷
使用CGLIB
接著看一下使用CGLIB的方式,使用CGLIB只需要實現MethodInterceptor介面即可:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class DaoProxy implements MethodInterceptor {
 
    @Override
    public Object intercept(Object object, Method method, Object[] objects, MethodProxy proxy) throws Throwable {
        String methodName = method.getName();
         
        if ("insert".equals(methodName) || "update".equals(methodName)) {
            System.out.println(methodName + "()方法開始時間:" + System.currentTimeMillis());
            proxy.invokeSuper(object, objects);
            System.out.println(methodName + "()方法結束時間:" + System.currentTimeMillis());
             
            return object;
        }
         
        proxy.invokeSuper(object, objects);
        return object;
    }
 
}

程式碼呼叫方式為:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public static void main(String[] args) {
    DaoProxy daoProxy = new DaoProxy();
     
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(DaoImpl.class);
    enhancer.setCallback(daoProxy);
         
    Dao dao = (DaoImpl)enhancer.create();
    dao.insert();
    System.out.println("----------分割線----------");
    dao.delete();
    System.out.println("----------分割線----------");
    dao.update();
}

使用CGLIB解決了JDK的Proxy無法針對類做代理的問題,但是這裡要專門說明一個問題:使用裝飾器模式可以說是對使用原生程式碼的一種改進,使用Java代理可以說是對於使用裝飾器模式的一種改進,但是使用CGLIB並不是對於使用Java代理的一種改進。

前面的可以說改進是因為使用裝飾器模式比使用原生程式碼更好,使用Java代理又比使用裝飾器模式更好,但是Java代理與CGLIb的對比並不能說改進,因為使用CGLIB並不一定比使用Java代理更好,這兩種各有優缺點,像Spring框架就同時支援Java Proxy與CGLIB兩種方式。

從目前看來程式碼又更好了一些,但是我認為還有兩個缺點:

無論使用Java代理還是使用CGLIB,編寫這部分程式碼都稍顯麻煩
程式碼之間的耦合還是沒有解決,像要針對delete()方法加上這部分邏輯就必須修改程式碼

使用AOP

最後來看一下使用AOP的方式,首先定義一個時間處理類,我將它命名為TimeHandler:

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class TimeHandler {
     
    public void printTime(ProceedingJoinPoint pjp) {
        Signature signature = pjp.getSignature();
        if (signature instanceof MethodSignature) {
            MethodSignature methodSignature = (MethodSignature)signature;
            Method method = methodSignature.getMethod();
            System.out.println(method.getName() + "()方法開始時間:" + System.currentTimeMillis());
             
            try {
                pjp.proceed();
                System.out.println(method.getName() + "()方法結束時間:" + System.currentTimeMillis());
            } catch (Throwable e) {
                 
            }
        }
    }
     
}

到第8行的程式碼與第12行的程式碼分別列印方法開始執行時間與方法結束執行時間。我這裡寫得稍微複雜點,使用了 的寫法,其實也可以拆分為 與 兩種,這個看個人喜好。

這裡多說一句,切面方法printTime本身可以不用定義任何的引數,但是有些場景下需要獲取呼叫方法的類、方法簽名等資訊,此時可以在printTime方法中定義JointPoint,Spring會自動將引數注入,可以透過JoinPoint獲取呼叫方法的類、方法簽名等資訊。由於這裡我用的 ,要保證方法的呼叫,這樣才能在方法呼叫前後輸出時間,因此不能直接使用JoinPoint,因為JoinPoint沒法保證方法呼叫。此時可以使用ProceedingJoinPoint,ProceedingPointPoint的proceed()方法可以保證方法呼叫,但是要注意一點,ProceedingJoinPoint只能和 搭配,換句話說,如果aop.xml中配置的是 ,然後printTime的方法引數又是ProceedingJoinPoint的話,Spring容器啟動將報錯。

接著看一下aop.xml的配置:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="
    xmlns:xsi="
    xmlns:aop="
    xmlns:tx="
    xsi:schemaLocation="
 
/spring-beans-3.0.xsd
 
 

 
 
/spring-aop-3.0.xsd">
 
    <bean id="daoImpl" class="org.xrq.spring.action.aop.DaoImpl" />
    <bean id="timeHandler" class="org.xrq.spring.action.aop.TimeHandler" />
 
    <aop:config>
        <aop:pointcut id="addAllMethod" expression="execution(* org.xrq.spring.action.aop.Dao.*(..))" />
        <aop:aspect id="time" ref="timeHandler">
            <aop:before method="printTime" pointcut-ref="addAllMethod" />
            <aop:after method="printTime" pointcut-ref="addAllMethod" />
        </aop:aspect>
    </aop:config>
     
</beans>

我不大會寫expression,也懶得去百度了,因此這裡就攔截Dao下的所有方法了。測試程式碼很簡單:
=
/**
* @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
*/
public class AopTest {

    @Test
    @SuppressWarnings("resource")
    public void testAop() {
        ApplicationContext ac = new ClassPathXmlApplicationContext("spring/aop.xml");
         
        Dao dao = (Dao)ac.getBean("daoImpl");
        dao.insert();
        System.out.println("----------分割線----------");
        dao.delete();
        System.out.println("----------分割線----------");
        dao.update();
    }
     
}

AOP總結

結果就不演示了。到此我總結一下使用AOP的幾個優點:

切面的內容可以複用,比如TimeHandler的printTime方法,任何地方需要列印方法執行前的時間與方法執行後的時間,都可以使用TimeHandler的printTime方法
避免使用Proxy、CGLIB生成代理,這方面的工作全部框架去實現,開發者可以專注於切面內容本身
程式碼與程式碼之間沒有耦合,如果攔截的方法有變化修改配置檔案即可
下面用一張圖來表示一下AOP的作用:

我們傳統的程式設計方式是垂直化的程式設計,即A–>B–>C–>D這麼下去,一個邏輯完畢之後執行另外一段邏輯。但是AOP提供了另外一種思路,它的作用是在業務邏輯不知情(即業務邏輯不需要做任何的改動)的情況下對業務程式碼的功能進行增強,這種程式設計思想的使用場景有很多,例如事務提交、方法執行之前的許可權檢測、日誌列印、方法呼叫事件等等。

AOP使用場景舉例
上面的例子純粹為了演示使用,為了讓大家更加理解AOP的作用,這裡以實際場景作為例子。

第一個例子,我們知道MyBatis的事務預設是不會自動提交的,因此在程式設計的時候我們必須在增刪改完畢之後呼叫SqlSession的commit()方法進行事務提交,這非常麻煩,下面利用AOP簡單寫一段程式碼幫助我們自動提交事務(這段程式碼我個人測試過可用):

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class TransactionHandler {
 
    public void commit(JoinPoint jp) {
        Object obj = jp.getTarget();
        if (obj instanceof MailDao) {
            Signature signature = jp.getSignature();
            if (signature instanceof MethodSignature) {
                SqlSession sqlSession = SqlSessionThrealLocalUtil.getSqlSession();               
                 
                MethodSignature methodSignature = (MethodSignature)signature;
                Method method = methodSignature.getMethod();
                  
                String methodName = method.getName();
                if (methodName.startsWith("insert") || methodName.startsWith("update") || methodName.startsWith("delete")) {
                    sqlSession.commit();
                }
                 
                sqlSession.close();
            }
        }
    }
     
}

這種場景下我們要使用的aop標籤為 ,即切在方法呼叫之後。

這裡我做了一個SqlSessionThreadLocalUtil,每次開啟會話的時候,都透過SqlSessionThreadLocalUtil把當前會話SqlSession放到ThreadLocal中,看到透過TransactionHandler,可以實現兩個功能:

insert、update、delete操作事務自動提交
對SqlSession進行close(),這樣就不需要在業務程式碼裡面關閉會話了,因為有些時候我們寫業務程式碼的時候會忘記關閉SqlSession,這樣可能會造成記憶體控制程式碼的膨脹,因此這部分切面也一併做了
整個過程,業務程式碼是不知道的,而TransactionHandler的內容可以充分再多處場景下進行復用。

第二個例子是許可權控制的例子,不管是從安全形度考慮還是從業務角度考慮,我們在開發一個Web系統的時候不可能所有請求都對所有使用者開放,因此這裡就需要做一層許可權控制了,大家看AOP作用的時候想必也肯定會看到AOP可以做許可權控制,這裡我就演示一下如何使用AOP做許可權控制。我們知道原生的Spring MVC,Java類是實現Controller介面的,基於此,利用AOP做許可權控制的大致程式碼如下(這段程式碼純粹就是一段示例,我構建的Maven工程是一個普通的Java工程,因此沒有驗證過):

/**
 * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7003082.html
 */
public class PermissionHandler {
 
    public void hasPermission(JoinPoint jp) throws Exception {
        Object obj = jp.getTarget();
         
        if (obj instanceof Controller) {
            Signature signature = jp.getSignature();
            MethodSignature methodSignature = (MethodSignature)signature;
             
            // 獲取方法簽名
            Method method = methodSignature.getMethod();
            // 獲取方法引數
            Object[] args = jp.getArgs();
             
            // Controller中唯一一個方法的方法簽名ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
            // 這裡對這個方法做一層判斷
            if ("handleRequest".equals(method.getName()) && args.length == 2) {
                Object firstArg = args[0];
                if (obj instanceof HttpServletRequest) {
                    HttpServletRequest request = (HttpServletRequest)firstArg;
                    // 獲取使用者id
                    long userId = Long.parseLong(request.getParameter("userId"));
                    // 獲取當前請求路徑
                    String requestUri = request.getRequestURI();
                     
                    if(!PermissionUtil.hasPermission(userId, requestUri)) {
                        throw new Exception("沒有許可權");
                    }
                }
            }
        }
         
    }
     
}

毫無疑問這種場景下我們要使用的aop標籤為 。這裡我寫得很簡單,獲取當前使用者id與請求路徑,根據這兩者,判斷該使用者是否有許可權訪問該請求,大家明白意思即可。

後記
文章演示了從原生程式碼到使用AOP的過程,一點一點地介紹了每次演化的優缺點,最後以實際例子分析了AOP可以做什麼事情。

微信公眾號【黃小斜】作者是螞蟻金服 JAVA 工程師,專注於 JAVA
後端技術棧:SpringBoot、SSM全家桶、MySQL、分散式、中介軟體、微服務,同時也懂點投資理財,堅持學習和寫作,相信終身學習的力量!關注公眾號後回覆”架構師“即可領取
Java基礎、進階、專案和架構師等免費學習資料,更有資料庫、分散式、微服務等熱門技術學習影片,內容豐富,兼顧原理和實踐,另外也將贈送作者原創的Java學習指南、Java程式設計師面試指南等乾貨資源


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69906029/viewspace-2654866/,如需轉載,請註明出處,否則將追究法律責任。

相關文章