[spring] spring AOP - 面向切面编程の学习
[spring] spring AOP - 面向切面编程の学习
几年前开始还在被 spring 的八股文时,AOP 就是一个比较热也比较大的点,为了面试确实背过不少,不过 AOP 实现本身做的不多,一方面也是因为 AOP 一旦配置好了基本上就不需要改什么,我那个时候也是 entry 岗,还没进阶到碰 AOP 的程度
这次正好趁着重学 spring 的机会,补一下 AOP 相关的概念
从结构来说,AOP 可以很好的解决两个问题:
-
代码纠缠
指的是业务逻辑纠缠在一起
-
代码分散
更多的是代码散落的到处都是
以常见的输出日志为例,在以不用第三方库为前提的条件下,controller、service 这两个常见模块都会有 try-catch 的逻辑。那也就代表:
- logger 的逻辑和 service/controller 紧紧地纠缠在了一起
- logger 的代码散落在 service/controller 的代码中
使用 aspect 就可以很好的解决这个问题,它可以
-
将这些横切关注点的逻辑进行抽离封装
这样可以解决了上面 代码纠缠 和 代码分散 的问题,并且让代码修改和维护变得简单不少
另外,修改 aspect 代码并不需要接触到主要的 java 项目,因此主项目并不需要被重新编译——这需要 aspect 实现遵从规范,保持松散的耦合度;不修改公共类的返回类型;以及进行模块化打包,让 aspcet 部分代码打包在不同的 jar 中
这样也可以清理业务逻辑,更好的运行 single responsibility principle
-
可以用在所有地方
这个后面会提到,它可以使用 wildcard match,所以只要 pattern 写的对,那么就可以用在任何想用的地方
-
同样的实现(class/aspect)基于配置实现
这也是上面提到的配置问题
它的具体业务逻辑可以包括:应用代理设计模式、日志输出、安全检查、交易、审计日志、处理异常、管理 API 等
当然,spring aop 也不是没有缺点的:
-
如果 AOP 太多,那么会导致 aop 的逻辑难以理解和追踪
-
aop 是在 runtime 时完成的 weaving,基于动态代理完成
因此,太多的 aop 会导致运行速度显著下降
另一个可以完成效能提升的时间方法是,在做 pointcut 的 matching 时,只包含当前项目的代码
术语
-
切面(Aspect)- 对关注的逻辑/业务所进行的抽离/抽象
比较常见的就是 logging 的业务,这里的抽象指的就是对 logger 功能的抽象
当然,它其实是包含了 具体要执行的业务逻辑 和 需要匹配的业务路径
-
连接点(Join Point)
即可以被 aspect 切入的点,以 spring aop 来说,最小且唯一的 Join Point 是方法(method)
-
切点(Pointcut)
以 sping aop 来说,因为可以使用 wildcard 进行 match,所以它上可以 match 到
-
方法名, 如
Service._(..)
) -
包名 / 类名, 如
com.example.service..*
-
参数类型, 如
(..)
、(String, ..)
-
返回类型, 如
void
、*
并且可以使用 AND/OR 进行操作关联
可以理解成,Pointcut 的实现是寻找到对应的 join point;即对 join point 的匹配,也就是 aspect 中需要匹配的业务路径——where
-
-
通知(Advice):在匹配到 join point 所要执行的增强逻辑
也就是 aspect 中提到的 具体要执行的业务逻辑——what
-
目标对象 (Target):代理的目标对象
即原始的业务逻辑,也就是未被增强的部分
依旧以 logging 为例,原本需要打 log 的方法中的业务逻辑——大多数情况下是 CRUD 操作,就是 aspect 的 target
-
引介(introduction):一种特殊的增强
大体找了下,意思是:
允许你给原本没有实现某接口的类,动态添加新的方法或属性
但是这个方法使用方法比较 edge,本篇笔记中不会提到
-
织入(Weaving):织入是将 advice 添加到 target 的 Join Point 的过程
spring aop 实现 weaving 是在 runtime 中通过 proxy 实现的动态插入
补充一下,目前比较主流的 AOP 框架有 spring aop 和 AspectJ,spring aop 用了一点 AspectJ 的命名风(语法),但是具体的实现是独立的
spring aop 比 AspectJ 更轻量级,相对来说 AspectJ 的功能比 spring aop 更强大
目前来说,比较常见的业务使用 spring aop 就够了
advice 类型
下面是几个常见的 advice 类型:
-
@Before
在方法执行前调用
-
@AfterReturning
在方法成功执行后调用
-
@AfterThrowing
在方法失败抛出异常后调用
-
@After
在方法执行后调用
与
@AfterReturning
、@AfterThrowing
之 之间的关系有点像 try-catch-after -
@Around
在代码执行前后都会调用
下面的案例代码会在项目里面运行一下做个介绍,加入 spring aop 的方法可以在 pom 文件里面添加下面这个 dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
⚠️:现在的新 spring boot 的项目,aop 都是自动开启的。但是如果跑的是老项目,导入 dependency 还是没有开启,需要手动添加 @EnableAspectJAutoProxy
,让 spring 开启对 aop 的识别
@Before
代码还是比较简单的,pointcut 的部分下面会提到,这里具体就不讲了:
package com.example.aopdemo.aspect;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class MyDemoLoggingAspect {
@Before("execution(public void addAccount())")
public void beforeAddAccountAdvice() {
System.out.println("\n=====>> Executing @Before advice on AddAccount()");
}
}
main 代码中依旧使用 commandLineRunner 去执行:
package com.example.aopdemo;
import com.example.aopdemo.dao.AccountDAO;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class AopdemoApplication {
public static void main(String[] args) {
SpringApplication.run(AopdemoApplication.class, args);
}
@Bean
public CommandLineRunner commandLineRunner(AccountDAO accountDAO) {
return runner -> {
demoTheBeforeAdvice(accountDAO);
};
}
private void demoTheBeforeAdvice(AccountDAO accountDAO) {
accountDAO.addAccount();
}
}
DAOImpl 只是保证有这个方法调用,并不是真的负责执行 CRUD 操作,执行结果如下:
👀:这里的部分内容是在 pointcut 之后写的,所以会涉及一些暂时还没提到的语法。不过对 match 稍微理解一点的应该能猜到,@Before
里的写法做的就是方法的 matching
@Before
通过 JoinPoint
获取参数
回顾一下:
即可以被 aspect 切入的点,以 spring aop 来说,最小且唯一的 Join Point 是方法(method)
这里的 JoinPoint
就是当前 advice 中,连接到的方法
@Before("com.example.aopdemo.aspect.AopExpressions.forDaoPackageNoGetterSetter()")
public void beforeAddAccountAdvice(JoinPoint joinPoint) {
System.out.println("=====>> Executing @Before advice on executionforDaoPackage() && !(getter() || setter())");
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
System.out.println("Method: " + methodSignature);
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
System.out.println(arg);
}
}
效果如下:
@AfterReturning
像上面提到过的, @AfterReturning
只有在方法成功运行没有抛出异常的情况下,才会执行。因此它大概率是需要能够接触到返回结果的,因此可以用 returning
去获取返回结果
⚠️:returning
中的名字,需要与 parameter 中的名字一致
@AfterReturning(pointcut = "execution(* com.example.aopdemo.dao.AccountDAO.findAccounts(..))", returning = "result")
public void afterReturningAddAccountAdvice(JoinPoint joinPoint, List<Account> result) {
String method = joinPoint.getSignature().toShortString();
System.out.println("=====>> Executing @AfterReturning advice on method: " + method + " with result:");
System.out.println("=====>> Result is: " + result + "\n");
}
@AfterReturning(pointcut = "com.example.aopdemo.aspect.AopExpressions.forDaoPackageNoGetterSetter()")
public void afterReturningAddAccountAdvice(JoinPoint joinPoint) {
String method = joinPoint.getSignature().toShortString();
System.out.println("=====>> Executing @AfterReturning advice on method: " + method);
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
System.out.println("Method: " + methodSignature);
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
System.out.println(arg);
}
}
运行结果如下:
修改返回结果
⚠️:这个操作,在大部分的实现里面是属于不太好的操作。正常需要对返回结果实现修改的操作,都应该放在 controller/service 中,而不是 advice 中
这里只是表示 aspect 中可以这么做,而不是推荐这么做
修改的代码为:
@AfterReturning(pointcut = "execution(* com.example.aopdemo.dao.AccountDAO.findAccounts(..))", returning = "result")
public void afterReturningAddAccountAdvice(JoinPoint joinPoint, List<Account> result) {
String method = joinPoint.getSignature().toShortString();
System.out.println("=====>> Executing @AfterReturning advice on method: " + method + " with result:");
System.out.println("=====>> Result is: " + result + "\n");
convertAccountNamesToUpperCase(result);
System.out.println("=====>> Result is: " + result + "\n");
}
private void convertAccountNamesToUpperCase(List<Account> result) {
for (Account account : result) {
account.setName(account.getName().toUpperCase());
}
}
效果其实已经贴在上面了
这种情况下,如果要修改 aspect,就需要对原本的 main app 也进行 recompile
@AfterThrowing
这个算是用的比较多的了,以我们现在的系统为例,任何的 exception 发生后都会触发各种各样的 email notification……
它的规则和 @AfterReturning
类似,只不过获取的不是返回结果,而是异常,代码实现如下:
@AfterThrowing(pointcut = "execution(* com.example.aopdemo.dao.AccountDAO.findAccounts(..))", throwing = "ex")
public void afterThrowingAddAccountAdvice(JoinPoint joinPoint, Throwable ex) {
String method = joinPoint.getSignature().toShortString();
System.out.println("=====>> Executing @AfterThrowing advice on method: " + method + " with exception:");
System.out.println("=====>> Exception is: " + ex + "\n");
}
一个 catch 并 swallow,另一个 catch 并且抛出的结果对比:
rethrow | swallow |
---|---|
![]() | ![]() |
@After
@After
是获取不到返回的数据或者是抛出的异常的,实现大体如下:
@After("execution(* com.example.aopdemo.dao.AccountDAO.findAccounts(..))")
public void afterFinallyAddAccountAdvice(JoinPoint joinPoint) {
String method = joinPoint.getSignature().toShortString();
System.out.println("=====>> Executing @After (finally) advice on method: " + method);
}
这里比较适合处理一些清理上下文、释放资源——手动分配的资源,如数据库这种 spring 自行管理的,还是让 spring 自己操作比较好、tracing 日志之类的操作,因为这些操作,不管业务逻辑成功还是失败,都是要执行的
@Around
@Around
在方法调用前后都会被触发,所以是最强大的 advice,因为这个特性,它可以执行很多的操作,如:
- 全局统一处理异常——非消化异常,而是将其包装成统一的格式进行返回
- 性能监控可以在调用前后进行计时,统计代码效率。当耗时太久时便可触发警报
- logging 中参数对比管理
- 用户校验
- 熔断/限流
- …
实现代码如下:
@Around("execution(* com.example.aopdemo.service.*.getTraffic(..))")
public Object getTraffic(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
System.out.println("=====>> Around: Executing @Around advice on getTraffic()");
long begin = System.currentTimeMillis();
Object result = proceedingJoinPoint.proceed();
long end = System.currentTimeMillis();
long duration = end - begin;
System.out.println("=====>> Around: Duration: " + duration / 1000.0 + " seconds");
return result;
}
效果如下:
@Around
处理异常
上面提到过 @Around
也可以处理异常,下面是实现方法:
@Around("execution(* com.example.aopdemo.service.*.getTraffic*(..))")
public Object getTraffic(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
System.out.println("=====>> Around: Executing @Around advice on getTraffic()");
Object result = null;
long begin = System.nanoTime();
try {
result = proceedingJoinPoint.proceed();
} catch (Exception ex) {
System.out.println("=====>> Around: Exception: " + ex);
result = "Major Accident! But no worries, your private AOP helicopter is on the way!";
}
long end = System.nanoTime();
long duration = end - begin;
System.out.println("=====>> Around: Duration: " + duration + " nanoseconds.");
return result;
}
效果如下:
其实这里比较推荐的是将异常统一包装后,再用 throw ex;
进行抛出。具体的业务逻辑还是在具体的地方实现比较好。如果统一在 advice 中处理,那么很有可能在某一个时间段,就会发生有些异常被 advice 消化了,而没有正确的在业务逻辑中被处理,那对于 debug 来说,也是非常大的挑战
最后补充一下 sequence diagram:
pointcut
这里主要提一下 pointcut 怎么做 match
pointcut 表达式
方法名 match -> exact match
这是前面出现过的写法:
@Before("execution(public void updateAccount())")
这里的话只会 match 到 public void updateAccount()
这一个方法,而且返回类型类型必须是 void
,而且是个无参方法。如果调用了其他的方法,或者是返回类型/参数不一样,那么就都不会触发这个 advice
main 部分如下:
package com.example.aopdemo;
import com.example.aopdemo.dao.AccountDAO;
import com.example.aopdemo.dao.MembershipDAO;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class AopdemoApplication {
public static void main(String[] args) {
SpringApplication.run(AopdemoApplication.class, args);
}
@Bean
public CommandLineRunner commandLineRunner(AccountDAO accountDAO, MembershipDAO membershipDAO) {
return runner -> {
demoTheBeforeAdvice(accountDAO, membershipDAO);
};
}
private void demoTheBeforeAdvice(AccountDAO accountDAO, MembershipDAO membershipDAO) {
accountDAO.addAccount();
membershipDAO.addAccount();
}
}
效果截图:
类 match -> exact match
这里会提供一个完整的路径(fully qualified classname)去做一个完整的 match:
@Before("execution(public void com.example.aopdemo.dao.AccountDAO.addAccount())")
public void beforeAddAccountAdvice() {
System.out.println("\n=====>> Executing @Before advice on AddAccount()");
}
换言之,这个 advice 只会在制定类下的 AccountDAO
调用 addAccount
才会触发。方法名的 match 上面提到了
这里 main 没有修改,效果如下:
可以看到 MembershipDAOImpl
中的方法没有触发 advice
match 所有 add 开始的方法
这里就可以用 wildcard 了:
@Before("execution(public void add*())")
public void beforeAddAccountAdvice() {
System.out.println("\n=====>> Executing @Before advice on Add*()");
}
效果如下:
⚠️:这里还是有返回类型和参数的限制
返回类型 match
这里的 advice 还是使用 void
,但是 addMembership
的返回类型换成了 boolean,可以看到 addMembership
没有出发 advice:
如果换成了 wildcard match,那么 addMembership
也能触发了:
参数 match
这里主要有这么几种:
-
()
无参 -
(SomeClass)
exact match 类型如:
@Before("execution(* add*(com.example.adpdemo.Account))")
这里就需要保证,传进去的参数必须是
com.example.adpdemo.Account
效果如下:
-
(*)
匹配 1 个任意格式的参数 -
(..)
匹配 0-n 个任意格式的参数这个写法中,第一个参数的要求依旧是
com.example.adpdemo.Account
,只是对于后面的参数没有限制,因此addMembership
依旧不会被触发:将第一个参数
com.example.adpdemo.Account
移除后,当前 advice 对 argument 没有任何的匹配,这样所有的方法都能触发 advice 了:
package 匹配
这个和之前的写法比较类似:
@Before("execution(* com.example.adpdemo.dao.*.*(..))")
需要注意的是,如果不对方法进行匹配,就需要额外再加一个 *
,上面这个匹配具体的理解方法是:任意参数 com.example.adpdemo.dao.包下的方法.任意方法(..)
❗:如果想匹配所有的 sub package 下的方法,写法为:
@Before("execution(* com.example.adpdemo.dao..*.*(..))")
pointcut 装饰器
之前的写法都在反复的 cv execution
这一段,其实 spring aop 提供了 pointcut 装饰器,用以提供 expression 的复用性,实现如下:
package com.example.aopdemo.aspect;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class MyDemoLoggingAspect {
@Pointcut("execution(* com.example.aopdemo.dao.*.*(..))")
private void forDaoPackage() {
}
@Before("forDaoPackage()")
public void beforeAddAccountAdvice() {
System.out.println("\n=====>> Executing @Before advice on execution(* add*(com.example.aopdemo.Account, ..))");
}
@Before("forDaoPackage()")
public void performApiAnalytics() {
System.out.println("\n=====>> Performing API analytics");
}
}
最终效果和反复 cv execution
的结果是一样的:
组合 pointcut 表达式
即添加 and, or 和 not 的操作:
@Pointcut("execution(* com.example.aopdemo.dao.*.get*(..))")
private void getter() {}
@Pointcut("execution(* com.example.aopdemo.dao.*.set*(..))")
private void setter() {}
@Pointcut("forDaoPackage() && !(getter() || setter())")
private void forDaoPackageNoGetterSetter() {}
结果如下:
aspect order
在运行过程中,流程可以确定的是从 before -> after returning/throwing -> after。但是在同一个周期内,不同 advice 运行的默认顺序是无法规定的。如果想要控制同一个生命周期内,多个 advice 的运行顺序,那么就可以通过 @Order
去实现
@Order
中接受参数的值从 integer 的最小值到最大值,并不需要连续,spring aop 会优先运行数值更小的 advice。当 advice 的 order 一样时,顺序则随机
实现大体如下:
规模太小了,所以没办法触发多个随机运行的结果
其他项目的运行结果
从之前 thymeleaf 的项目里重新用 Logger 实现了一下 aspect,一个比较简单的实现如下:
package com.demo.springboot.thymeleafdemo.aspect;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.logging.Logger;
@Component
@Aspect
public class DemoLoggingAspect {
private Logger logger = Logger.getLogger(getClass().getName());
@Pointcut("execution(* com.demo.springboot.thymeleafdemo.controller.*.*(..))")
private void controllerMethods() {}
@Pointcut("execution(* com.demo.springboot.thymeleafdemo.service.*.*(..))")
private void serviceMethods() {}
@Pointcut("execution(* com.demo.springboot.thymeleafdemo.dao.*.*(..))")
private void daoMethods() {}
@Pointcut("controllerMethods() || daoMethods() || serviceMethods()")
private void appFlow() {}
@Before("appFlow()")
public void logBefore(JoinPoint joinPoint) {
logger.info("========>> @Before " + joinPoint.getSignature().toShortString());
logger.info("========>> Arguments: " + Arrays.toString(joinPoint.getArgs()));
}
@AfterReturning(pointcut = "appFlow()", returning = "result")
public void logAfter(JoinPoint joinPoint, Object result) {
logger.info("========>> @AfterReturning " + joinPoint.getSignature().toShortString());
logger.info("========>> Result: " + result);
}
}
效果如下:
总结
-
✅ 推荐使用 AOP 处理:日志、权限校验、上下文清理、异常封装、性能监控
-
✅ 配合 MDC + ThreadLocal 实现 traceId 链路追踪更实用
这里没提到,有空再研究下
-
❌ 不推荐用 AOP 处理:复杂业务流程控制、数据库事务强控制、请求参数转换
这部分的内容大体都会通过框架管理,使用 aspect 可能会 break 框架的管理
-
🚫 Advice 中避免直接修改返回值、吞掉异常