Spring AOP 基本概念與代理模式 實現原理 及常用AspectJ註解

0. 背景

Spring框架用的時間蠻久了,但是一直都沒怎麼系統的學習,致使用法比較單一不靈活,也出過一些低級錯誤,是時候好好學習一下啦

1. AOP簡述

回到主題,何爲AOP?AOP即面向切面編程——Spring提供了面向切面編程的豐富支持,允許通過分離應用的業務邏輯與系統級服務(例如審計(auditing)和事務(transaction)管理)進行內聚性的開發。應用對象只實現它們應該做的——完成業務邏輯——僅此而已。它們並不負責(甚至是意識)其它的系統級關注點,例如日誌或事務支持。

如下圖,可以很直接明瞭的展示整個AOP的過程:

1.1 一些基本概念

  • 通知(Adivce)

    通知有5種類型:

    • Before 在方法被調用之前調用

    • After 在方法完成後調用通知,無論方法是否執行成功

    • After-returning 在方法成功執行之後調用通知

    • After-throwing 在方法拋出異常後調用通知

    • Around 通知了好、包含了被通知的方法,在被通知的方法調用之前後調用之後執行自定義的行爲

    我們可能會問,那通知對應系統中的代碼是一個方法、對象、類、還是接口什麼的呢?我想說一點,其實都不是,你可以理解通知就是對應我們日常生活中所說的通知,比如‘某某人,你2019年9月1號來學校報個到’,通知更多地體現一種告訴我們(告訴系統何)何時執行,規定一個時間,在系統運行中的某個時間點(比如拋異常啦!方法執行前啦!),並非對應代碼中的方法!並非對應代碼中的方法!並非對應代碼中的方法!

  • 切點(Pointcut)

    哈哈,這個你可能就比較容易理解了,切點在Spring AOP中確實是對應系統中的方法。但是這個方法是定義在切面中的方法,一般和通知一起使用,一起組成了切面。

  • 連接點(Join point)

    比如:方法調用、方法執行、字段設置/獲取、異常處理執行、類初始化、甚至是 for 循環中的某個點

    理論上, 程序執行過程中的任何時點都可以作爲作爲織入點, 而所有這些執行時點都是 Joint point

    但 Spring AOP 目前僅支持方法執行 (method execution) 也可以這樣理解,連接點就是你準備在系統中執行切點和切入通知的地方(一般是一個方法,一個字段)

  • 切面(Aspect)

    切面是切點和通知的集合,一般單獨作爲一個類。通知和切點共同定義了關於切面的全部內容,它是什麼時候,在何時和何處完成功能。

  • 引入(Introduction)

    引用允許我們向現有的類添加新的方法或者屬性

  • 織入(Weaving)

    組裝方面來創建一個被通知對象。這可以在編譯時完成(例如使用AspectJ編譯器),也可以在運行時完成。Spring和其他純Java AOP框架一樣,在運行時完成織入。

2. 代理模式

首先AOP思想的實現一般都是基於代理模式,在JAVA中一般採用JDK動態代理模式,但是我們都知道,JDK動態代理模式只能代理接口,如果要代理類那麼就不行了。因此,Spring AOP 會這樣子來進行切換,因爲Spring AOP 同時支持 CGLIB、ASPECTJ、JDK動態代理,當你的真實對象有實現接口時,Spring AOP會默認採用JDK動態代理,否則採用cglib代理。

  • 如果目標對象的實現類實現了接口,Spring AOP 將會採用 JDK 動態代理來生成 AOP 代理類;
  • 如果目標對象的實現類沒有實現接口,Spring AOP 將會採用 CGLIB 來生成 AOP 代理類——不過這個選擇過程對開發者完全透明、開發者也無需關心。

這裏簡單說說代理模式,代理模式的UML類圖如下:

2.1 靜態代理

接口類:

interface Person {
    void speak();
}

真實實體類:

class Actor implements Person {
    private String content;
    public Actor(String content) {
        this.content = content;
    }

