文章

Spring - 用AspectJ定义切面

解释 Spring AOP 为什么借用 AspectJ 的切面定义方式,以及 @AspectJ、切点表达式、JDK/CGLIB 代理差异。

Spring - 用AspectJ定义切面

Spring - AOP已经解释过:Spring AOP 使用运行期动态代理(JDK 或 CGLIB)实现增强。真正让使用者感到繁琐的地方,往往不是代理本身,而是手动定义 advice、pointcut、advisor 并把它们组装起来。

  1. 切面定义的优劣
  2. @AspectJ定义切面
  3. 自动创建代理
  4. AspectJ切面中语法
    1. 切点表达式
    2. 命名切点
    3. 连接点JoinPoint/ProceedingJoinPoint
  5. jdk和cglib做动态代理的区别
    1. 注解标注在接口上:等于白标
  6. 感想

这篇文章关注 Spring AOP 与 AspectJ 的分工:Spring 继续负责运行期代理和 bean 生命周期,AspectJ 提供更简洁的切面定义语法。理解这个边界之后,@Aspect@Pointcut@Before@Around 这些注解就不再只是“更方便的配置”,而是 Spring AOP 借来的表达层。

两者的分工可以先画成这样:AspectJ 语法负责“表达切面”,Spring AOP 负责“在 bean 生命周期里生成代理”。

flowchart LR
    Aspect["@Aspect 类<br/>@Pointcut / @Before / @Around"] --> Parser["AnnotationAwareAspectJAutoProxyCreator<br/>解析 AspectJ 风格注解"]
    Parser --> Advisors["Advisor 列表<br/>Pointcut + Advice"]
    Bean["普通 Spring Bean"] --> Match{"是否匹配切点?"}
    Advisors --> Match
    Match -->|"匹配"| Proxy["创建代理<br/>JDK 或 CGLIB"]
    Match -->|"不匹配"| Raw["保留原 bean"]
    Proxy --> Container["放回 Spring 容器"]
    Raw --> Container

    classDef syntax fill:#e3f2fd,stroke:#1565c0,color:#0d47a1
    classDef spring fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
    classDef decision fill:#fff3bf,stroke:#f9a825,color:#7a4f00
    classDef raw fill:#ffe3e3,stroke:#c62828,color:#7f1d1d
    class Aspect,Parser,Advisors syntax
    class Bean,Proxy,Container spring
    class Match decision
    class Raw raw

切面定义的优劣

使用spring aop定义的切面有几个问题:

  1. 增强:必须实现spring aop的Advice接口(或其子接口,比如AfterReturningAdvice),耦合了;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
     public class SleepAfterPlay implements AfterReturningAdvice {
        
     	@Override
     	public void afterReturning(Object returnObj, Method method, Object[] args, Object target) throws Throwable {
     		if ("play".equals(method.getName())) {
     			String name = (String) args[0];
     			System.out.println("[after] sleep for: " + name + " in: " + target.getClass().getName() + "." + method.getName());
     		} else {
     			System.out.println("[WRONG after] only 'play' can be proxied");
     		}
     	}
     }
    
  2. 切点:切点定义在切面里,且切点定义太麻烦了,指定类和方法需要override相应的函数,看起来臃肿;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
     public class PlayAdvisor extends StaticMethodMatcherPointcutAdvisor {
        
         @Override
         public boolean matches(Method method, Class<?> targetClass) {
             return "play".equals(method.getName());
         }
        
         @Override
         public ClassFilter getClassFilter(){
             return NaiveStudent.class::isAssignableFrom;
         }
     }
    
  3. 切面:切面不能把切点和增强直接定义在一起,还要在配置里进行组装,麻烦;
    1
    2
    3
    4
    5
    6
    
     @Bean
     public PlayAdvisor afterPlayAdvisor(SleepAfterPlay sleepAfterPlay) {
         PlayAdvisor playAdvisor = new PlayAdvisor();
         playAdvisor.setAdvice(sleepAfterPlay);
         return playAdvisor;
     }
    

所以spring aop的配置看起来不够优雅。

AspectJ的配置则很简单:

  1. 一个切面类 = 切点 + 增强,放在一起;
  2. 切面类是普通的pojo!多出来的annotation只是配置,不影响代码执行,所以这个类依然是POJO;
  3. 切点定义很方便!当然,缺点是必须得学学切点表达式……

