AOP的簡單實現

hyuan發表於2019-01-19

之前一篇文章分析了Java AOP的核心 – 動態代理的實現,主要是基於JDK Proxycglib兩種不同方式。所以現在乾脆把這個專題做完整,再造個簡單的輪子,給出一個AOP的簡單實現。這裡直接使用到了cglib,這也是Spring所使用的方式。

這裡是完整程式碼,實現總的來說比較簡單,無非就是各種反射,以及cglib代理。需要說明的是這只是我個人的實現方式,功能也極其有限。我並沒有看過Spring的原始碼,也不知道它的AOP實現方式具體是什麼樣的,但原理應該是類似的。

原理分析

如果你熟悉了動態代理,應該不難構思出一個AOP的方案。要實現AOP的功能,無非就是把兩個部分串聯起來:

  1. 切面(Aspect
  2. 切點(PointCut

只要一個類的方法中含有切點PointCut,那說明這個方法需要被代理,插入切面Aspect,所以相應的Bean就需要產生代理類。我們只需找到所有的PointCut,以及它們對應的Aspect,整理出一張表,就能產生出代理類,並且能知道對應的每個方法,是否有Aspect,以及如何呼叫Aspect函式。

這裡關鍵就是把這張PointCut和Aspect的對應表建立起來。因為在代理方法時,關注點首先是基於PointCut,所以這張表也是由PointCut到Aspect的對映:

PointCut Class A

    PointCutMethod 1
        Aspect Class / Method
        Aspect Class / Method

    PointCutMethod 2
        Aspect Class / Method

    PointCutMethod 3
        Aspect Class / Method
        Aspect Class / Method
   ...

PointCut Class B

    PointCutMethod 1
        Aspect Class / Method

    PointCutMethod 2
        Aspect Class / Method
   ...

例如定義一個切面類和方法:

@Aspect
public class LoggingAspect {
  @PointCut(type=PointCutType.BEFORE,
            cut="public void Greeter.sayHello(java.lang.String)")
  public static void logBefore() {
    System.out.println("=== Before ===");
  }
}

這裡的註解語法都是我自己定義的,和Spring不太一樣,不過意思應該很明瞭。這是一個前置通知,列印一行文字,切點是Greeter這個類的sayHello方法:

public class Greeter {
  public void sayHello(String name) {
    System.out.println("Hello, " + name);
  }
}

所以我們最後生成的AOP關係表就是這樣:

Greeter
    sayHello
        LoggingAspect.logBefore

這樣我們在為Greeter類生成代理類時就有了依據,具體來說就是在cglibMethodInterceptor.intercept()方法中,就可以確定需要在哪些方法,哪些位置,呼叫哪些Aspect函式。

程式碼實現

作為準備工作,首先我們定義相應的註解類:

Aspect是類註解,表明這是一個切面類,包含了切面函式。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Aspect {}

然後是切點PointCut,這是方法註解:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface PointCut {
  // PointCut Type, BEFORE or AFTER。
  PointCutType type();
  
  // PointCut expression.
  String cut();
}

不要和Spring的混起來了,我這裡簡單化了,直接用一個叫PointCut的註解,定義了兩個field,一個是切點型別type,這裡只有前置通知BEFORE和後置通知AFTER兩種,當然你也可以新增更多。一個是切點表示式cut,語法上類似於Spring,但也簡單化了,去掉了execution語法,直接寫函式表示式,用分號;隔開多個函式,也沒有什麼複雜的萬用字元匹配。

Bean 和 BeanFactory

由於要產生各種類的例項,我們不妨也像Spring那樣定義一個BeanBeanFactory的概念,但功能非常簡單,只是用來管理所有的類而已。

Bean:

public class Bean {
  /* bean id */
  private String id;
  /* bean class */
  private Class<?> clazz;
  /* instance, singleton */
  private Object instance;
}

DefaultBeanFactory

public class DefaultBeanFactory {
  /* beanid ==> Bean */
  private Map<String, Bean> beans;

  /* bean id ==> bean aspects */
  protected Map<String, BeanAspects> aops;
  
  /* get bean */
  public Object getBean(String beanId) {
    // ...
  }
}

這裡的beans是管理所有Bean的一個簡單Map,key是bean id;而aops就是之前說到的維護PointCut和Aspect對映關係的表,key是PointCut類的bean id,而value是我定義的另一個類BeanAspects,具體程式碼就不貼了,這實際上又是一層巢狀的表,是一個PointCut類中各個PointCut方法,到對應的切面Aspect方法集的對映。這裡實際上有幾層表的巢狀,不過結構是很清楚的,就是從PointCut到Aspect的對映,可以參照我上面的圖:

PointCut Class A

    PointCut Method 1
        Aspect Class / Method

    PointCut Method 2
        Aspect Class / Method

建立 PointCut 和 Aspect 關係表

現在的關鍵問題就是要建立這張關係表,實現起來並不難,就是利用反射而已。像Spring那樣,我們需要掃描給定的package中的所有類,找出註解Aspect修飾的切面類,找到它所包含的PointCut修飾的切面方法,分析它們對應的切入點PointCut,把這張表建立起來就可以了。

第一個問題是如何掃描java package,我用了guava中的ClassPath類:

ClassPath cp = ClassPath.from(getClass().getClassLoader());

// Scan all classes under a package.
for (ClassPath.ClassInfo ci : cp.getTopLevelClasses(pkg)) {
  Class<?> clazz = ci.load();
  // ...
}

然後用註解Aspect判斷一個類是否是切面類,如果是就用PointCut註解找出切面方法:

if (clazz.getAnnotation(Aspect.class) != null) {
  for (Method m : clazz.getMethods()) {
    PointCut pointCut = (PointCut)(m.getAnnotation(PointCut.class));
    if (pointCut != null) {
      /* Parse point cut expression. */
      List<Method> pointCutMethods = parsePointCutExpr(pointCut.cut());
      for (Method pointCutMethod : pointCutMethods) {
        /* Add mapping to aops table: mapping from poitcut to aspect. */
        /* ... */
      }
    }
  }
}

至於parsePointCutExpr方法如何實現,解析切點表示式,無非就是一堆正則匹配和反射,簡單粗暴,程式碼比較冗長,這裡就不貼了,感興趣的童鞋可以直接去看這裡的連結

代理類的生成

代理類何時生成?應該是在呼叫getBean時,如果這個Bean類被切面介入了,就需要用cglib為它生成代理類。我把這部分邏輯放在了Bean.java中:

if (!beanFactory.aops.containsKey(id)) {
   this.instance = (Object)clazz.newInstance();
} else {
   BeanAspects beanAspects = beanFactory.aops.get(id);
   // Create proxy class instance.
   Enhancer eh = new Enhancer();
   eh.setSuperclass(clazz);
   eh.setCallback(new BeanProxyInterceptor(beanFactory, beanAspects));
   this.instance = eh.create();
}

這裡先檢查這個bean是否需要AOP代理,如果不需要直接調建構函式生成 instance 就可以;如果需要代理,則使用BeanProxyInterceptor生成代理類,它的intercept方法包含了方法代理的全部邏輯:

@Override
class BeanProxyInterceptor implements MethodInterceptor {
  public Object intercept(Object obj, Method method, Object[] args,
                          MethodProxy proxy) throws Throwable {
    /* Find aspects for this method. */
    Map<String, BeanAspects.AspectMethods> aspects = 
        beanAspects.pointCutAspects.get(method);
    if (aspects == null) {
      // No aspect for this method.
      return proxy.invokeSuper(obj, args);
    }
    
    // TODO: Invoke before advices.

    // Invoke the original method.
    Object re = proxy.invokeSuper(obj, args);
    
    // TODO: Invoke after advices.

    return re;
  }

我們這裡只實現前置和後置通知,所以TODO部分實現出來就可以了。因為我們前面已經從PointCut和Aspect的關係表aops和子表BeanAspects裡拿到了這個PointCut類、這個PointCut方法對應的所有Aspect切面方法,儲存在aspects裡,所以我們只需遍歷aspects並依次呼叫所有方法就可以了。為了簡明,下面是虛擬碼邏輯:

for method in aspects.beforeAdvices:
  invokeAspectMethod(aspectBeanId, method)

// invoke original method
// ...

for method in aspects.afterAdvices:
  invokeAspectMethod(aspectBeanId, method)

invokeAspectMethod需要做一個簡單的static判斷,對於非static的切面方法,需要拿到切面類Bean的例項 instance。

void invokeAspectMethod(String aspectBeanId, Method method) {
  if (Modifier.isStatic(method.getModifiers())) {
    method.invoke(null);
  } else {
    method.invoke(beanFactory.getBean(aspectBeanId));
  }
}

測試

切面類,定義了三個切面方法,一個前置列印,一個後置列印,還有一個自增計數器,前兩個是static方法:

@Aspect
public class MyAspect {
  private AtomicInteger count = new AtomicInteger();

  // Log before.
  @PointCut(type=PointCutType.BEFORE,
            cut="public int aop.example.Calculator.add(int, int);" +
                "public void aop.example.Greeter.sayHello(java.lang.String);")
  public static void logBefore() {
    System.out.println("=== Before ===");
  }

  // Log after.
  @PointCut(type=PointCutType.AFTER,
            cut="public long aop.example.Calculator.sub(long, long);" +
                "public void aop.example.Greeter.sayHello(java.lang.String)")
  public static void logAfter() {
    System.out.println("=== After ===");
  }

  // Increment counter.
  @PointCut(type=PointCutType.AFTER,
            cut="public int aop.example.Calculator.add(int, int);" +
                "public long aop.example.Calculator.sub(long, long);" +
                "public void aop.example.Greeter.sayHello(java.lang.String);")
  public void incCount() {
    System.out.println("count: " + count.incrementAndGet());
  }
}

被切入的切點類是GreeterCalculator,比較簡單,裡面的方法簽名都是符合上面MyAspect類中的切點表示式的:

public class Greeter {
  public void sayHello(String name) {
    System.out.println("Hello, " + name);
  }
}
public class Calculator {
  public int add(int x, int y) {
    return x + y;
  }
  public long sub(long x, long y) {
    return x - y;
  }
}

關於 Aspect 和 PointCut 主次關係的一點思考

不難發現,從代理實現的角度來說,那張AOP關係表應該是基於切點PointCut的,以此為主索引,從PointCut到Aspect,這也似乎更符合我們的常規思維。然而像Spring這樣的框架,包括我上面給出的仿照Spring的例子,在定義AOP時,無論是基於XML還是註解,寫法上都是以切面Aspect為主的,由具體Aspect通過切點表示式來定義要切入哪些PointCut,這可能也是Aspect Oriented Programming的本意。所以上面的關係表的建立過程其實是在反轉這種主次關係,把PointCut作為主。

不過這似乎有點麻煩,就我個人而言我還是更傾向於在語法層面就直接使用前者,即基於PointCut。如果以Aspect為主,對程式碼的可維護性是一個挑戰,因為你在定義Aspect時,就需要用相應的表示式來定義PointCut,而隨著實際需求變化,例如PointCut函式的增加或減少,這個表示式往往需要改變,這樣的耦合性往往會給程式碼維護帶來麻煩;而反過來如果只簡單定義Aspect,而由具體的PointCut自己決定需要呼叫哪些切面,雖然註解量會略微增加,但是更容易管理。當然如果用XML配置可能會比較頭痛。

其實Python就是這樣做的,Python的函式註解就是天然的,基於PointCut的的AOP。Python註解實際上是一個函式的wrapper,包裹了原函式,返回給你一個新的函式,但在語法層面上是透明的,在wrapper裡就可以定義切面的行為。這樣的AOP似乎更符合人的直觀感受,當然這也源於Python本身對函數語言程式設計的良好支援,而Java由於其對OOP的蜜汁堅持,目前來講肯定是不會這樣做的,所以只能通過代理這樣”醜陋“的方式實現AOP了。

相關文章