    @Override
    public void speak() {
        System.out.println(this.content);
    }
}

代理類:

class Agent implements Person {
    private Actor actor;
    private String before;
    private String after;
    public Agent(Actor actor, String before, String after) {
        this.actor = actor;
        this.before = before;
        this.after = after;
    }
    @Override
    public void speak() {
        //before speak
        System.out.println("Before actor speak, Agent say: " + before);
        //real speak
        this.actor.speak();
        //after speak
        System.out.println("After actor speak, Agent say: " + after);
    }
}

測試方法:

public class StaticProxy {
    public static void main(String[] args) {
        Actor actor = new Actor("I am a famous actor!");
        Agent agent = new Agent(actor, "Hello I am an agent.", "That's all!");
        agent.speak();
    }
}

2.2 動態代理

在講JDK的動態代理方法之前,不妨先想想如果讓你來實現一個可以任意類的任意方法的代理類,該怎麼實現?有個很naive的做法,通過反射獲得Class和Method,再調用該方法,並且實現一些代理的方法。我嘗試了一下,很快就發現問題所在了。於是乎,還是使用JDK的動態代理接口吧。

JDK自帶方法

首先介紹一下最核心的一個接口和一個方法:

首先是java.lang.reflect包裏的InvocationHandler接口:

    public interface InvocationHandler {
        public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable;
    }

我們對於被代理的類的操作都會由該接口中的invoke方法實現,其中的參數的含義分別是:

  • proxy:被代理的類的實例
  • method:調用被代理的類的方法
  • args:該方法需要的參數

使用方法首先是需要實現該接口,並且我們可以在invoke方法中調用被代理類的方法並獲得返回值,自然也可以在調用該方法的前後去做一些額外的事情,從而實現動態代理,下面的例子會詳細寫到。

另外一個很重要的靜態方法是java.lang.reflect包中的Proxy類的newProxyInstance方法:

    public static Object newProxyInstance(ClassLoader loader,
                                          Class<?>[] interfaces,
                                          InvocationHandler h)
        throws IllegalArgumentException

其中的參數含義如下:

  • loader:被代理的類的類加載器
  • interfaces:被代理類的接口數組
  • invocationHandler:就是剛剛介紹的調用處理器類的對象實例

該方法會返回一個被修改過的類的實例,從而可以自由的調用該實例的方法。下面是一個實際例子。

Fruit接口:

    public interface Fruit {
        public void show();
    }

Apple實現Fruit接口:

    public class Apple implements Fruit{
        @Override
        public void show() {
            System.out.println("<<<<show method is invoked");
        }
    }

代理類Agent.java:

    public class DynamicAgent {
    
        //實現InvocationHandler接口,並且可以初始化被代理類的對象
        static class MyHandler implements InvocationHandler {
            private Object proxy;
            public MyHandler(Object proxy) {
                this.proxy = proxy;
            }
                
            //自定義invoke方法
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                System.out.println(">>>>before invoking");
                //真正調用方法的地方
                Object ret = method.invoke(this.proxy, args);
                System.out.println(">>>>after invoking");
                return ret;
            }
        }
    
        //返回一個被修改過的對象
        public static Object agent(Class interfaceClazz, Object proxy) {
            return Proxy.newProxyInstance(interfaceClazz.getClassLoader(), new Class[]{interfaceClazz},
                    new MyHandler(proxy));
        }    
    }

測試類:

    public class ReflectTest {
        public static void main(String[] args) throws InvocationTargetException, IllegalAccessException {
            //注意一定要返回接口,不能返回實現類否則會報錯
            Fruit fruit = (Fruit) DynamicAgent.agent(Fruit.class, new Apple());
            fruit.show();
        }
    }

結果:

可以看到對於不同的實現類來說,可以用同一個動態代理類來進行代理,實現了“一次編寫到處代理”的效果。但是這種方法有個缺點,就是被代理的類一定要是實現了某個接口的,這很大程度限制了本方法的使用場景。下面還有另外一個使用了CGlib增強庫的方法。

