AspectJ 动态代理的实际演练

1. AOP 的概念引入

AspectJ 从其命名就可以看出其强调面向切面编程,什么是面向切面编程?

Aspect Oriented Programming(AOP),俗称面向切面编程。切面是相对于方法栈的入栈和压栈而言的。你可以想象每一个方法都是一块面包片,而切面就是面包片之间的间隙。

下图是一个方法栈:

image-20200616133147181

image-20200417181020876

刀从吐司最前端开始切,每新切一刀,相当于程序计数器 +1。

面向切面编程的含义就是将方法栈切开,然后在合适的切面上涂点苹果酱、花生酱。如果在吐司片后面涂,相当于在这个代码段执行后执行一些额外方法,如果在一片吐司片正面涂,相当于在这个代码块执行前执行一些额外的代码。

AOP 涉及一些该领域的专业习惯用语,其中比较重要的有:

  • 通知( Advice):切面的工作被称为通知,换句话说,第三方功能即为通知。
  • 按照通知在切面作用顺序,分为:前置通知 Before、后置通知 After、环绕通知 Around。按功能分还有返回通知、异常通知。
  • 连接点(Join point):描述一种能力:可能插入切点的方法栈点,比如调用的方法、抛出异常时、修改一个字段时。
  • 切点(Pointcut):真正插入第三方功能的程序点。
  • 切面(Aspect):通知+切点,即第三方类库干什么和在哪里做。
  • 织入(Weaving):织入是把切面应用到目标对象并创建新的代理对象的过程。创建的代理类既可以在编译期间完成,也可以在运行期间完成。

2. AspectJ 是什么?

能实现 AOP 思想的工具有很多,比如 JDK 动态代理、CGLIB 动态字节码增强技术以及 AspectJ,AspectJ 和其他技术最大的区别就在于其是在编译期进行,而另外两者都是在运行时执行。具体来说,它们的区别可以用下图表示:

技术 代理逻辑 内部原理
JDK 动态代理 动态代理 在运行时将被代理类封装到代理类中,然后产生代理类实例
CGLIB 动态代理 动态代理 在运行时增强被代理类的字节码,直接生成字节码增强后的代理类实例
AspectJ 静态代理 在编译期间将切面织入被代理类中,对运行时生成代理类实例的逻辑透明

AspectJ 有着其独特的语法特性,具体可以看 The AspectJTM Programming Guide,其提供了两套配置方式:

  • 基于 Java 注解的配置方式;
  • 基于 aspect 文件的切面描述方法,通常需要 IDE 安装额外的插件才能够进行语法检查;

AspectJ 的主要作用就是作为 Java 的一个面向 AOP 的框架而存在,下一节将讲述最朴素地使用 AspectJ 的用法。

3. AspectJ 的用法

aspectJ.jar 解压有的目录如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
bin/
├── aj
├── aj5
├── ajbrowser
├── ajc
└── ajdoc
lib/
├── aspectjrt.jar
├── aspectjtools.jar
├── aspectjweaver.jar
└── org.aspectj.matcher.jar

这当中重点的文件是四个 jar 包中的前三个,bin 文件夹中的脚本其实都是调用这些 jar 包的命令。

  • aspectjrt.jar 包主要是提供运行时的一些注解,静态方法等等东西,通常我们要使用 aspectJ 的时候都要使用这个包。
  • aspectjtools.jar 包主要是提供赫赫有名的 ajc 编译器,可以在编译期将将 java 文件或者 class 文件或者 aspect 文件定义的切面织入到业务代码中。通常这个东西会被封装进各种 IDE 插件或者自动化插件中。
  • aspectjweaverjar 包主要是提供了一个 java agent 用于在类加载期间织入切面 (Load time weaving)。并且提供了对切面语法的相关处理等基础方法,供 ajc 使用或者供第三方开发使用。这个包一般我们不需要显式引用,除非需要使用 LTW。

上面的说明其实也就指出了 aspectJ 的几种标准的使用方法 (参考文档):

  1. 编译时织入,利用 ajc 编译器替代 javac 编译器,直接将源文件 (java 或者 aspect 文件) 编译成 class 文件并将切面织入进代码。
  2. 编译后织入,利用 ajc 编译器向 javac 编译期编译后的 class 文件或 jar 文件织入切面代码。
  3. 加载时织入,不使用 ajc 编译器,利用 aspectjweaver.jar 工具,使用 java agent 代理在类加载期将切面织入进代码。

三种用法如下图所示:

image-20200718105816858

基于 Maven 插件自动构建的 Aspect 方式实际上采用的是方式 ②,下一节会有提到。

4. AspectJ 的具体使用案例

4.1 基于 aspectj 文件的 AspectJ