所以spring兼容了AspectJ定义切面的方式,简化了切面配置,其他保持不变。这里说的AspectJ(包括使用xml定义AspectJ切面、使用@AspectJ注解定义切面),仅仅是替换了spring aop的切面定义方式,底层还是spring aop原有的基于动态代理(jdk、cglib)的方式实现的!

spring也可以集成整个AspectJ,但那就是另一块内容了。不再是spring aop,而是在spring里使用AspectJ aop。

其他的:

  1. 怎么写增强;
  2. 怎么获取连接点信息(spring aop是一堆散参数,AspectJ是JoinPoint/ProceedingJoinPoint);
  3. 怎么获取异常、返回值;

二者大同小异。其中在获取异常方面,AspectJ似乎还更强一些。

所以spring aop选择直接集成“AspectJ定义切面”的方式

  • 定义切面可以完全由AspectJ来定义;
  • 把AspectJ的切面作为bean由自己来管理;

这样,二者就强强联合了。

管理的方式也是一样的:spring声明一个AspectJ相关的BeanPostProcessor(AspectJAwareAdvisorAutoProxyCreator),只要发现bean和切面相符合,就给这个bean生成动态代理!

Spring - AOP里,spring aop使用的是DefaultAdvisorAutoProxyCreator,它和AspectJAwareAdvisorAutoProxyCreator一样都是AbstractAdvisorAutoProxyCreator的子类。

@AspectJ定义切面

依然使用Spring - AOP里相同的接口和实例,区别仅在于切面的定义和动态代理对象的自动生成上。

AspectJ定义切面相关的注解:

  • @Aspect:切面,等同于spring aop里的Advisor,但是好听多了
  • @Pointcut:切点;
  • 增强:增强里可以使用value或者pointcut通过切点表达式定义切点(匿名切点),所以可能用不到@Pointcut定义切点
    • @Before
    • @AfterReturning
    • @Around
    • @AfterThrowing
    • @After:相当于Java的finally语法,一般用于释放资源

增强和spring aop的几个增强接口相对应。

前置增强切面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * @author puppylpg on 2022/07/05
 */
@Aspect
@Component
public class BeforeExamAspect {

    @Before("execution (* examine(..))")
    public void beforeExam(JoinPoint joinPoint) {
        String name = (String) joinPoint.getArgs()[0];
        Object target = joinPoint.getTarget();
        // 获取Method有点儿隐晦
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        System.out.println("[before] prepare for: " + name + " in: " + target.getClass().getName() + "." + method.getName());
    }
}

