漫畫:AOP 面試造火箭事件始末

這是一個困擾我司由來已久的難題,Dubbo 瞭解過吧,對外提供的服務可能有多個方法,一般我們爲了不給調用方埋坑,會在每個方法裏把所有異常都 catch 住,只返回一個 result,調用方會根據這個 result 裏的 success 判斷此次調用是否成功,舉個例子

public class ServiceResultTO<Textends Serializable {
   private static final long serialVersionUID = xxx;
   private Boolean success;
   private String message;
   private T data;
}

public interface TestService {
   ServiceResultTO<Boolean> test();
}

public class TestServiceImpl implements TestService {
   @Override
   public ServiceResultTO<Boolean> test() {
       try {
           // 此處寫服務裏的執行邏輯
           return ServiceResultTO.buildSuccess(Boolean.TRUE);
       } catch(Exception e) {
         return ServiceResultTO.buildFailed(Boolean.FALSE, "執行失敗");            
       }
   }
}

比如現在以上這樣的 dubbo 服務(TestService),它有一個 test 方法,爲了執行正常邏輯時出現異常,我們在此方法執行邏輯外包了一層「try... catch...」如果只有一個 test 方法,這樣做當然沒問題,但問題是在工程裏我們一般要要提供幾十上百個 service,每個 service 有幾十個像 test 這樣的方法,如果每個方法都要在執行的時候包一層 「try ...catch...」,雖然可行,但代碼會比較醜陋,可讀性也比較差,你能想想辦法改進一下嗎?

既然是用切面解決的,我先解釋下什麼是切面。我們知道,面向對象將程序抽象成多個層次的對象,每個對象負責不同的模塊,這樣的話各個對象分工明確,各司其職,也不互相藕合,確實有力地促進了工程開發與分工協作,但是新的問題來了,不同的模塊(對象)間有時會出現公共的行爲,這種公共的行爲很難通過繼承的方式來實現,如果用工具類的話也不利於維護,代碼也顯得異常繁瑣。切面(AOP)的引入就是爲了解決這類問題而生的,它要達到的效果是保證開發者在不修改源代碼的前提下,爲系統中不同的業務組件添加某些通用功能。

舉個例子來說說

比如上面這個例子,三個 service 對象執行過程中都存在安全,事務,緩存,性能等相同行爲,這些相同的行爲顯然應該在同一個地方管理,有人說我可以寫一個統一的工具類,在這些對象的方法前/後都嵌入此工具類,那問題來了,這些行爲都屬於業務無關的,使用工具類嵌入的方式導致與業務代碼緊藕合,很不合工程規範,代碼可維護性極差!切面就是爲了解決此類問題應運而生的,能做到相同功能的統一管理,對業務代碼無侵入

以性能爲例,這些對象負責的模塊存在哪些相似的功能呢

比如說吧,每個 service 都有不同的方法,我想統計每個方法的執行時間,如果不用切面你需要在每個方法的首尾計算下時間,然後相減

如果我要統計每一個 service 中每個方法的執行時間可想而知不用切面的話就得在每個方法的首尾都加上類似上述的邏輯,顯然這樣的代碼可維護性是非常差的,這還只是統計時間,如果此方法又要加上事務,風控等,是不是也得在方法首尾加上事務開始,回滾等代碼,可想而知業務代碼與非業務代碼嚴重藕合,這樣的實現方式對工程是一種災難,是不能接受的!

那如果用切面該怎麼做呢

在說解決方案前,首先我們要看下與切面相關的幾個定義JoinPoint: 程序在執行流程中經過的一個個時間點,這個時間點可以是方法調用時,或者是執行方法中異常拋出時,也可以是屬性被修改時等時機,在這些時間點上你的切面代碼是可以(注意是可以但未必)被注入的

Pointcut: JoinPoints 只是切面代碼可以被織入的地方,但我並不想對所有的 JoinPoint 進行織入,這就需要某些條件來篩選出那些需要被織入的 JoinPoint,Pointcut 就是通過一組規則(使用 AspectJ pointcut expression language 來描述) 來定位到匹配的 joinpoint