2.3 CGLIB庫的方法

CGlib是一個字節碼增強庫,爲AOP等提供了底層支持。下面看看它是怎麼實現動態代理的。

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;

public class CGlibAgent implements MethodInterceptor {

    private Object proxy;

    public Object getInstance(Object proxy) {
        this.proxy = proxy;
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(this.proxy.getClass());
        // 回調方法
        enhancer.setCallback(this);
        // 創建代理對象
        return enhancer.create();
    }
    //回調方法
    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println(">>>>before invoking");
        //真正調用
        Object ret = methodProxy.invokeSuper(o, objects);
        System.out.println(">>>>after invoking");
        return ret;
    }

    public static void main(String[] args) {
        CGlibAgent cGlibAgent = new CGlibAgent();
        Apple apple = (Apple) cGlibAgent.getInstance(new Apple());
        apple.show();
    }
}

3. Spring中的AOP: @AspectJ

3.1 @AspectJ 由來

AspectJ是一個AOP框架,它能夠對java代碼進行AOP編譯(一般在編譯期進行),讓java代碼具有AspectJ的AOP功能(當然需要特殊的編譯器),可以這樣說AspectJ是目前實現AOP框架中最成熟,功能最豐富的語言,更幸運的是,AspectJ與java程序完全兼容,幾乎是無縫關聯,因此對於有java編程基礎的工程師,上手和使用都非常容易。

其實AspectJ單獨就是一門語言,它需要專門的編譯器(ajc編譯器).
Spring AOP 與ApectJ的目的一致,都是爲了統一處理橫切業務,但與AspectJ不同的是,Spring AOP並不嘗試提供完整的AOP功能(即使它完全可以實現),Spring AOP 更注重的是與Spring IOC容器的結合,並結合該優勢來解決橫切業務的問題,因此在AOP的功能完善方面,相對來說AspectJ具有更大的優勢,同時,Spring注意到AspectJ在AOP的實現方式上依賴於特殊編譯器(ajc編譯器),因此Spring很機智迴避了這點,轉向採用動態代理技術的實現原理來構建Spring AOP的內部機制(動態織入),這是與AspectJ(靜態織入)最根本的區別。在AspectJ 1.5後,引入@Aspect形式的註解風格的開發,Spring也非常快地跟進了這種方式,因此Spring 2.0後便使用了與AspectJ一樣的註解。請注意,Spring 只是使用了與 AspectJ 5 一樣的註解,但仍然沒有使用 AspectJ 的編譯器,底層依是動態代理技術的實現,因此並不依賴於 AspectJ 的編譯器

所以,Spring AOP雖然是使用了AspectJ那一套註解,其實實現AOP的底層是使用了動態代理(JDK或者CGLib)來動態植入

3.2 舉個栗子

小狗類,會說話:

public class Dog {

    private String name;


