记录日志、统计方法耗时、权限检查,这些功能经常会出现在不同的业务方法中。如果每个方法都写一遍,不仅重复,后面修改起来也麻烦。
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 动态代理则面向接口方法。排查时先看调用是否经过代理,再看切点表达式是否匹配。