记录日志、统计方法耗时、权限检查,这些功能经常会出现在不同的业务方法中。如果每个方法都写一遍,不仅重复,后面修改起来也麻烦。

AOP(Aspect-oriented programming,面向切面编程)可以把这部分公共逻辑抽出来,在符合条件的方法执行时统一处理。下面先用一个日志切面看看怎么做。

Hello Aspect

在 Spring Boot 项目中加入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

下面使用 Spring Boot 2.1.1、Java 8。Spring Boot 2.x 默认使用基于类的 CGLIB 代理;如果想使用基于接口的 JDK 动态代理,可以设置 spring.aop.proxy-target-class=false,目标类还需要实现接口。没有接口时仍会使用 CGLIB。

先给 cn.wangxs.spb.web 包下的方法加一条执行日志,省略 package 和 import:

@Component
@Aspect
public class LogAspect {

    @Pointcut("execution(* cn.wangxs.spb.web.*.*(..))")
    public void logPointCut() {
    }

    @Before("logPointCut()")
    public void logBefore(JoinPoint jp) {
        System.out.println(LocalDateTime.now() + " 执行 "
                + jp.getSignature().getName());
    }
}

@Aspect 声明切面,@Component 把它交给 Spring 管理。@Pointcut 定义要匹配的方法,@Before 则指定在这些方法执行前运行 logBefore。

例如通过请求调用这个包下 Controller 的 hello() 方法,会先输出当前时间和方法名。这里记录的是执行时刻,方法耗时还要在执行前后分别计时。

切面和 Controller 都要在 Spring 的扫描范围内。上面的表达式只包含 web 当前包,不包含子包。

相关概念

结合这个例子,几个概念就容易区分了:

  • 连接点(Join Point):可以插入通知的位置。在 Spring AOP 中,对应方法执行。
  • 切点(Pointcut):筛选连接点的条件。这里的条件是 web 包下的方法。
  • 通知(Advice):匹配后执行的逻辑,以及执行时机。这里是方法执行前打印日志。
  • 切面(Aspect):把切点和通知组织在一起,例如 LogAspect。

@Before 描述的是通知的执行时机,并不是连接点本身。

定义切点

@Pointcut 注解的方法用来给切点命名,返回类型为 void,方法体通常留空。通知里使用 logPointCut(),就可以复用这个表达式。

execution

最常用的是 execution,基本格式如下,带 ? 的部分可以省略:

execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name-pattern(param-pattern) throws-pattern?)

分别对应修饰符、返回类型、声明类型、方法名、参数和异常类型。

execution(* cn.wangxs.spb.web.*.*(..))

第一个 * 表示任意返回类型,web.* 表示当前包里的类,后面的 * 表示任意方法名,(..) 表示任意数量、任意类型的参数。

几个常见写法:

表达式匹配范围
execution(public * *(..))public 方法
execution(* set*(..))名字以 set 开头的方法
execution(* cn.wangxs.spb.service.HelloService.*(..))HelloService 中的方法
execution(* cn.wangxs.spb.service.*.*(..))service 包下的方法,不含子包
execution(* cn.wangxs.spb.service..*.*(..))service 包及子包下的方法
execution(* cn.wangxs.spb.service..*.*())service 包及子包下的无参方法

* 和 .. 要结合所在位置看:参数列表中的 (*) 表示恰好一个任意类型参数,(..) 才是任意数量。类型模式中的 + 表示该类型及其子类型,例如 java.util.List+。

这些表达式描述匹配条件,实际能否拦截还受代理方式限制,不代表所有匹配的方法都会被增强。

其他切点指示符

指示符含义
within按方法所在的声明类型匹配
this按代理对象的类型匹配
target按目标对象的类型匹配
args按运行时参数类型匹配,也可以绑定参数
@annotation按方法上的注解匹配
@within按方法所在声明类型上的注解匹配
@target按目标对象运行时类型上的注解匹配
@args按运行时参数类型上的注解匹配

例如,匹配 service 包及子包中声明的方法:

within(cn.wangxs.spb.service..*)

代理对象和目标对象都可以按接口类型匹配:

this(cn.wangxs.spb.service.IHelloService)
target(cn.wangxs.spb.service.IHelloService)

两者检查的对象不同。使用 JDK 动态代理时,代理对象实现了接口,但不是实现类的子类;目标对象仍然是实际的实现类实例。

匹配只有一个参数,且运行时参数符合 Serializable 类型的方法:

args(java.io.Serializable)

它与 execution(* *(java.io.Serializable)) 不同,后者看的是方法声明的参数类型。

按注解匹配:

@within(org.springframework.web.bind.annotation.RestController)
@annotation(cn.wangxs.spb.annotation.Log)

第二个表达式中的 Log 是自定义方法注解,需要使用 @Retention(RetentionPolicy.RUNTIME),才能在运行时读取。

表达式可以用 &&、||、! 组合。例如只处理 service 包及子包中名字不以 get 开头的方法:

@Pointcut("execution(* cn.wangxs.spb.service..*.*(..))"
        + " && !execution(* get*(..))")
public void servicePointCut() {
}

定义通知

下面改用 service 包的例子,把 logPointCut() 的表达式替换为:

@Pointcut("execution(* cn.wangxs.spb.service..*.*(..))")
public void logPointCut() {
}

目标类放在 cn.wangxs.spb.service 包中:

@Service
public class HelloService {