这种说法比较蛋疼,其实我想说明的是这种不兼容 javac 的一种切面表示形式。比如当前我们有一个业务类 App.java:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class App {

  public void say() {
    System.out.println("App say");
  }

  public static void main(String[] args) {
    App app = new App();
    app.say();
  }
}

我们希望对在 say 函数里加一个切面, 那就创建一个 AjAspectj.aj 的文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public aspect AjAspect {
		//定义切面
    pointcut say():
            execution(* App.say(..));
		//切面之前调用
    before(): say() {
        System.out.println("AjAspect before say");
    }
    //切面之后调用
    after(): say() {
        System.out.println("AjAspect after say");
    }
}

这样我们就能实现切面的功能。可这个 aj 文件的语法虽然跟 java 很类似,但是毕竟还是不能用 javac 来编译,如果我们要用这个的话就必须使用 ajc 编译器。使用的方法大概有这几种:

  1. 调用命令直接编译 (直接使用 ajc 命令或者调用 java -jar aspectjtools.jar);
  2. 使用 IDE 集成的 ajc 编译器编译;
  3. 使用自动化构建工具的插件编译; 其实 2,3 两点的本质都是使用 asp ectjtools.jar,最简单的调用方法如下:
1
2
3
4
5
6
#!/usr/bin/env bash

ASPECTJ_TOOLS=/home/myths/.m2/repository/org/aspectj/aspectjtools/1.8.9/aspectjtools-1.8.9.jar
ASPECTJ_RT=/home/myths/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar

java -jar $ASPECTJ_TOOLS -cp $ASPECTJ_RT -sourceroots .

调用 aspectjtools.jar 包,指定 aspectjrt 的 classpath,以及需要编译的路径,这样就会生成 AjAspectj.aj 以及 App.java 对应的 class 文件。我们反编译一下看看:

AjAspectj.class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.io.PrintStream;
import org.aspectj.lang.NoAspectBoundException;

public class AjAspect
{
  private static Throwable ajc$initFailureCause;
  public static final AjAspect ajc$perSingletonInstance;

  public static AjAspect aspectOf()
  {
    if (ajc$perSingletonInstance == null) {
      throw new NoAspectBoundException("AjAspect", ajc$initFailureCause);
    }
    return ajc$perSingletonInstance;
  }

  public static boolean hasAspect()
  {
    return ajc$perSingletonInstance != null;
  }

  private static void ajc$postClinit()
  {
    ajc$perSingletonInstance = new AjAspect();
  }

  static
  {
    try
    {

    }
    catch (Throwable localThrowable)
    {
      ajc$initFailureCause = localThrowable;
    }
  }
	
  //切面方法的定义
  public void ajc$before$AjAspect$1$682722c()
  {
    System.out.println("AjAspect before say");
  }

  public void ajc$after$AjAspect$2$682722c()
  {
    System.out.println("AjAspect after say");
  }
}

App.class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.io.PrintStream;

public class App
{
  public void say()
  {
    try
    {
      AjAspect.aspectOf().ajc$before$AjAspect$1$682722c();//织入了切面
      System.out.println("App say");
    }
    catch (Throwable localThrowable)
    {
      AjAspect.aspectOf().ajc$after$AjAspect$2$682722c();//织入了切面
      throw localThrowable;
    }
    AjAspect.aspectOf().ajc$after$AjAspect$2$682722c();
  }

  public static void main(String[] args)
  {
    App app = new App();
    app.say();
  }
}

可以看出 AspectJ 的织入原理的特点:

  • 每一个 aspect 文件(.aj 文件)通过 ajc 编译后产生的 Class 文件实际上采用的是单例模式

    单例通过 类名.aspectOf() 方法额得到。

  • AspectJ 织入后不仅仅会在方法前后织入方法,还会额外地引入 try-catch 语句块,此语句块的特点在于其优先执行完所有织入方法,然后再是抛出异常(不进行异常处理);

不过,虽然事实上这种基于 aj 文件的切面描述方法比基于 java 注解的切面描述方法用起来要灵活的多,但是由于他无法摆脱 ajc 的支持,而且本身不兼容 java 语法导致难以统一编码规范,加上需要较多额外的学习成本,因此事实上很多项目还是不怎么用这种方式,更多的还是采用了兼容 java 语法的用注解定义切面的方式。

4.2 基于 Java 注解的 AspectJ

下面我们主要还是着力考虑下基于 java 注解的切面使用方法。

先建一个普通的项目看看,老样子,从 maven 的 maven-archetype-quickstart 开始,pom.xm 文件里我们一般只需要加上 AspectJ Runtime 的依赖即可,但是由于后续需要利用到 AspectJ 的编译工具,因此还要引入 AspectJ Tools 依赖与 AspectJ Weaver 依赖。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjrt</artifactId>
  <version>1.8.9</version>