Advice:  代碼織入(也叫增強),Pointcut 通過其規則指定了哪些 joinpoint 可以被織入,而 Advice 則指定了這些 joinpoint 被織入(或者增強)的具體時機與邏輯,是切面代碼真正被執行的地方,主要有五個織入時機

  1. Before Advice: 在 JoinPoints 執行前織入
  2. After Advice: 在 JoinPoints 執行後織入(不管是否拋出異常都會織入)
  3. After returning advice: 在 JoinPoints 執行正常退出後織入(拋出異常則不會被織入)
  4. After throwing advice: 方法執行過程中拋出異常後織入
  5. Around Advice: 這是所有 Advice 中最強大的,它在 JoinPoints 前後都可織入切面代碼,也可以選擇是否執行原有正常的邏輯,如果不執行原有流程,它甚至可以用自己的返回值代替原有的返回值,甚至拋出異常。在這些 advice 裏我們就可以寫入切面代碼了 綜上所述,切面(Aspect)我們可以認爲就是 pointcut 和 advice,pointcut 指定了哪些 joinpoint 可以被織入,而 advice 則指定了在這些 joinpoint 上的代碼織入時機與邏輯
畫外音:織入(weaving),將切面作用於委託類對象以創建 adviced object 的過程(即代理,下文會提)

列了一大堆概念真讓人生氣,請用你奶奶都能聽得懂的語言來解釋一下這些概念!

把技術解釋得讓非技術的人也聽懂才叫本事,這才說明你真的懂了。

這也難不倒我,比如在餐館裏點菜,菜單有 10 個菜,這 10 個菜就是 JoinPoint,但我只點了帶有蘿蔔名字的菜,那麼帶有蘿蔔名字這個條件就是針對 JoinPoint(10 個菜)的篩選條件,即 pointcut,最終只有胡蘿蔔,白蘿蔔這兩個 JoinPoint 滿足條件,然後我們就可以在喫胡蘿蔔前洗手(before advice),或喫胡蘿蔔後買單(after advice),也可以統計喫胡蘿蔔的時間(around advice),這些洗手,買單,統計時間的動作都是與喫蘿蔔這個業務動作解藕的,都是統一寫在 advice 的邏輯裏

能否用程序實現一下,talk is cheap, show me your code!

好嘞,讓你看下我的實力

 public interface TestService {
   // 喫蘿蔔
   void eatCarrot();

   // 喫蘑菇
   void eatMushroom();

   // 喫白菜
   void eatCabbage();
}
@Component
public class TestServiceImpl implements TestService {
   @Override
   public void eatCarrot() {
       System.out.println("喫蘿蔔");
   }

   @Override
   public void eatMushroom() {
       System.out.println("喫蘑菇");
   }

   @Override
   public void eatCabbage() {
       System.out.println("喫白菜");
   }
}

假設有以上 TestService, 實現了喫蘿蔔,喫蘑菇,喫白菜三個方法,這三個方法都用切面織入,所以它們都是 joinpoints,但現在我只想對喫蘿蔔這個 joinpoints 前後織入 advice,該怎麼辦呢,首先當然要聲明 pointcut 表達式,這個表達式表明只想織入喫蘿蔔這個 joinpoint,指明瞭之後再讓 advice 應用於此 pointcut 不就完了,比如我想在喫蘿蔔前洗手,喫蘿蔔後買單,可以寫出如下切面邏輯

@Aspect
@Component
public class TestAdvice {
   // 1. 定義 PointCut
   @Pointcut("execution(* com.example.demo.api.TestServiceImpl.eatCarrot())")
   private void eatCarrot(){}

   // 2. 定義應用於 JoinPoint 中所有滿足 PointCut 條件的 advice, 這裏我們使用 around advice,在其中織入增強邏輯
   @Around("eatCarrot()")
   public void handlerRpcResult(ProceedingJoinPoint point) throws Throwable {
       System.out.println("喫蘿蔔前洗手");
       //  原來的 TestServiceImpl.eatCarrot 邏輯,可視情況決定是否執行
       point.proceed();
       System.out.println("喫蘿後買單");
   }
}

