一. 什么是 Spring AOP
关于什么是 Spring AOP. 当我翻到官方文档的时候也是一惊
Let us begin by defining some central AOP concepts and terminology. These terms are not Spring-specific… unfortunately, AOP terminology is not particularly intuitive; however, it would be even more confusing if Spring used its own terminology.
什么意思呢 ? 大概意思就是 Spring 官方说这些概念不是 Spring 独有的, 而是已经存在了的. 并且这些术语都不是很直观, 如果使用 SPring 自己的术语, 将会让人更加困惑.
当说到这的时候, 相信你已经知道了. 不是 Spring 特有的概念这不是关键, 主要的是连官方都说了 Spring AOP 的概念非常抽象, 理解起来会有一定的难度. 大致来看看到底什么是 SPring AOP, 它又到底是能做些什么 ?
1. Spring AOP 作用
现在无论是什么系统或者应用, 在使用之前都需要进行用户登陆验证. 除了登陆以及注册一面不需要验证以外, 其余功能基本都需要登陆才能使用. 因此想要完成这件事, 在之前我们是在所有需要验证的页面中通过 Controller 来登陆验证.
当你的功能越来越多之时, 你需要写的登陆验证也越来越多, 但是这些方法又是相同的, 会无故增加你很多代码修改和维护的成本. 那么如何将它单独抽离出来做成一个大家都能使用的功能, 让其他页面直接调用就能判定登录就是目前需要解决的.
在上面的基础之上, Spring AOP 的诞生就让其很好地进行了解决. 那么现在能解释什么是 Spring AOP 了吧 ?
AOP 是一种思想, Spring AOP 它是一种框架, 它提供了一种对 AOP 的具体实现. 类似于我们之前学的 Ioc 和 DI 之间的关系. 简单来说, AOP 干的是将某一个统一的功能集中处理.
- 统一的用户登录
- 统一的日志记录
- 统一的方法执行时间统计
- 统一的返回格式
- 统一的异常处理
- 统一的事务开启和提交
除了这些, 还有很多. 也就是说使用 AOP 可以扩充多个对象的某种能力. ( 张三、李四都具有相同的登陆能力等等 ).
2. AOP 的组成
2.1 Aspect 切面
什么是切面 ?
指的是横切多个类的一种模块. 在 Spring 中切面用的就是普通的类 ( XML 或者 @Aspect 注解配置 ). 这么一听好像很抽象, 比如我们刚刚说的登陆页面模块, 查看他人文章, 发布文章等等, 在执行自己对应的功能之前进行登录验证. 那么为了处理这个问题, 创建了一个普通类来集中处理. 而这个类就是切面.
这里的多个类就是执行对应的功能类, 而横切就是登陆. 把登陆创建成一个集中模块来处理就是切面.
2.2 Joint point
Joint Point : 连接点, 表示要横切的方法. 就是执行 AOP 功能的所有方法
例如上面登陆说的, 查看他人文章和发布文章的功能方法都是连接点.
2.3 Pointcut 切点
所谓的切点就是从哪里开始入手的意思. 也就是对于那些连接点起作用.
例如上面登陆说的, 可以从查看他人文章和发布文章这两个功能切入, 也就是定义的 AOP 只对这两个起作用, 而对于注册功能是不起作用的, 因为它不需要实现登录检验.
2.4 Advice 通知
通知, 是非常复杂的. 它里面定义了切面是什么, 什么时候使用. 描述了切面要完成的工作, 还解决什么时候执行这个工作的问题.
关于 Advice 通知它有很多类型, 可以在方法上使用一下注解, 会设置改方法为通知方法, 在满足条件以后会通知本方法进行调用
2.4.1 前置通知 : @Before
通知方法会在目标方法 ( 也就是连接点 ) 调用之前执行
2.4.2 后置通知 : @After
通知方法会在目标方法 ( 连接点 ) 调用之后执行.
PS : 无论连接点是正常结束还是异常结束都会执行
2.4.3 返回通知 : @AfterReturning
通知方法会在目标方法 ( 连接点 ) 返回之后调用
PS : 需要方法正常 return 并且没有抛出异常
2.4.4 异常通知 : @AfterThrowing
通知方法会在目标方法 ( 连接点 ) 异常之后调用
2.4.5 环绕通知 : @Around
通知包裹了被通知的方法, 在被通知的方法 ( 连接点 ) 通知之前和调用之后执行的自定义的行为
二. Spring AOP 实现
知道了什么是 Spring AOP 还有 AOP 的组成等等, 现在就可以来简单实现一个统一的登陆功能了.
1. 添加 Spring AOP 框架
因为我们的 Spring Boot 框架没有内置 Spring AOP 因此, 我们需要手动引入依赖. 而 Spring 框架中是有 Spring AOP 依赖的. 但我此处是 Spring Boot 项目. 需要引入 Spring Boot 框架支持的 Spring AOP 框架.
上 Maven 仓库 中进行引入依赖, 一定是引入的 Spring Boot 支持的 Spring AOP 依赖
选择一个相应的版本进行 pom.xml 中引入依赖即可
<!-- 添加的是 Spring Boot 项目的 AOP 依赖, 而原生的 AOP 依赖在 Spring 中使用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.7.12</version>
</dependency>
2. 定义切面
引入好了 Spring AOP 以后, 对登陆功能进行统一处理, 需要先建立切面, 也就是前所说的一个普通类来集中处理
@Aspect // 添加切面注解
@Component // 随 Spring 框架启动而注入
// 创建切面 - 关于某一操作的统一类
public class UserAOP {
}
可以看到, 我添加了 @Aspect 和 @Component 注解.
- @Aspect 注解也就是切面注解, 表明我们当前这个普通类是一个切面
- @Component 注解不陌生, 将其添加到我们的 Spring 容器当中供外部使用. 为什么呢 ?
原因也很简单就是为了在 Spring 框架启动时就注入, 否则其他功能进行访问时, 还未成功加载, 就无法进行统一的处理了.
3. 定义切点
切点我们刚刚也说了, 它是对我们的连接点进行匹配的, 看那些连接点是我们需要处理的, 那些是我们不需要处理的, 从而提高效率. 并且切点是必须定义在切面里的
@Pointcut("execution(* com.example.demo.controller.UserController.* (..))")
public void doPointcut() {
// 切点只是为了配置规则, 并非具体实现. 因此为空方法
}
@Pointcut 切点注解, 里面需要配置 execution 也就是需要处理的
而里面配置就是我们具体需要匹配的连接点, 相当于拦截的规则. 而这里面是如何设置的, 采用的是 aspectj 的语法
3.1 aspectj 语法
Aspectj 支持的三种通配符 :
1.* : 匹配任意字符, 只匹配一个元素 ( 包, 类, 方法, 或者方法参数 )**
2. … : 匹配任意字符, 可以匹配多个元素, 在表示类的时候必须和 * 联合使用
3. + : 表示按照类型匹配指定类的所有类, 必须跟在类名后面
如 com.example.demo.User + 这就表示继承 com.example.demo 包底下的 User 类的所有子类, 也包括 User 本身
execution 的组成包括 : ( 修饰符 + 返回类型 + 包.类.方法(参数) + 异常 )
- 修饰符
在 aspectj 中 修饰符一般可以省略. 如果不省略的话, 可以进行声明, 例如
( public + 返回类型 + 包.类.方法(参数) + 异常 )
当然, 除了指定特定返回值以外, 还可以使用通配符一次性匹配所有的修饰符
( * + 返回类型 + 包.类.方法(参数) + 异常 )
- 返回类型
返回值是不可以省略的. 和修饰符一样, 可以指定返回值或者使用通配符
- 包.
固定包 : com.example.demo
固定包底下的任意子包 : com.example.demo.*.service
即 com.example.demo 固定包下面的 service 包
固定包底下的所有包 : com.example.demo…
即 com.example.demo 固定包下的所有包, 包括本身
- 类.
指定类 : UserController
Controller : 以 Controller 结尾
User : 以 User 开头
- 方法
方法名不能省略
fun : 固定方法
fun* : 以 fun 开头的方法
*fun : 以 fun 结尾的方法
通配符 * : 任意方法
- 参数
( ) : 无参
( int ) : 指定 int 类型参数
( int, int ) : 指定两个 int 类型的参数
( … ) : 表示任意参数
- 异常
异常一般省略不写
3.2 实现切点
了解了 Aspectj 语法以后, 现在就可以在 @Pointcut 注解里配置 execution 拦截规则了.
例如, 我想拦截 com.example.demo.controller 包下的 UserController 类任意参数并且返回值任意的所有方法
@Pointcut("execution(* com.example.demo.controller.UserController.* (..))")
public void doPointcut() {
// 切点只是为了配置规则, 并非具体实现. 因此为空方法
}
可以看到, 我在里面提供了一个没有方法体的空方法 doPointcut( ), 需要注意的是, **切点它只是为连接点是否执行 AOP 设置匹配规则的, 并非具体的连接点功能实现. **
4. 建立连接点
由于刚刚的切点配置的是 com.example.demo.controller.UserController , 因此起作用的连接点只能在这里面, 而在这外面的连接点都是不起作用的.
在指定的类里写我们的连接点和具体连接方法实现, 我们上面说的查看文章和发布文章
@RequestMapping("/user/article")
public String login1() {
System.out.println("执行了查看文章功能中的登陆检验");
return "Spring AOP";
}
@RequestMapping("user/write")
public String login2() {
System.out.println("执行了写文章功能中的登录检验");
return "Spring AOP";
}
5. 建立通知
为了更方便我们查看是否真的执行了 Spring AOP 的统一登录功能, 我们可以在切面中实现一个通知来帮助我们观察, 下面就举例一个前置通知
// 配置前置通知
@Before("pointcut()") // 里面填写针对那个切点的通知, 可以有多个切点
public void doBefore() {
System.out.println("执行 before 前置通知 : 登陆检验" );
}
通知在我们上面的 AOP 组成中说到, 它里面定义了切面是什么, 什么时候使用. 这里的定义了切面指的就是是因为我们通知的一般主体是切面. **可以理解为这个通知就是通知这个统一的处理去干什么 ( 切点 - 拦截那些内容 ), 什么时候执行( 在连接点之前, 之后等, 具体看什么通知 ) **
6. 检验统一登录
当我们想使用写文章和查看文章功能的时候, 需要先经过 Spring AOP 的统一登录检验, 来看看我们的这两个路由方法是否能正确在 Spring AOP 中执行
- 查看文章功能
可以看到, 当我们使用查看文章时, 它便会进行指定的统一登录检验, 触发切面中的通知方法.
- 写文章功能
同样的, 当我们执行写文章功能时, 它也会先进行统一登录的检验, 触发切面中的通知方法
当然, 上面还是不够直观, 我们可以创建一个切点以外 ( 和切点路径不匹配的 ) 的连接点
我在 controller 包底下建立了一个 RegisterController 类, 来表示我的注册类
@RestController
public class RegisterController {
@RequestMapping("/user/reg")
public String register() {
System.out.println("执行注册功能 -> ");
return "Spring AOP";
}
}
显然, 我们的注册方法是不需要经过统一的登陆处理的. 因此预期它不会触发我们切面中的通知方法
可以看到, 我们虽然执行了注册功能, 但是并没有触发通知方法, 也就是说并没有进行统一的登陆检验.
三. Advice 通知演示
刚刚已经演示过了 @Before 前置通知, 就不在赘述了
1. @After 注解
利用 @After 后置通知实现一个统一的日志处理功能
- 建立切面
@Aspect
@Component
public class LogAOP {
}
- 创建切点
@Aspect
@Component
public class LogAOP {
// 创建切点
@Pointcut("execution(* com.example.demo.controller.LogController.* (..))")
public void doPointcut() {
// 切点只是为了配置规则, 并非具体实现. 因此为空方法
}
}
- 创建后置通知
@Aspect
@Component
public class LogAOP {
// 创建切点
@Pointcut("execution(* com.example.demo.controller.LogController.* (..))")
public void doPointcut() {
// 切点只是为了配置规则, 并非具体实现. 因此为空方法
}
// 创建后置通知
@After("doPointcut()")
public void doAfter() {
System.out.println("记录日志结束 ");
}
}
- 建立连接点
@RestController
public class LogController {
// 创建日志对象
public static final Logger log = LoggerFactory.getLogger(LogController.class);
@RequestMapping("/user/log")
public String longLog() {
System.out.println("执行登陆功能");
log.info("记录操作日志 : 用户成功登陆");
return "Spring AOP";
}
}
访问路由方法模拟执行用户登陆
可以看到, 当执行登陆后, 记录下操作日志, 此时该连接点方法执行结束, 执行后置通知 " 日志记录结束 "
2. @AfterReturning 注解
还是刚刚的登陆操作, 当执行的是 @AfterReturning 返回通知时, 预期在登陆操作结束后执行
- 建立切面
@Aspect
@Component
public class UserAOP {
}
- 创建切点
@Pointcut("execution(* com.example.demo.controller.UserController.* (..))")
public void pointcut() {
// 切点只是为了配置规则, 并非具体实现. 因此为空方法
}
- 建立返回通知
@AfterReturning("doPointcut()")
public void doAfterReturning() {
System.out.println("执行返回通知 ");
}
- 创建连接点
@RequestMapping("user/login")
public String login() {
System.out.println("执行登陆操作 ");
return "Spring AOP";
}
执行登陆方法
3. @AfterThrowing 注解
异常通知, 当执行匹配的连接点的方法遇到异常结束后返回通知
还是刚刚的切面里, 同样的切点, 建立异常通知, 执行登陆方法
@AfterThrowing("doPointcut()")
public void doAfterThrowing() {
System.out.println("执行异常通知 ");
}
可以看到抛异常了, 并且控制台也在异常之后打印了异常通知
4. @Around 注解
环绕通知, 被通知的方法本身被通知包裹着, 也就是执行环绕通知在被通知的方法执行之前会发一次通知, 在被执行方法执行结束后也会执行一次通知.
还是在刚刚的切面和切点, 建立环绕通知
// 添加事件本身
// ProceedingJoinPoint 表示正在执行的方法或表达式的连接点
// 配置环绕通知
@Around("doPointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("开始执行环绕通知" );
long startTime = System.currentTimeMillis();
// 因为是将方法包围起来执行, 因此只能在方法里调用本身
Object object = joinPoint.proceed(); // 调用连接点的方法
System.out.println("结束执行环绕通知" );
long endTime = System.currentTimeMillis();
System.out.println("时间差为 : " + (endTime - startTime));
return object; // 计算时间差后并获取秒数毫秒级
}
可以看到, 环绕通知里面有 joinPoint, 前面说到它是连接点的意思. 这里不难明白, 切面需要通过连接点来确定何时调用环绕通知和如何调用它们. 因此这里传入的必须是连接点. 而前面的前置、后置通知等切面只需要在正确匹配的连接点之前或者之后通知就可以了.
建立连接点
@RequestMapping("/user/count")
public String fun1() {
System.out.println("执行了 count 方法");
int count = 0;
for(int i = 0; i < 1000000000; i++) {
count++;
}
return "统计方法";
}
通过环绕通知, 我们就可以统计某个方法的具体执行时间了, 从而作为该方法是否需要优化的重要依据之一.
5. 环绕和前置后置通知同时执行
你可能发现了, 环绕通知其实就是一个前置通知搭配一个后置通知. 那么, 既然前置后置都有了, 为什么还需要环绕通知呢 ? 他们放在一起执行会报错呢 ? 还是有什么联系呢 ?
public class UserAOP {
@Pointcut("execution(* com.example.demo.controller.UserController.* (..))")
public void doPointcut() {
// 切点只是为了配置规则, 并非具体实现. 因此为空方法
}
// 配置前置通知
@Before("pointcut()") // 里面填写针对那个切点的通知, 可以有多个切点
public void doBefore() {
System.out.println("执行 before 前置通知 : 登陆检验" );
}
@After("doPointcut()")
public void doAfter() {
System.out.println("执行 After 后置通知 : 登陆检验结束");
}
// 添加事件本身
// ProceedingJoinPoint 表示正在执行的方法或表达式的连接点
// 配置环绕通知
@Around("doPointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("开始执行环绕通知" );
long startTime = System.currentTimeMillis();
// 因为是将方法包围起来执行, 因此只能在方法里调用本身
Object object = joinPoint.proceed(); // 调用连接点的方法
System.out.println("结束执行环绕通知" );
long endTime = System.currentTimeMillis();
System.out.println("时间差为 : " + (endTime - startTime));
return object; // 计算时间差后并获取秒数毫秒级
}
}
可以看到, 前置和后置通知总是在之间环绕通知的. 这是为什么 ?
由于环绕通知需要在目标方法执行之前和之后分别执行, 以便正确的控制目标方法的执行
具体来说, 如果放在前置和后置执行之间, 那么当目标方法执行时, 环绕通知也会被调用. 通俗一点理解就是执行方法时的前置通知时间是极短的, 如果在这执行前置通知还需要执行环绕通知有可能会导致重复执行, 从而导致代码重复和性能损失.
6. 总结
Spring AOP 除了上面的这几样简单的统一功能处理外, 还有很多功能可以实现, 只要它符合统一集中处理的思想, 也就是符合 AOP 的思想. 就可以用 Spring AOP 来实现.
四. Spring AOP 原理分析
Spring AOP 倒地是怎么执行的呢 ? 它为什么就知道那些连接点是我们匹配的, 那些是我们不需要的呢 ?
Spring AOP 是构建在动态代理基础上的. 因此 Spring 对 AOP 的支持局限于方法级别的拦截.
我们上面所写的 Spring AOP 它都是原生的. 而在 JDK 中早已替我们封装了
- JDK 动态代理
通过反射的机制, 在运行的时候生成一个代理对象, 并将所有的方法调用转发给代理对象. 代理对象实现了 InvocationHandler 接口,并重写了 invoke() 方法, 该方法会在代理对象被调用的时候执行.
- CGLIB 动态代理
和 JDK 动态代理类似, 也是通过代理对象来实现方法调用的转发. 但 CGLIB 动态代理比 JDK 动态代理更加的灵活, 因为它可以处理继承关系中私有的方法中的私有方法.
无论是那种方式, 在 Spring AOP 都可以使用动态代理的方式来实现切面逻辑