</dependency>

<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjtools</artifactId>
  <version>1.8.9</version>
</dependency>

<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjweaver</artifactId>
  <version>1.8.13</version>
</dependency>

如果使用高版本,可能会遇到命令不兼容的错误。

创建 Proxyee 类:

1
2
3
4
5
public class Proxyee {
    public void sayHello(String str){
        System.out.println("Hello " + str);
    }
}

创建切面类 ProxyAspect 类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Aspect
public class ProxyAspect {
    //定义切点
    @Pointcut("execution(* aspectjproxy.Proxyee.sayHello(..))")
    public void jointPoint(){}
    //定义前置通知
    @Before("jointPoint()")
    public void before(){
        System.out.println("-------Log.log()-------");
    }
    //定义后置通知
    @After("jointPoint()")
    public void after(){
        System.out.println("-------Log.log()-------");
    }
}

接下来我们就来尝试下 4 种不同的编译方式。

4.2.1 编译时织入

编译时织入其实就是使用 ajc 来进行编译,暂时不使用自动化构建工具,我们先在项目根目录下手动写一个编译脚本 compile.sh:

1
2
3
4
5
6
#!/usr/bin/env bash
ASPECTJ_WEAVER=~/.m2/repository/org/aspectj/aspectjweaver/1.8.13/aspectjweaver-1.8.13.jar
ASPECTJ_RT=~/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar
ASPECTJ_TOOLS=~/.m2/repository/org/aspectj/aspectjtools/1.8.9/aspectjtools-1.8.9.jar

java -jar $ASPECTJ_TOOLS -cp $ASPECTJ_RT -source 1.5 -sourceroots src/main/java/aspectjproxy -d target/classes
  • -cp 参数指定 aspectjrt.jar 的路径;
  • -source 1.5 参数指定支持 java1.5 以后的注解;
  • -sourceroots 指明编译的文件夹;
  • -d 指明输出路径;

注意事项:这里需要你将路径修改为自己本地 Maven 项目 jar 包的存放路径。

此时成的字节码为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package aspectjproxy;

import org.aspectj.lang.NoAspectBoundException;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Aspect
public class ProxyAspect {
  public ProxyAspect() {
  }

  @Before("jointPoint()")
  public void before() {
    System.out.println("-------Log.log()-------");
  }

  @After("jointPoint()")
  public void after() {
    System.out.println("-------Log.log()-------");
  }
	//单例模式
  public static ProxyAspect aspectOf() {
    if (ajc$perSingletonInstance == null) {
      throw new NoAspectBoundException("aspectjproxy.ProxyAspect", ajc$initFailureCause);
    } else {
      return ajc$perSingletonInstance;
    }
  }

  public static boolean hasAspect() {
    return ajc$perSingletonInstance != null;
  }

  static {
    try {
      ajc$postClinit();
    } catch (Throwable var1) {
      ajc$initFailureCause = var1;
    }

  }
}

以及被代理类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package aspectjproxy;

public class Proxyee {
  public Proxyee() {
  }

  public void sayHello(String str) {
    try {
      ProxyAspect.aspectOf().before();//前置织入
      System.out.println("Hello " + str);//原方法
    } catch (Throwable var3) {
      ProxyAspect.aspectOf().after();//后置织入
      throw var3;
    }

    ProxyAspect.aspectOf().after();
  }
}

可见:注解版本的编译器对应的字节码可读性更好一点,但是实现的内部原理是相同的:AspectJ 注解类还是通过单例模式被代理类的调用,最终实现编译时织入。

4.2.2 编译后织入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/usr/bin/env bash
# 编译后织入,即使用的是 ajc 编译器间接来编译 class 文件,前置的 class 文件由 javac 编译器生成