可以看到通過 AOP 我們巧妙地在方法執行前後執行插入相關的邏輯,對原有執行邏輯無任何侵入!

小子果然有兩把刷子,我們 HR 眼光不錯,還有一個問題,開頭我司的那個難題你用切面又是如何解決的呢。

這就要說到 PointCut 的 AspectJ pointcut expression language 聲明式表達式,這個表達式支持的類型比較全面,可以用正則,註解等來指定滿足條件的 joinpoint , 比如類名後加 .*(..) 這樣的正則表達式就代表這個類裏面的所有方法都會被織入,使用 @annotation 的方式也可以指定對標有這類註解的方法織入代碼

恩,可以,繼續

首先我們先定義一個如下註解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface GlobalErrorCatch {

}

然後將所有 service 中方法裏的 「try... catch...」移除掉,在方法簽名上加上上述我們定義好的註解

public class TestServiceImpl implements TestService {
   @Override
   @GlobalErrorCatch
   public ServiceResultTO<Boolean> test() {
        // 此處寫服務裏的執行邏輯
        boolean result = xxx;
        return ServiceResultTO.buildSuccess(result);
   }
}

然後再指定註解形式的 pointcuts 及 around advice

@Aspect
@Component
public class TestAdvice {
   // 1. 定義所有帶有 GlobalErrorCatch 的註解的方法爲 Pointcut
   @Pointcut("@annotation(com.example.demo.annotation.GlobalErrorCatch)")
   private void globalCatch(){}
   // 2. 將 around advice 作用於 globalCatch(){} 此 PointCut 
   @Around("globalCatch()")
   public Object handlerGlobalResult(ProceedingJoinPoint point) throws Throwable {
       try {
           return point.proceed();
       } catch (Exception e) {
           System.out.println("執行錯誤" + e);
           return ServiceResultTO.buildFailed("系統錯誤");
       }
   }

}

通過這樣的方式,所有標記着 GlobalErrorCatch 註解的方法都會統一在 handlerGlobalResult 方法裏執行,我們就可以在這個方法裏統一 catch 住異常,所有 service 方法中又長又臭的 「try...catch...」全部幹掉,真香!

按照大佬提供的思路,我首先打印了 TestServiceImp 這個 bean 所屬的類

@Component
public class TestServiceImpl implements TestService {
   @Override
   public void eatCarrot() {
       System.out.println("喫蘿蔔");
   }
}
@Aspect
@Component
public class TestAdvice {
   // 1. 定義 PointCut
   @Pointcut("execution(* com.example.demo.api.TestServiceImpl.eatCarrot())")
   private void eatCarrot(){}

   // 2. 定義應用於 PointCut 的 advice, 這裏我們使用 around advice
   @Around("eatCarrot()")
   public void handlerRpcResult(ProceedingJoinPoint point) throws Throwable {
        // 省略相關邏輯
   }
}
@SpringBootApplication
@EnableAspectJAutoProxy
public class DemoApplication {
   public static void main(String[] args) {
       ConfigurableApplicationContext context = SpringApplication.run(DemoApplication.classargs);
       TestService testService = context.getBean(TestService.class);
       System.out.println("testService = " + testService.getClass());
   }
}

打印後我果然發現了端倪,這個 bean 的 class 居然不是 TestServiceImpl!而是com.example.demo.impl.TestServiceImpl EnhancerBySpringCGLIB$$705c68c7!

果然有長進,繼續說,爲啥會生成這樣一個類

我們注意到類名中有一個 EnhancerBySpringCGLIB ,注意 CGLiB,這個類就是通過它生成的動態代理

打住,先不要說動態代理,先談談啥是代理吧

代理在生活中隨處可見,比如說我要買房,我一般不會直接和賣家對接,一般會和中介打交道,中介就是代理,賣家就是目標對象,我就是調用者,代理不僅實現了目標對象的行爲(幫目標對象賣房),還可以添加上自己的動作(收保證金,籤合同等),用 UML 圖來表示就是下面這樣Client 是直接和 Proxy 打交道的,Proxy 是 Client 要真正調用的 RealSubject 的代理,它確實執行了 RealSubject 的 request 方法,不過在這個執行前後 Proxy 也加上了額外的 PreRequest(),afterRequest() 方法,注意 Proxy 和 RealSubject 都實現了 Subject 這個接口,這樣在 Client 看起來調用誰是沒有什麼分別的(面向接口編程,對調用方無感,因爲實現的接口方法是一樣的),Proxy 通過其屬性持有真正要代理的目標對象(RealSubject)以達到既能調用目標對象的方法也能在方法前後注入其它邏輯的目的