    public void say(){
        System.out.println(name + "在汪汪叫!...");
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

切面類:

@Aspect //聲明自己是一個切面類
public class MyAspect {
    /**
     * 前置通知
     */
     //@Before是增強中的方位
     // @Before括號中的就是切入點了
     //before()就是傳說的增強(建言):說白了,就是要幹啥事.
    @Before("execution(* com.zdy..*(..))")
    public void before(){
        System.out.println("前置通知....");
    }
}

這個類是重點,先用@Aspect聲明自己是切面類,然後before()爲增強,@Before(方位)+切入點可以具體定位到具體某個類的某個方法的方位. Spring配置文件:

    //開啓AspectJ功能.
    <aop:aspectj-autoproxy />

    <bean id="dog" class="com.zdy.Dog" />
    <!-- 定義aspect類 -->
    <bean name="myAspect" class="com.zdy.MyAspect"/>

然後Main方法:

        ApplicationContext ac =new ClassPathXmlApplicationContext("applicationContext.xml");
        Dog dog =(Dog) ac.getBean("dog");
        System.out.println(dog.getClass());
        dog.say();

輸出結果:

class com.zdy.Dog$$EnhancerBySpringCGLIB$$80a9ee5f
前置通知....
null在汪汪叫!...

說白了,就是把切面類丟到容器,開啓一個AdpectJ的功能,Spring AOP就會根據切面類中的(@Before+切入點)定位好具體的類的某個方法(我這裏定義的是com.zdy包下的所有類的所有方法),然後把增強before()切入進去.

3.3 舉個Spring Boot中的栗子

這個栗子很實用,關於Aop做切面去統一處理Web請求的日誌:

@Aspect
@Component
public class WebLogAspect {

    private Logger logger = Logger.getLogger(getClass());

    @Pointcut("execution(public * com.didispace.web..*.*(..))")
    public void webLog(){}

    @Before("webLog()")
    public void doBefore(JoinPoint joinPoint) throws Throwable {
        // 接收到請求,記錄請求內容
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        // 記錄下請求內容
        logger.info("URL : " + request.getRequestURL().toString());
        logger.info("HTTP_METHOD : " + request.getMethod());
        logger.info("IP : " + request.getRemoteAddr());
        logger.info("CLASS_METHOD : " + joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName());
        logger.info("ARGS : " + Arrays.toString(joinPoint.getArgs()));

    }

    @AfterReturning(returning = "ret", pointcut = "webLog()")
    public void doAfterReturning(Object ret) throws Throwable {
        // 處理完請求,返回內容
        logger.info("RESPONSE : " + ret);
    }

}

可以看上面的例子,通過@Pointcut定義的切入點爲com.didispace.web包下的所有函數(對web層所有請求處理做切入點),然後通過@Before實現,對請求內容的日誌記錄(本文只是說明過程,可以根據需要調整內容),最後通過@AfterReturning記錄請求返回的對象。

通過運行程序並訪問:http://localhost:8080/hello?name=didi,可以獲得下面的日誌輸出

2016-05-19 13:42:13,156  INFO WebLogAspect:41 - URL : http://localhost:8080/hello
2016-05-19 13:42:13,156  INFO WebLogAspect:42 - HTTP_METHOD : http://localhost:8080/hello
2016-05-19 13:42:13,157  INFO WebLogAspect:43 - IP : 0:0:0:0:0:0:0:1
2016-05-19 13:42:13,160  INFO WebLogAspect:44 - CLASS_METHOD : com.didispace.web.HelloController.hello
2016-05-19 13:42:13,160  INFO WebLogAspect:45 - ARGS : [didi]
2016-05-19 13:42:13,170  INFO WebLogAspect:52 - RESPONSE:Hello didi

3.4 Spring AOP支持的幾種AspectJ註解

  • 前置通知@Before: 前置通知通過@Before註解進行標註,並可直接傳入切點表達式的值,該通知在目標函數執行前執行,注意JoinPoint,是Spring提供的靜態變量,通過joinPoint 參數,可以獲取目標對象的信息,如類名稱,方法參數,方法名稱等,該參數是可選的。
@Before("execution(...)")
public void before(JoinPoint joinPoint){
    System.out.println("...");
}
  • 後置通知@AfterReturning: 通過@AfterReturning註解進行標註,該函數在目標函數執行完成後執行,並可以獲取到目標函數最終的返回值returnVal,當目標函數沒有返回值時,returnVal將返回null,必須通過returning = “returnVal”註明參數的名稱而且必須與通知函數的參數名稱相同。請注意,在任何通知中這些參數都是可選的,需要使用時直接填寫即可,不需要使用時,可以完成不用聲明出來。
@AfterReturning(value="execution(...)",returning = "returnVal")
public void AfterReturning(JoinPoint joinPoint,Object returnVal){
   System.out.println("我是後置通知...returnVal+"+returnVal);
}
  • 異常通知 @AfterThrowing:該通知只有在異常時纔會被觸發,並由throwing來聲明一個接收異常信息的變量,同樣異常通知也用於Joinpoint參數,需要時加上即可.
@AfterThrowing(value="execution(....)",throwing = "e")
public void afterThrowable(Throwable e){
  System.out.println("出現異常:msg="+e.getMessage());
}
  • 最終通知 @After:該通知有點類似於finally代碼塊,只要應用了無論什麼情況下都會執行.
@After("execution(...)")
public void after(JoinPoint joinPoint) {
    System.out.println("最終通知....");
}
  • 環繞通知 @Around: 環繞通知既可以在目標方法前執行也可在目標方法之後執行,更重要的是環繞通知可以控制目標方法是否指向執行,但即使如此,我們應該儘量以最簡單的方式滿足需求,在僅需在目標方法前執行時,應該採用前置通知而非環繞通知。案例代碼如下第一個參數必須是ProceedingJoinPoint,通過該對象的proceed()方法來執行目標函數,proceed()的返回值就是環繞通知的返回值。同樣的,ProceedingJoinPoint對象也是可以獲取目標對象的信息,如類名稱,方法參數,方法名稱等等
@Around("execution(...)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("我是環繞通知前....");
    //執行目標函數
    Object obj= (Object) joinPoint.proceed();
    System.out.println("我是環繞通知後....");
    return obj;
}

然後說下一直用"…"忽略掉的切入點表達式,這個表達式可以不是exection(…),還有其他的一些,我就不說了,說最常用的execution:

//scope :方法作用域,如public,private,protect
//returnt-type:方法返回值類型
//fully-qualified-class-name:方法所在類的完全限定名稱
//parameters 方法參數
execution(<scope> <return-type> <fully-qualified-class-name>.*(parameters))
<fully-qualified-class-name>.*(parameters)

注意這一塊,如果沒有精確到class-name,而是到包名就停止了,要用兩個"…"來表示包下的任意類:

  • execution(* com.zdy…*(…)):com.zdy包下所有類的所有方法.
  • execution(* com.zdy.Dog.*(…)): Dog類下的所有方法.

具體詳細語法,大家如果有需求自行google了,我最常用的就是這倆了。要麼按照包來定位,要麼按照具體類來定位.

在使用切入點時,還可以抽出來一個@Pointcut來供使用:

/**
 * 使用Pointcut定義切點
 */
@Pointcut("execution(...)")
private void myPointcut(){}

/**
 * 應用切入點函數
 */
@After(value="myPointcut()")
public void afterDemo(){
    System.out.println("最終通知....");
}

可以避免重複的execution在不同的註解裏寫很多遍…

3.5 AOP切面的優先級

由於通過AOP實現,程序得到了很好的解耦,但是也會帶來一些問題,比如:我們可能會對Web層做多個切面,校驗用戶,校驗頭信息等等,這個時候經常會碰到切面的處理順序問題。

所以,我們需要定義每個切面的優先級,我們需要@Order(i)註解來標識切面的優先級。i的值越小,優先級越高。假設我們還有一個切面是CheckNameAspect用來校驗name必須爲derry,我們爲其設置@Order(10),而上文中WebLogAspect設置爲@Order(5),所以WebLogAspect有更高的優先級,這個時候執行順序是這樣的:

  • @Before中優先執行@Order(5)的內容,再執行@Order(10)的內容
  • @After@AfterReturning中優先執行@Order(10)的內容,再執行@Order(5)的內容

所以我們可以這樣子總結:

  • 在切入點前的操作,按order的值由小到大執行
  • 在切入點後的操作,按order的值由大到小執行

Ref

  1. https://github.com/Snailclimb/JavaGuide/blob/master/docs/system-design/framework/spring/Spring.md
  2. https://juejin.im/post/5aa7818af265da23844040c6
  3. https://www.cnblogs.com/puyangsky/p/6218925.html
  4. https://juejin.im/post/5a55af9e518825734d14813f
  5. http://blog.didispace.com/springbootaoplog/
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章