## 第一步使用 javac 命令先生产原始的 class 文件
javac -cp ~/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar  -d target/classes src/main/java/aspectjproxy/*.java

## 第二步接着使用 ajc 编译器接收上述 class 文件,然后生产新的 class 文件并覆盖原文件
ASPECTJ_WEAVER=~/.m2/repository/org/aspectj/aspectjweaver/1.8.13/aspectjweaver-1.8.13.jar
ASPECTJ_RT=~/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar
ASPECTJ_TOOLS=~/.m2/repository/org/aspectj/aspectjtools/1.8.9/aspectjtools-1.8.9.jar

java -jar $ASPECTJ_TOOLS -cp $ASPECTJ_RT -source 1.5 -inpath target/classes/aspectjproxy -d target/classes

第一步中生成的 .class 文件实际上就是 javac 直接生成的字节码文件,没有实现 AspectJ 的方法织入,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package aspectjproxy;

public class Proxyee {
    public Proxyee() {
    }

    public void sayHello(String var1) {
        System.out.println("Hello " + var1);
    }
}

以及

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package aspectjproxy;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class ProxyAspect {
  public ProxyAspect() {
  }

  @Pointcut("execution(* aspectjproxy.Proxyee.sayHello(..))")
  public void jointPoint() {
  }

  @Before("jointPoint()")
  public void before() {
    System.out.println("-------Log.log()-------");
  }

  @After("jointPoint()")
  public void after() {
    System.out.println("-------Log.log()-------");
  }
}

第二步则会生成同编译时织入方式一样的字节码文件。

4.2.3 加载时织入 (LTW)

LTW 的含义是:Load-time weaving,类加载时织入。

前两种织入方法都依赖于 ajc 的编译工具,LTW 却通过 java agent 机制在内存中操作类文件,可以不需要 ajc 的支持做到动态织入。 为了实现 LTW,我们需要在资源目录下配置 META-INF/aop.xml 文件,来告知类加载器我们当前注册的切面。

在上面的项目中,我们其实只需要创建 src/main/resources/META-INF/aop.xml:

1
2
3
4
5
<aspectj>
    <aspects>
        <aspect name="aspectjproxy.ProxyAspect"/>
    </aspects>
</aspectj>

这样,我们就可以先使用 javac 编译源文件,再使用 java agent 在运行时织入:

1
2
3
4
5
6
7
#!/usr/bin/env bash
# 编译时织入,即使用的是 ajc 编译器直接来编译 java 文件,生产 class 文件
ASPECTJ_WEAVER=~/.m2/repository/org/aspectj/aspectjweaver/1.8.13/aspectjweaver-1.8.13.jar
ASPECTJ_RT=~/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar
ASPECTJ_TOOLS=~/.m2/repository/org/aspectj/aspectjtools/1.8.9/aspectjtools-1.8.9.jar

java -javaagent:$ASPECTJ_WEAVER -cp $ASPECTJ_RT:target/classes/ aspectjproxy.Proxyee

控制台输出:

——-Log.log()——- Hello Spongecaptain ——-Log.log()——-

注意事项:因为这里是加载时进行织入,因此必须要有 main 方法,因此这里手下你要对代理类加入 main 方法,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class Proxyee {

  public void sayHello(String str){
    System.out.println("Hello " + str);
  }

  public static void main(String[] args) {
    final Proxyee proxyee = new Proxyee();

    proxyee.sayHello("Spongecaptain");
  }
}

4.2.4 maven 自动化构建

显然,自己写脚本还是比较麻烦的,如果用如 maven 这样的自动化构建工具的话就会方便很多,codehaus 提供了一个 ajc 的编译插件 aspectj-maven-plugin,我们只需要在 build/plugins 标签下加上这个插件的配置即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>aspectj-maven-plugin</artifactId>
  <version>1.10</version>
  <configuration>
    <source>1.8</source>
    <target>1.8</target>
    <complianceLevel>1.8</complianceLevel>
  </configuration>
  <executions>
    <execution>
      <goals>
        <goal>compile</goal>
      </goals>
    </execution>
  </executions>
</plugin>

这个插件会绑定到编译期,采用的应该是编译后织入的方式,在 maven-compiler-plugin 处理完之后再工作的。插件其实是对 aspectjtools.jar 的一个 mojo 封装

注意事项:每一种方式执行前应当将上一个方式产生的 class 文件删除掉,避免这一步执行的是上一步产生的结果。

第四节主要引用于:https://blog.mythsman.com/post/5d301cf2976abc05b34546be

3. AspectJ 和 Spring AOP 之间的关系

Spring AOP 底层没有使用 AspectJ 来实现,其基于 JDK 动态代理以及 CGLIB 运行时字节码增强:

  • 被代理的方法属于接口:使用 JDK 动态代理;
  • 被代理的方法不属于接口:使用 CGLIB 运行时字节码增强;

那么为什么 Spring AOP 依赖于 AspectJ 呢?

事实上 Spring AOP 仅仅使用了 AspectJ 的相关注解以及 AspectJ 的相关切面概念和语法。因此 Spring AOP 实际上仅仅依赖于 AspectJ 的 aspectjrt.jar 包,其主要是提供运行时的一些注解,静态方法等等东西。而其他两个用于产生织入代码的工具 jar 包并没有依赖。

你可以在 org.springframework.aop jar 包中看到很多类都依赖于 org.aspectj.lang.annotation packages,而后者位于 AspectJ 的 aspectjrt.jar 包中。