直接定义一个切面:切点 + 增强,非常方便且集中。 而且,它是一个POJO!(多出来的annotation只是配置,不影响代码执行,所以这个类依然是POJO

它比spring aop需要实现特有的切面接口要好得多! (而且它不再叫org.springframework.aop.Advisor了,直接叫org.aspectj.lang.annotation.Aspect,顺口!)

当然,得学学aspectj的切点表达式。

后置增强切面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * @author puppylpg on 2022/07/05
 */
@Aspect
@Component
public class AfterPlayAspect {

    @AfterReturning(value = "execution (* play(..))")
    public void aroundExam(JoinPoint pjp) {
        String name = (String) pjp.getArgs()[0];
        Object target = pjp.getTarget();
        Method method = ((MethodSignature) pjp.getSignature()).getMethod();

        System.out.println("[after] sleep for: " + name + " in: " + target.getClass().getName() + "." + method.getName());
    }
}

在后置增强中,可以使用AfterReturning.returning()绑定连接点方法的返回值。不过play方法没有返回值,所以没法使用了。

环绕增强切面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * @author puppylpg on 2022/07/05
 */
@Aspect
@Component
public class AroundExamAspect {

    @Around("execution (* examine(..))")
    public Object aroundExam(ProceedingJoinPoint pjp) throws Throwable {
        String name = (String) pjp.getArgs()[0];
        Object target = pjp.getTarget();
        Method method = ((MethodSignature) pjp.getSignature()).getMethod();
        System.out.println("[around] prepare for: " + name + " in: " + target.getClass().getName() + "." + method.getName());

        Object obj = pjp.proceed();

        System.out.println("[around] relax for: " + name);
        return obj;
    }
}

环绕增强的ProceedingJoinPoint比前后增强org.aspectj.lang.JoinPoint 多了ProceedingJoinPoint.proceed(),因为要执行连接点的方法。

异常处理切面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * @author puppylpg on 2022/07/05
 */
@Aspect
@Component
public class AfterBreakdownAspect {

    @AfterThrowing(value = "execution (* breakdown(..))", throwing = "ex")
    public void aroundExam(JoinPoint pjp, Exception ex) {
        String name = (String) pjp.getArgs()[0];
        Object target = pjp.getTarget();
        Method method = ((MethodSignature) pjp.getSignature()).getMethod();

        System.out.println("    -----------");
        System.out.println("method: " + method.getName());
        System.out.println("exception catched: " + ex.getMessage());
        System.out.println("Handle student's exception successfully~");
        System.out.println("    -----------");
    }
}

通过AfterThrowing.throwing()可以绑定异常,而非spring aop定义切面时候靠着约定帮你注入exception。

自动创建代理

1
2
3
4
5
6
7
8
9
10
11
    /**
     * 类似于aop模块里配置的自动检测切面的{@link DefaultAdvisorAutoProxyCreator}。
     */
    @Bean
    public AnnotationAwareAspectJAutoProxyCreator annotationAwareAspectJAutoProxyCreator() {
        AnnotationAwareAspectJAutoProxyCreator creator = new AnnotationAwareAspectJAutoProxyCreator();

        // 使用cglib。现在会给实现类NaiveStudent织入增强,所以@DebugLog的时候,能打印它的方法签名中在接口里没有的注解
        creator.setProxyTargetClass(true);
        return creator;
    }

因为用了AspectJ定义切面,所以使用AspectJAwareAdvisorAutoProxyCreator(的子类AnnotationAwareAspectJAutoProxyCreator)按照AspectJ的切面自动为相应bean创建动态代理对象。

当然这里的对象还是spring aop的动态代理对象,和AspectJ创建代理对象完全没关系。

执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    private static void run(Class<?>... annotatedClasses) {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(annotatedClasses);

        Student strengthen = applicationContext.getBean(Student.class);

        System.out.println("========= examine =========");
        strengthen.examine("math");
        System.out.println("========= play =========");
        strengthen.play("halo");
        System.out.println("========= break down =========");
        try {
            strengthen.breakdown("wtf");
        } catch (RuntimeException e) {
            System.out.println("BUT THE EXCEPTION STILL THROW");
        }
    }

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
========= examine =========
[around] prepare for: math in: .puppylpg.aspectj.NaiveStudent.examine
[before] prepare for: math in: .puppylpg.aspectj.NaiveStudent.examine
Start to examine for: math
[around] relax for: math
========= play =========
Start to play: halo
[after] sleep for: halo in: .puppylpg.aspectj.NaiveStudent.play
========= break down =========
I break down: wtf
    -----------
method: breakdown
exception catched: <exit>
Handle student's exception successfully~
    -----------
BUT THE EXCEPTION STILL THROW

输出跟之前的spring aop没什么区别。

AspectJ切面中语法

切点表达式

既然享受了 AspectJ 定义切面带来的便利,就需要掌握它的切点表达式。可以从 Baeldung 的 Spring AOP pointcut tutorial 入门。

命名切点

增强注解里定义的匿名切点无法复用,类似于Java的匿名类。所以导致切点定义重复了,怎么复用呢?使用@Pointcut注解和切面类方法定义切点。

1
2
3
4
5
6
7
8
9
10
11
/**
 * 命名切点。调用者需要使用fully qualified class name引用pointcut,
 * 因为是以string的形式调用的
 *
 * @author puppylpg on 2022/07/31
 */
public class ExaminePointcut {

    @Pointcut("execution (* examine(..))")
    public void examine() {}
}

调用起来费劲一些,需要使用"io.puppylpg.aspectj.pointcut.ExaminePointcut.examine()",但是没办法,毕竟是以string的形式调用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Aspect
@Component
public class BeforeExamAspect {

    @Before("io.puppylpg.aspectj.pointcut.ExaminePointcut.examine()")
    public void beforeExam(JoinPoint joinPoint) {
        String name = (String) joinPoint.getArgs()[0];
        Object target = joinPoint.getTarget();
        // 获取Method有点儿隐晦
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        System.out.println("[before] prepare for: " + name + " in: " + target.getClass().getName() + "." + method.getName());
    }
}

连接点JoinPoint/ProceedingJoinPoint

spring aop的增强接口是传了一堆散参数(method、returnObject、args、target),而AspectJ则是集中放到了JoinPoint/ProceedingJoinPoint里。

JoinPoint:

  • getArgs
  • getSignature
  • getTarget
  • getThis

ProceedingJoinPoint继承了JoinPoint,增加了用来执行切点方法的两个接口方法:

  • proceed
  • proceed(args):可以骚操作一下,传入新的args,替换原来的args;

具体使用可以参考上面@Aspect里的增强。

jdk和cglib做动态代理的区别

自定义一个方法注解@DebugLog,使用AspectJ定义环绕增强切面,在方法调用前后打debug log。

1
2
3
4
5
6
7
8
9
10
/**
 * 标记在方法上,给标注的方法增加debug log。
 *
 * @see io.puppylpg.aspectj.advisor.DebugLogAspect
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface DebugLog {
}

切面:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
 * @author puppylpg on 2022/07/06
 */
@Aspect
@Component
public class DebugLogAspect {

    private static final String DEBUG_PROMPT = "[debug log] ";

    /**
     * 用注解定义切点:标注{@link io.puppylpg.aspectj.annotation.DebugLog}的方法就是切点
     */
    @Pointcut("@annotation(io.puppylpg.aspectj.annotation.DebugLog))")
    public void debugLogPointCut(){

    }

    @Around("debugLogPointCut()")
    public Object logDebugInfo(ProceedingJoinPoint joinPoint) throws Throwable {
        StringBuilder message = new StringBuilder(DEBUG_PROMPT + joinPoint.getSignature().getName() + " is called!" + System.lineSeparator());

        // 获取join point的静态信息。方法签名可以获取方法的各种相关信息!
        MethodSignature methodSignature = (MethodSignature) joinPoint.getStaticPart().getSignature();

        // 获取method中的所有注解变量
        Annotation[][] paramsAnnotations = methodSignature.getMethod().getParameterAnnotations();
        Object[] args = joinPoint.getArgs();

        message.append(allArgInfos(paramsAnnotations, args));
        System.out.println(message);

        Object ret = null;
        try {
            ret = joinPoint.proceed();
            return ret;
        } finally {
            System.out.println(DEBUG_PROMPT + joinPoint.getSignature().getName() + " returned. return value=" + ret);
        }
    }

    private String allArgInfos(Annotation[][] paramsAnnotations, Object[] args) {
        StringBuilder message = new StringBuilder();
        List<String> argInfos = new ArrayList<>();
        if (args != null && paramsAnnotations.length == args.length) {
            for (int index = 0; index < paramsAnnotations.length; index++) {
                String param = oneArgInfo(paramsAnnotations[index], args[index], index);
                if (StringUtils.isNotEmpty(param)) {
                    argInfos.add(param);
                }
            }
            message.append(StringUtils.join(argInfos, System.lineSeparator()));
        }
        return message.toString();
    }

    private String oneArgInfo(Annotation[] annotations, Object arg, int index) {
        StringBuilder oneArgInfo = new StringBuilder();
        oneArgInfo.append(DEBUG_PROMPT).append("arg ").append(index).append(":").append(System.lineSeparator());
        for (Annotation annotation : annotations) {
            // 添加arg的annotations
            oneArgInfo.append(DEBUG_PROMPT).append("annotation: ").append(annotation).append(System.lineSeparator());
        }
        // 添加arg的值
        oneArgInfo.append(DEBUG_PROMPT).append("arg value: ").append(arg).append(System.lineSeparator());
        return oneArgInfo.toString();
    }
}

接口,没标记任何注解:

1
2
3
4
5
public interface Student {
   void examine(String name);
   void play(String name);
   void breakdown(String name) throws RuntimeException;
}

给实现类的play方法加上@DebugLog注解,同时给参数新增了一个@NonNull注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class NaiveStudent implements Student {

	@Override
	public void examine(String name) {
		System.out.println("Start to examine for: " + name);
	}

	/**
	 * 这个@NonNull只在实现类里有,在接口里没有
	 * 如果是cglib代理,能基于该实现类生成基于子类的代理对象,{@link DebugLog}就能打印这个@NonNull,
	 * 如果是jdk代理,只能基于{@link Student}接口生成代理对象,就无法打印接口里没有定义的@NonNull
	 */
	@DebugLog
	@Override
	public void play(@NonNull String name){
		System.out.println("Start to play: " + name);
	}

	@Override
	public void breakdown(String name) throws RuntimeException {
		System.out.println("I break down: " + name);
		throw new RuntimeException("<exit>");
	}
}

一个好玩的现象是——

如果生成动态代理的AnnotationAwareAspectJAutoProxyCreator使用jdk动态代理(setProxyTargetClass(false)),输出:

1
2
3
[debug log] play is called!
[debug log] arg 0:
[debug log] arg value: halo

如果使用cglib动态代理,输出:

1
2
3
4
[debug log] play is called!
[debug log] arg 0:
[debug log] annotation: @org.springframework.lang.NonNull()
[debug log] arg value: halo

所以使用cglib能把实现类函数签名里的@NonNull打出来,而jdk动态代理不行。

这充分说明了:cglib是基于子类生成的代理,所以代理对象里有@NonNull;而jdk动态代理是基于接口生成的动态代理,所以代理对象里没有@NonNull。

同理,如果反过来,接口里加了@NonNull但是实现类里没加,那么jdk动态代理生成的bean能输出这个注解,而cglib动态代理不行。

注解标注在接口上:等于白标

如果给接口里的play增加@DebugLog,但是不给实现类里的play加@DebugLog,结果再也无法输出debug log了。

这让我很费解,因为在 @Transactional 里也提到了注解标注的位置:如果标注到接口上,只有基于接口的 JDK 动态代理才能织入事务支持。所以 Spring 建议把注解加到实现类上,具体说明见官方文档里的 declarative transaction annotations

The Spring team recommends that you annotate only concrete classes (and methods of concrete classes) with the @Transactional annotation, as opposed to annotating interfaces. You certainly can place the @Transactional annotation on an interface (or an interface method), but this works only as you would expect it to if you use interface-based proxies. The fact that Java annotations are not inherited from interfaces means that, if you use class-based proxies (proxy-target-class="true") or the weaving-based aspect (mode="aspectj"), the transaction settings are not recognized by the proxying and weaving infrastructure, and the object is not wrapped in a transactional proxy.

但是在接口上标记@DebugLog的现象却是:不管用jdk还是cglib,都无法像@Transactional一样织入。为什么?

后来,终于在一个 Spring Framework issue 中找到了答案。

原因:根据Java标准,任何标注在接口上的注解都不会被检测到(这个我试了,确实如此)

"AspectJ follows Java's rule that annotations on interfaces are not inherited". Actually all non-type annotations, so method/field/constructor/package annotations, are not inherited

所以,在AspectJ中使用注解检测切点,如果注解标注在接口上,AspectJ会返回不匹配。标注在实现类的方法上则会返回匹配。

So, for pointcut targeting execution of class method annotated with given annotation AspectJ will return that method does not match pointcut expression if the annotation is not on the method implementation being executed - e.g. if it's only on method declaration in interface like in your example.

顺便好心提醒,如果父实现类方法上标注了注解,子类override方法上没标注,也不会有这个注解:

Even if abstract class had method annotated, and concrete class did overide that method without annotation - that method would not match expression.

而给自己的自定义注解加上java.lang.annotation.@Inherited元注解,也只在“class级别”子类会inherit父类的注解。方法上都不会inherit。

所以想在接口上标记注解作为切点,的确是没卵用的!

那@Transactional为什么可以?因为spring团队自己为@Transactional注解提供了额外支持,没有只靠AspectJ检测注解定义的切点:

So framework enables this different behaviour for it's own annotations/aspects. For user/custom aspects it relies on what AspectJ/Java support, but it doesn't prevent you from adding custom logic where/if needed.

所以我们自己定义的注解想标记切点,要么也想spring团队一样模仿@Transactional自己提供支持,要么就把注解老老实实标注到实现类上!

spring还不如不加这个支持……加了反而让人混淆,以为只要加到接口上并使用jdk基于接口的动态代理就都可以。

感想

Spring AOP 没有重复造一套切面表达语言,而是直接接入 AspectJ 的成熟方案;同时它又保留了自己基于容器和动态代理的实现方式。这个组合很典型:表达层借用成熟语法,执行层仍然服务于 Spring 的 bean 生命周期。

本文由作者按照 CC BY 4.0 进行授权