    public List<String> getNameList() {
        List<String> list = Arrays.asList("a", "b");
        System.out.println("list:" + list);
        return list;
    }

    public void fail() {
        throw new IllegalStateException("测试异常");
    }
}

通过 Spring 注入的 HelloService 调用这些方法。下面每种通知分别演示,切面中保留 logPointCut() 和当前这一种通知,便于看清各自的作用。

@Before:前置通知

@Before("logPointCut()")
public void logBefore(JoinPoint jp) {
    System.out.println("执行 " + jp.getSignature().getName());
}

调用 getNameList():

执行 getNameList
list:[a, b]

@After:后置通知

@After("logPointCut()")
public void logAfter(JoinPoint jp) {
    System.out.println(jp.getSignature().getName() + " 执行完毕");
}

正常返回或抛出异常都会经过这个通知,类似 finally。

调用 getNameList():

list:[a, b]
getNameList 执行完毕

调用 fail() 时,也会输出 fail 执行完毕,异常继续向调用方抛出。“执行完毕”不代表成功。

@AfterReturning:返回通知

@AfterReturning(value = "logPointCut()", returning = "result")
public void logAfterReturning(JoinPoint jp, List<String> result) {
    System.out.println("返回值:" + result);
}

调用 getNameList():

list:[a, b]
返回值:[a, b]

returning 与通知参数名对应,参数类型也会限制匹配的返回值。这里接收 List<String>;如果目标方法抛出异常,就不会进入返回通知。

返回通知不能通过给 result 重新赋值来替换调用方收到的返回值。需要改变返回结果时,可以使用环绕通知。

@AfterThrowing:异常通知

@AfterThrowing(value = "logPointCut()", throwing = "e")
public void logAfterThrowing(Exception e) {
    System.out.println("抛出异常:" + e.getMessage());
}

调用 fail():

抛出异常:测试异常

throwing 与异常参数名对应。这里的 Exception 用来接收匹配的异常,通知执行后异常仍然向调用方传播,不会因为打印了日志就被处理掉。

@Around:环绕通知

环绕通知使用 ProceedingJoinPoint,通过 proceed() 继续执行调用链:

@Around("logPointCut()")
public Object logAround(ProceedingJoinPoint jp) throws Throwable {
    System.out.println("Around记录 " + jp.getSignature().getName() + " 开始执行");
    Object result = jp.proceed();
    System.out.println("Around记录 " + jp.getSignature().getName() + " 执行完毕");
    return result;
}

调用 getNameList():

Around记录 getNameList 开始执行
list:[a, b]
Around记录 getNameList 执行完毕

调用 fail() 时,proceed() 会抛出异常,后面的“执行完毕”不会输出。没有调用 proceed(),目标方法也不会执行;有返回值时,要把结果返回给调用方。

如果要统计正常、异常两种情况下的耗时,可以把结束计时放进 finally,替换上面的环绕通知:

@Around("logPointCut()")
public Object logAround(ProceedingJoinPoint jp) throws Throwable {
    long start = System.nanoTime();
    try {
        return jp.proceed();
    } finally {
        long cost = System.nanoTime() - start;
        System.out.println(jp.getSignature().getName()
                + " 耗时 " + cost / 1_000_000.0 + " ms");
    }
}

这里用 nanoTime() 计算时间差。返回值和异常都沿着原来的调用链传递。

多个切面同时匹配一个方法时,可以在切面类上使用 @Order 指定优先级,数值越小优先级越高:进入时先执行,退出时后执行。本文使用的 Spring 5.1 中,不要依赖同一个切面里多个通知方法的源码排列顺序。需要固定先后关系的一段逻辑,可以放进同一个环绕通知中。

连接点信息

通知方法可以通过 JoinPoint 获取方法名、参数和对象信息。例如把前置通知改成:

@Before("logPointCut()")
public void logBefore(JoinPoint jp) {
    MethodSignature signature = (MethodSignature) jp.getSignature();
    System.out.println("方法:" + signature.getDeclaringTypeName()
            + "." + signature.getName());
    System.out.println("参数:" + Arrays.toString(jp.getArgs()));
    System.out.println("返回类型:" + signature.getReturnType().getSimpleName());
    System.out.println("目标类型:" + jp.getTarget().getClass().getName());
}

MethodSignature 来自 org.aspectj.lang.reflect。调用无参的 getNameList(),参数输出为 [],返回类型为 List。

getThis() 得到代理对象,getTarget() 得到目标对象,这也对应前面的 this 和 target 切点指示符。上面的参数打印只用于这个简单示例,实际日志不要直接输出密码、令牌等敏感参数。

为什么有时切面没有执行

Spring AOP 的调用过程可以看成:

调用方 → Spring 代理对象 → 匹配的通知 → 目标方法

自己 new HelloService() 得到的对象没有经过 Spring 代理。即使类上有 @Service,直接调用这个对象也不会触发切面。

同一个对象内部通过 this 调用另一个方法,也不会重新经过代理。例如外部调用 foo(),在 foo() 中执行 this.bar(),针对 bar() 的通知不会因此再执行一次。

另外,CGLIB 通过子类实现代理,不能对 final 类建立这种代理,也不能增强 final 或 private 方法;JDK 动态代理则面向接口方法。排查时先看调用是否经过代理,再看切点表达式是否匹配。

最后修改:2018 年 12 月 02 日
如果觉得我的文章对你有用,请随意赞赏