聽得我要睡着了,根據這個 UML 來寫下相應的實現類吧

沒問題,不過在此之前我要先介紹一下代理的類型,代理主要分爲兩種類型:靜態代理和動態代理,動態代理又有 JDK 代理和 CGLib 代理兩種,我先解釋下靜態和動態的含義

好小子,邏輯清晰,繼續吧

要理解靜態和動態這兩個含義,我們首先需要理解一下 Java 程序的運行機制首先 Java 源代碼經過編譯生成字節碼,然後再由 JVM 經過類加載,連接,初始化成 Java 類型,可以看到字節碼是關鍵,靜態和動態的區別就在於字節碼生成的時機靜態代理:由程序員創建代理類或特定工具自動生成源代碼再對其編譯。在編譯時已經將接口,被代理類(委託類),代理類等確定下來,在程序運行前代理類的.class文件就已經存在了動態代理:在程序運行後通過反射創建生成字節碼再由 JVM 加載而成

好,那你寫下靜態代理吧

嘿嘿按這張 UML 類庫依葫蘆畫瓢,傻瓜也會

public interface Subject {
   public void request();
}

public class RealSubject implements Subject {
   @Override
   public void request() {
       // 賣房
       System.out.println("賣房");
   }
}

public class Proxy implements Subject {

   private RealSubject realSubject;

   public Proxy(RealSubject subject) {
       this.realSubject = subject;
   }


   @Override
   public void request() {
    // 執行代理邏輯
       System.out.println("賣房前");

       // 執行目標對象方法
       realSubject.request();

       // 執行代理邏輯
       System.out.println("賣房後");
   }

   public static void main(String[] args) {
       // 被代理對象
       RealSubject subject = new RealSubject();

       // 代理
       Proxy proxy = new Proxy(subject);

       // 代理請求
       proxy.request();
   }
}

喲喲喲,"傻瓜也會",看把你能的,那你說下靜態代理有啥劣勢

靜態代理主要有兩大劣勢

  1. 代理類只代理一個委託類(其實可以代理多個,但不符合單一職責原則),也就意味着如果要代理多個委託類,就要寫多個代理(別忘了靜態代理在編譯前必須確定)
  2. 第一點還不是致命的,再考慮這樣一種場景:如果每個委託類的每個方法都要被織入同樣的邏輯,比如說我要計算前文提到的每個委託類每個方法的耗時,就要在方法開始前,開始後分別織入計算時間的代碼,那就算用代理類,它的方法也有無數這種重複的計算時間的代碼

回答的不錯,那該怎麼改進

嘿嘿,這就要提到動態代理了,靜態代理的這些劣勢主要是是因爲在編譯前這些代理類是確定的,如果這些代理類是動態生成的呢,是不是可以省略一大堆代理的代碼。

給你 5 分鐘你先寫一下 JDK 的動態代理並解釋其原理

動態代理分爲 JDK 提供的動態代理和 Spring AOP 用到的 CGLib 生成的代理,我們先看下 JDK 提供的動態代理該怎麼寫

這是代碼

// 委託類
public class RealSubject implements Subject {
   @Override
   public void request() {
       // 賣房
       System.out.println("賣房");
   }
}


import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class ProxyFactory {

   private Object target;// 維護一個目標對象

   public ProxyFactory(Object target) {
       this.target = target;
   }

   // 爲目標對象生成代理對象
   public Object getProxyInstance() {
       return Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(),
               new InvocationHandler() {

                   @Override
                   public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                       System.out.println("計算開始時間");
                       // 執行目標對象方法
                       method.invoke(target, args);
                       System.out.println("計算結束時間");
                       return null;
                   }
               });
   }

   public static void main(String[] args) {
       RealSubject realSubject = new RealSubject();
       System.out.println(realSubject.getClass());
       Subject subject = (Subject) new ProxyFactory(realSubject).getProxyInstance();
       System.out.println(subject.getClass());
       subject.request();
   }
}```
打印結果如下:
```shell
原始類:class com.example.demo.proxy.staticproxy.RealSubject
代理類:class com.sun.proxy.$Proxy0
計算開始時間
賣房
計算結束時間

我們注意到代理類的 class 爲 com.sun.proxy.$Proxy0,它是如何生成的呢,注意到 Proxy 是在 java.lang.reflect 反射包下的,注意看看 Proxy 的 newProxyInstance 簽名

public static Object newProxyInstance(ClassLoader loader,
                                         Class<?>[] interfaces,
                                         InvocationHandler h)
;
  1. loader: 代理類的ClassLoader,最終讀取動態生成的字節碼,並轉成 java.lang.Class 類的一個實例(即類),通過此實例的 newInstance() 方法就可以創建出代理的對象
  2. interfaces: 委託類實現的接口,JDK 動態代理要實現所有的委託類的接口
  3. InvocationHandler: 委託對象所有接口方法調用都會轉發到 InvocationHandler.invoke(),在 invoke() 方法裏我們可以加入任何需要增強的邏輯 主要是根據委託類的接口等通過反射生成的

這樣的實現有啥好處呢

由於動態代理是程序運行後才生成的,哪個委託類需要被代理到,只要生成動態代理即可,避免了靜態代理那樣的硬編碼,另外所有委託類實現接口的方法都會在 Proxy 的 InvocationHandler.invoke() 中執行,這樣如果要統計所有方法執行時間這樣相同的邏輯,可以統一在 InvocationHandler 裏寫, 也就避免了靜態代理那樣需要在所有的方法中插入同樣代碼的問題,代碼的可維護性極大的提高了。

說得這麼厲害,那麼 Spring AOP 的實現爲啥卻不用它呢

JDK 動態代理雖好,但也有弱點,我們注意到 newProxyInstance 的方法簽名

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

注意第二個參數 Interfaces 是委託類的接口,是必傳的, JDK 動態代理是通過與委託類實現同樣的接口,然後在實現的接口方法裏進行增強來實現的,這就意味着如果要用 JDK 代理,委託類必須實現接口,這樣的實現方式看起來有點蠢,更好的方式是什麼呢,直接繼承自委託類不就行了,這樣委託類的邏輯不需要做任何改動,CGlib 就是這麼做的

回答得不錯,接下來談談 CGLib 動態代理吧

好嘞,開頭我們提到的 AOP 就是用的 CGLib 的形式來生成的,JDK 動態代理使用 Proxy 來創建代理類,增強邏輯寫在 InvocationHandler.invoke() 裏,CGlib 動態代理也提供了類似的  Enhance 類,增強邏輯寫在 MethodInterceptor.intercept() 中,也就是說所有委託類的非 final 方法都會被方法攔截器攔截,在說它的原理之前首先來看看它怎麼用的

public class MyMethodInterceptor implements MethodInterceptor {
   @Override
   public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
       System.out.println("目標類增強前!!!");
       //注意這裏的方法調用,不是用反射哦!!!
       Object object = proxy.invokeSuper(obj, args);
       System.out.println("目標類增強後!!!");
       return object;
   }
}

public class CGlibProxy {
   public static void main(String[] args) {
       //創建Enhancer對象,類似於JDK動態代理的Proxy類,下一步就是設置幾個參數
       Enhancer enhancer = new Enhancer();
       //設置目標類的字節碼文件
       enhancer.setSuperclass(RealSubject.class);
       //設置回調函數
       enhancer.setCallback(new MyMethodInterceptor());

       //這裏的creat方法就是正式創建代理類
       RealSubject proxyDog = (RealSubject) enhancer.create();
       //調用代理類的eat方法
       proxyDog.request();
   }
}

打印如下

代理類:class com.example.demo.proxy.staticproxy.RealSubject$$EnhancerByCGLIB$$889898c5
目標類增強前!!!
賣房
目標類增強後!!!

可以看到主要就是利用 Enhancer 這個類來設置委託類與方法攔截器,這樣委託類的所有非 final 方法就能被方法攔截器攔截,從而在攔截器裏實現增強

底層實現原理是啥

之前也說了它是通過繼承自委託類,重寫委託類的非 final 方法(final 方法不能重載),並在方法裏調用委託類的方法來實現代碼增強的,它的實現大概是這樣

public class RealSubject {
   @Override
   public void request() {
       // 賣房
       System.out.println("賣房");
   }
}

/** 生成的動態代理類(簡化版)**/
public class RealSubject$$EnhancerByCGLIB$$889898c5 extends RealSubject {
   @Override
   public void request() {
       System.out.println("增強前");
       super.request();
       System.out.println("增強後");
   }
}

可以看到它並不要求委託類實現任何接口,而且 CGLIB 是高效的代碼生成包,底層依靠 ASM(開源的 java 字節碼編輯類庫)操作字節碼實現的,性能比 JDK 強,所以 Spring AOP 最終使用了 CGlib 來生成動態代理

CGlib 動態代理使用上有啥限制嗎

第一點之前已經已經說了,只能代理委託類中任意的非 final 的方法,另外它是通過繼承自委託類來生成代理的,所以如果委託類是 final 的,就無法被代理了(final 類不能被繼承)

小夥子,這次確實可以看出你作了非常充分的準備,不過你答的這些網上都能搜到答案,爲了防止一些候選人背書本,我這裏還有最後一個問題:JDK 動態代理的攔截對象是通過反射的機制來調用被攔截方法的,CGlib 呢,它通過什麼機制來提升了方法的調用效率。

嘿嘿,我猜到了你不知道,我告訴你吧,由於反射的效率比較低,所以 CGlib 採用了FastClass 的機制來實現對被攔截方法的調用。FastClass 機制就是對一個類的方法建立索引,通過索引來直接調用相應的方法,建議參考下https://www.cnblogs.com/cruze/p/3865180.html這個鏈接好好學學

還有一個問題,我們通過打印類名的方式知道了 cglib 生成了 RealSubject EnhancerByCGLIB$$889898c5 這樣的動態代理,那麼有反編譯過它的 class 文件來了解 cglib 代理類的生成規則嗎

也在參考鏈接裏,既然出來面試,對每個技術點都要深挖纔行,像 Redis, MQ 這些中間件等平時只會用是不行的,對這些技術一定要做到原理級別的瞭解,鑑於你最後兩題沒答出來,我認爲你造火箭能力還有待提高,先回去等通知吧

後記

AOP 是 Spring 一個非常重要的特性,通過切面編程有效地實現了不同模塊相同行爲的統一管理,也與業務邏輯實現了有效解藕,善用 AOP 有時候能起到出奇制勝的效果,舉一個例子,我們業務中有這樣的一個需求,需要在不同模塊中一些核心邏輯執行前過一遍風控,風控通過了,這些核心邏輯才能執行,怎麼實現呢,你當然可以統一封裝一個風控工具類,然後在這些核心邏輯執行前插入風控工具類的代碼,但這樣的話核心邏輯與非核心邏輯(風控,事務等)就藕合在一起了,更好的方式顯然應該用 AOP,使用文中所述的註解 + AOP 的方式,將這些非核心邏輯解藕到切面中執行,讓代碼的可維護性大大提高了。

篇幅所限,文中沒有分析 JDK 和 CGlib 的動態代理生成的實現,不過建議大家有餘力的話還是可以看看,尤其是文末的參考鏈接,生成動態代理主要用到了反射的特性,不過我們知道反射存在一定的性能問題,爲了提升性能,底層用了一些比如緩存字節碼,FastClass 之類的技術來提升性能,通讀源碼之後的,對反射的理解也會大大加深。

巨人的肩膀

  • Spring AOP 是怎麼運行的?徹底搞定這道面試必考題 https://cloud.tencent.com/developer/article/1584491

本文分享自微信公衆號 - 武培軒(wupeixuan404)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章