【Spring6笔记】 - 13 - 面向切面编程(AOP)
1. AOP 介绍
1.1 什么是 AOP?
AOP (Aspect Oriented Programming),即面向切面编程。它是 OOP(面向对象编程)的有效补充和完善。
- OOP: 核心是对象。将需求功能划分为不同的对象,其逻辑是自上而下的垂直结构。
- AOP: 核心是切面。它通过"横向抽取"机制,将散落在各个业务模块中的公共代码(如日志记录、事务控制、权限校验)抽取出来,形成独立模块,在程序运行期间再动态地"织入"到业务逻辑中。
1.2 AOP 的底层原理
AOP 的底层是基于 动态代理 实现的:
- 如果目标类实现了接口,默认使用 JDK 动态代理。
- 如果目标类没有实现接口,使用 CGLIB 动态代理。
2. AOP 的七大术语
理解 AOP 的核心就在于理解这七个名词,它们定义了"在哪里干"、"干什么"、"怎么干"。
2.1 连接点 (JoinPoint)
- 描述: 连接点描述的是位置。
- 定义: 在程序的整个执行流程中,可以织入切面的特定位置。例如:方法调用前、方法调用后、异常抛出后等。
- 形象理解: 森林里的每一棵树都可以被标记,这些可标记的点就是连接点。
2.2 切点 (PointCut)
- 描述: 切点本质上是筛选后的连接点。
- 定义: 真正织入切面的那个位置(方法)。通过切点表达式来定位具体的连接点。
- 形象理解: 虽然每棵树都能标记,但我只在"挂了红绳"的树上操作。
2.3 通知 / 增强 (Advice)
- 描述: 通知描述的是具体要做什么代码。
- 定义: 注入到切点上的那段代码。
- 分类:
- 前置通知 (@Before): 目标方法执行前执行。
- 后置通知 (@AfterReturning): 目标方法成功执行并返回后执行。
- 环绕通知 (@Around): 目标方法执行前后都执行,功能最强。
- 异常通知 (@AfterThrowing): 目标方法抛出异常后执行。
- 最终通知 (@After): 无论是否发生异常,最后都会执行(类似
finally)。
2.4 切面 (Aspect)
- 公式: 切面 = 切点 + 通知。
- 定义: 它是一个具体的类(使用
@Aspect标注),包含了"往哪切"和"切了干什么"的完整逻辑。
2.5 织入 (Weaving)
- 描述: 织入是一个过程。
- 定义: 把通知应用到目标对象上的动态过程。
2.6 代理对象 (Proxy)
- 定义: 目标对象被织入通知后,由 Spring 容器生成的那个"加了特技"的新对象。
2.7 目标对象 (Target)
- 定义: 被织入通知的原始业务对象(如
UserService)。
2.8 形象化案例:警察查酒驾
1. 目标对象 (Target)
- 例子 :公路上正常行驶的所有司机。
2. 连接点 (JoinPoint)
- 例子 :马路上的每一个十字路口 、红绿灯口 或收费站。这些地方理论上都可以设岗查车。
3. 切点 (Pointcut)
- 例子:警察最终决定,今晚只在**"北京路与南京路交叉口"**设岗。
4. 通知 / 增强 (Advice)
- 例子 :警察拦截后的具体动作:拿出测酒仪让司机吹气,并记录结果。
5. 切面 (Aspect)
- 例子 :"交警大队的查酒驾计划书"。上面写清楚了:要在哪个路口(切点)干什么事(通知)。
6. 织入 (Weaving)
- 例子 :"设岗检查这个动作"。警察真的站到了路口,把查酒驾的逻辑应用到了司机的行驶路径中。
7. 代理对象 (Proxy)
- 例子 :"被拦截检查后的司机"。现在的司机不是简单的行驶了,而是"行驶 + 吹气"的组合体。
总结:
| 术语 | 查酒驾例子 | 程序员视角 |
|---|---|---|
| 目标对象 (Target) | 开车的司机 | 原始的业务类 |
| 连接点 (JoinPoint) | 每一个十字路口 | 每一个方法 |
| 切点 (Pointcut) | 选中的某个路口 | 选中的某个方法 |
| 通知 (Advice) | 吹气测酒精 | 增强的代码逻辑 |
| 切面 (Aspect) | 查酒驾方案 | 切点 + 通知的组合类 |
| 织入 (Weaving) | 设岗检查的动作 | 动态生成的代理过程 |
| 代理对象 (Proxy) | 配合检查的司机 | 增强后的新对象 |
3. 切点表达式 (Pointcut Expression)
切点表达式决定了你的"横切逻辑"要作用于哪些方法。
3.1 表达式语法
java
execution([访问权限修饰符] 返回值类型 [全限定类名] 方法名(形式参数列表) [异常])
3.2 符号详解
*:匹配任意数量的字符。..:用在包名处表示当前包及其子包;用在参数列表处表示任意参数。
3.3 常用示例
| 表达式 | 描述 |
|---|---|
execution(* com.zzz..*(..)) |
匹配 com.zzz 包及其子包下所有类的所有方法 |
execution(public * *(..)) |
匹配所有公开(public)的方法 |
execution(* com.zzz.service.*.delete*(..)) |
匹配 service 包下所有类中以 delete 开头的方法 |
execution(* *(String, ..)) |
匹配第一个参数是 String 的所有方法 |
4、使用Spring的AOP
4.1 基于 AspectJ 的 AOP 注解式开发
4.1.1 环境准备
在 Spring6 中使用 AOP,首先需要引入 spring-aspects 依赖,但是我们使用的是 spring-context,它通常已经包含了基础 AOP 功能。
xml
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.2.11</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>6.2.11</version>
</dependency>
关键配置(二选一):
-
方式 A:XML 启用
xml<aop:aspectj-autoproxy proxy-target-class="true"/> -
方式 B:全注解启用(推荐)
java@Configuration @ComponentScan("com.zzz.service") @EnableAspectJAutoProxy(proxyTargetClass = true) // 开启自动代理 public class SpringConfig { }
注意
proxy-target-class:
true:强制使用 CGLIB 动态代理(基于继承)。false(默认):优先使用 JDK 动态代理(基于接口)。
4.1.2 目标类定义 (UserService.java)
这是我们要"切入"的对象,它只负责核心业务。
java
@Service("userService")
public class UserService {
// 目标方法
public String login(String username, String password) {
System.out.println("系统正在验证身份...");
//用作异常测试
//if(1 == 1){
//throw new RuntimeException("异常");
//}
return ("admin".equals(username) && "123456".equals(password)) ?
"登录成功" : "登录失败";
}
}
4.1.3 切面类结构分解
一个完整的切面类需要使用 @Component 交给 Spring 管理,并使用 @Aspect 标注其为切面。
1. 定义切点 (Reusable Pointcut)
为了避免在每个通知注解里重复写切点表达式,建议定义一个通用的切点方法。
java
@Pointcut("execution(* com.zzz.service.UserService.*(..))")
public void pointCut() {
// 此方法仅作为切点标识,不写代码
}
2. 五种通知类型的实现
java
@Component("logAspect")
@Aspect // 标注这是一个切面类
@Order(2) // 设置优先级,数字越大优先级越低(在 SecurityAspect 之后执行)
public class LogAspect {
/**
* 【切点 Pointcut】:定义往哪些方法上切入。
* 抽取公共表达式,方便后续复用。
*/
@Pointcut("execution(* com.zzz.service.UserService.*(..))")
public void pointCut() {}
/**
* 【前置通知 @Before】:在目标方法执行前执行。
* @param joinPoint 能够获取目标方法的签名、参数等情报
*/
@Before("pointCut()")
public void beforeAdvice(JoinPoint joinPoint) {
System.out.println("前置日志通知");
// 获取目标方法签名:public String login(String, String)
System.out.println("目标方法签名:" + joinPoint.getSignature());
}
/**
* 【后置通知 @AfterReturning】:目标方法"正常结束后"执行。
*/
@AfterReturning("pointCut()")
public void afterReturning() {
System.out.println("后置日志通知");
}
/**
* 【环绕通知 @Around】:最强大的通知,包围了整个目标方法。
* @param joinPoint 必须使用 ProceedingJoinPoint,它有 proceed() 方法
*/
@Around("pointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("前置环绕通知:计时/日志开始...");
// 【关键】:手动调用目标方法执行!
Object proceed = joinPoint.proceed();
System.out.println("后置环绕通知:计时/日志结束...");
return proceed;
}
/**
* 【异常通知 @AfterThrowing】:目标方法抛出异常时执行。
*/
@AfterThrowing("pointCut()")
public void afterThrowing() {
System.out.println("异常通知:检测到异常,触发告警!");
}
/**
* 【最终通知 @After】:相当于 finally 块,无论是否有异常都会执行。
*/
@After("pointCut()")
public void after() {
System.out.println("最终通知:释放资源...");
}
}
4.1.4 多个切面的顺序控制 (@Order)
当一个方法被多个切面(如 LogAspect 和 SecurityAspect)同时拦截时,谁先执行?
- 解决方案: 使用
@Order(数字)注解。 - 规则: 数字越小,优先级越高。
- 执行逻辑: 进入 时:数字小的先执行(如安全校验先于日志记录)。
- 退出时:数字小的后执行(类似剥洋葱,最外层先切入,最后离开)。
代码示例:
java
@Component("securityAspect")
@Aspect
@Order(1) // 数字越小,优先级越高。SecurityAspect(1) 会在 LogAspect(2) 外层执行。
public class SecurityAspect {
// 跨类引用切点:需要写全类名路径
@Before("com.zzz.service.LogAspect.pointCut()")
public void before() {
System.out.println("前置安全通知:权限校验中...");
}
}
@Aspect
@Order(2) // 优先级低,内层
public class LogAspect { ... }
4.1.5 核心细节补充:JoinPoint 参数
在通知方法中,我们可以通过 JoinPoint(环绕通知用 ProceedingJoinPoint)获取目标方法的"情报":
joinPoint.getArgs():获取目标方法的入参。joinPoint.getSignature():获取方法签名(包括方法名、声明类型等)。joinPoint.getTarget():获取被代理的目标对象。
4.1.6 测试验证
java
@Test
public void testNoXml() {
// 加载配置类
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(SpringConfig.class);
// 获取代理对象(注意:即便类型写 UserService,拿到的也是增强后的代理类)
UserService userService = context.getBean("userService", UserService.class);
userService.login("admin", "123456");
}
通知的执行顺序
当调用 userService.login() 且没有异常时,执行顺序如下:
- SecurityAspect
@Before(Order 1) - LogAspect
@Around(前半部分) - LogAspect
@Before(Order 2) - UserService
login()(目标方法执行) - LogAspect
@Around(后半部分) - LogAspect
@AfterReturning - LogAspect
@After(最终通知)
测试结果:

现在我们在UserService.login()方法中抛出异常
异常情况下的执行顺序
如果在 UserService.login() 执行过程中发生了异常,执行链条如下:
- SecurityAspect
@Before(Order 1) ------ 正常拦截。 - LogAspect
@Around(前半部分) ------ 正常开始。 - LogAspect
@Before(Order 2) ------ 正常拦截。 - UserService
login()------ [ 抛出异常 ] - LogAspect
@Around(后半部分) ------ 不会执行 ,因为joinPoint.proceed()抛出了异常,环绕通知后续逻辑被中断。 - LogAspect
@AfterThrowing------ 异常通知触发,捕获到异常并执行增强代码。 - LogAspect
@After------ 最终通知触发,无论成败,这里都会执行。
测试结果:

正常 vs 异常 执行对比表
我们将两种情况放在一起对比:
| 执行阶段 | 正常执行 (Success) | 异常发生 (Exception) |
|---|---|---|
| 1. 外部切面 | SecurityAspect @Before |
SecurityAspect @Before |
| 2. 环绕开始 | LogAspect @Around (前) |
LogAspect @Around (前) |
| 3. 内部前置 | LogAspect @Before |
LogAspect @Before |
| 4. 目标方法 | UserService.login() |
UserService.login() (报错) |
| 5. 环绕结束 | LogAspect @Around (后) |
跳过 |
| 6. 结果通知 | LogAspect @AfterReturning |
LogAspect @AfterThrowing |
| 7. 最终通知 | LogAspect @After |
LogAspect @After |

4.2 基于 XML 配置方式的 AOP
4.2.1 核心思想
在 XML 配置方式中,目标类 和切面类都是普通的 POJO(Plain Old Java Object),它们内部不包含任何 Spring 特有的 AOP 注解。所有的连接逻辑(哪个方法切入哪段代码)全部由 XML 文件统一管理。
这种方式的内核依然是 "切点 + 通知 = 切面" ,只是将原本写在 Java 类上的注解全部"外迁"到了 spring.xml 中。
4.2.2 目标类与切面类实现
由于不使用注解,代码显得非常"干净":
1. 目标类 (UserService.java)
java
public class UserService { // 目标对象:纯粹的业务逻辑
public void logout(){
System.out.println("系统正在安全退出");
//用作异常测试
//if(1 == 1){
//throw new RuntimeException("异常");
//}
}
}
2. 切面类 (TimerAspect.java)
这里只需要编写增强逻辑的方法,方法名aroundAdvice可以自定义,不需要加 @Around 等注解。
java
public class TimerAspect {
// 这是一个环绕通知逻辑
public void aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
// 前环绕:记录开始时间
long begin = System.currentTimeMillis();
// 执行目标方法
joinPoint.proceed();
// 后环绕:记录结束时间
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - begin) + "毫秒");
}
public void beforeAdvice() throws Throwable {
System.out.println("前置通知");
}
public void afterAdvice() throws Throwable {
System.out.println("最终通知");
}
public void afterReturningAdvice() throws Throwable {
System.out.println("后置通知");
}
public void afterThrowingAdvice() throws Throwable {
System.out.println("异常通知");
}
}
4.2.3 XML 核心配置 (spring.xml)
这是 XML 方式的精髓。你需要使用 <aop:config> 标签及其子标签来手动完成"编织"过程。
xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd">
<bean id="userService" class="com.zzz.service.UserService"/>
<bean id="timerAspect" class="com.zzz.service.TimerAspect"/>
<aop:config>
<aop:pointcut id="pointCut" expression="execution(* com.zzz.service..*(..))"/>
<aop:aspect ref="timerAspect">
<aop:around method="aroundAdvice" pointcut-ref="pointCut"/>
<aop:before method="beforeAdvice" pointcut-ref="pointCut"/>
<aop:after-throwing method="afterThrowingAdvice" pointcut-ref="pointCut"/>
<aop:after-returning method="afterReturningAdvice" pointcut-ref="pointCut"/>
<aop:after method="afterAdvice" pointcut-ref="pointCut"/>
</aop:aspect>
</aop:config>
</beans>
4.2.4 测试验证
java
public class SpringAOPTest {
@Test
public void testXML(){
// 加载 XML 配置文件
ClassPathXmlApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring.xml");
// 从容器中获取代理对象
UserService userService = applicationContext.getBean("userService", UserService.class);
// 调用方法,此时会自动触发 TimerAspect 中的 aroundAdvice 逻辑
userService.logout();
}
}
正常运行结果:

异常运行结果:

4.2.5 注解式 vs XML 配置式对比
| 特点 | 基于 AspectJ 注解 | 基于 XML 配置 |
|---|---|---|
| 便利性 | 极高,开发速度快 | 较低,需要频繁切换 XML |
| 侵入性 | 较高(代码中带有 @Aspect 等注解) | 极低(Java 类是纯净的 POJO) |
| 集中性 | 分散在各个切面类中 | 高度集中,在一个 XML 中纵览全局 |
| 适用场景 | 现代主流开发、微服务 | 老项目维护、不希望修改源代码的第三方库集成 |
5. AOP 实际案例:事务处理 (Transaction Management)
5.1 案例背景
在银行转账或订单系统中,一个业务方法通常包含多个数据库操作。为了保证数据的原子性,我们必须:
- 执行前:开启事务。
- 成功后:提交事务。
- 异常时:回滚事务。
如果每个业务方法都手动写这些代码,会导致大量的冗余且难以维护。
5.2 业务目标类实现
这些类作为 Target(目标对象),其内部代码非常纯粹,只包含核心业务逻辑。
1. 账户业务 (AccountService.java)
java
@Service
public class AccountService {
// 转账业务
public void transfer(){
System.out.println("银行账户正在完成转账操作...");
}
// 取款业务
public void withdraw(){
System.out.println("银行账户正在完成取款操作...");
}
}
2. 订单业务 (OrderService.java)
java
@Service
public class OrderService {
public void generate(){
System.out.println("正在生成订单...");
}
// 模拟异常场景:取消订单时抛出空指针异常
public void cancel(){
System.out.println("订单已取消...");
String s = null;
s.toString(); // 这里会触发 NullPointerException
}
}
5.3 事务切面实现 (TransactionAspect.java)
这是 AOP 的精华所在。我们使用 @Around(环绕通知) ,因为它能完整控制整个事务的生命周期,并利用 try-catch 捕捉异常实现回滚。
java
@Aspect
@Component
public class TransactionAspect {
/**
* 环绕通知:模拟 Spring 底层的声明式事务控制
* 匹配 com.zzz.service 包下所有类的所有方法
*/
@Around("execution(* com.zzz.service..*(..))")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) {
Object proceed = null;
try {
// 【前置增强】:相当于开启事务
System.out.println("==> 开启事务 (Transaction Begin)");
// 执行目标方法
proceed = joinPoint.proceed();
// 【后置增强】:如果没有异常,正常提交事务
System.out.println("==> 提交事务 (Transaction Commit)");
} catch (Throwable e) {
// 【异常增强】:一旦捕获到 Throwable,立即回滚
System.out.println("==> 检测到异常 [" + e.getClass().getSimpleName() + "],回滚事务 (Transaction Rollback)");
// 注意:在实际开发中,通常会将异常继续抛出,以便让调用者知道出错
}
return proceed;
}
}
5.4 运行逻辑分析
java
@Test
public void testTransactionAOP() {
// 1. 初始化 Spring 容器
ApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring.xml");
// 2. 从容器中获取 AccountService (正常情况)
System.out.println("--- 场景 A:模拟正常转账 ---");
AccountService accountService = applicationContext.getBean("accountService", AccountService.class);
accountService.transfer();
System.out.println("\n----------------------------\n");
// 3. 从容器中获取 OrderService (异常情况)
System.out.println("--- 场景 B:模拟异常取消订单 ---");
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
orderService.cancel();
}
我们分析两种运行情况:
情况 A:正常转账 (AccountService.transfer)
- 触发切面,打印:
开启事务 - 执行业务:
银行账户正在完成转账操作... - 业务结束,打印:
提交事务
情况 B:异常取消订单 (OrderService.cancel)
- 触发切面,打印:
开启事务 - 执行业务:
订单已取消... - 触发代码
s.toString()-> 抛出异常。 - 切面
catch到异常,跳过提交逻辑,打印:回滚事务
测试结果:

5.5 案例总结
通过这个案例,我们可以深刻体会到 AOP 的优势:
- 零侵入 :
OrderService和AccountService完全不知道事务切面的存在。 - 高复用 :只需写一个
TransactionAspect,就可以保护com.zzz.service包下成千上万个方法。 - 易维护:如果以后要更换事务实现方式,只需修改切面类一处即可。
- 代理对象的本质 : 在测试类中,虽然我们写的代码是
applicationContext.getBean("orderService", OrderService.class),但 Spring 容器返回给你的其实是 OrderService 的代理对象 。当你调用cancel()时,实际上是先进入了代理对象的逻辑。 - 异常捕获的逻辑 : 在
TransactionAspect的环绕通知中,我们通过try-catch包裹了joinPoint.proceed()。- 如果没有 Catch:异常会直接抛给调用者,事务无法自动回滚(在 AOP 层面)。
- 有了 Catch :我们可以在
catch块中执行System.out.println("回滚事务")。这正是 Spring 声明式事务处理的核心原理。
6. AOP 实际案例:安全日志 (Security Logging)
6.1 案例背景
需求描述: 系统要求对所有业务模块(User、Vip 等)的"增、删、改"操作进行审计。记录具体是谁、在什么时间、访问了哪个类的哪个方法。
关键技术:
- 利用
execution表达式匹配方法名。 - 使用
||(逻辑或) 组合多个切点。 - 通过
JoinPoint获取目标类的全限定名和方法名。
6.2 业务目标类实现
这些类包含各种业务方法,我们需要通过 AOP 筛选出需要记录日志的方法。
1. 用户业务 (UserService.java)
java
@Service
public class UserService {
public void saveUser() { System.out.println("新增用户信息"); }
public void deleteUser() { System.out.println("删除用户信息"); }
public void modifyUser() { System.out.println("修改用户信息"); }
public void getUser() { System.out.println("查询用户信息"); } // 读操作,不应触发日志
}
2. 会员业务 (VipService.java)
java
@Service
public class VipService {
public void saveVip() { System.out.println("新增Vip信息"); }
public void deleteVip() { System.out.println("删除Vip信息"); }
public void modifyVip() { System.out.println("修改Vip信息"); }
public void getVip() { System.out.println("查询Vip信息"); } // 读操作,不应触发日志
}
6.3 安全日志切面实现 (SecurityLogAspect.java)
java
@Component
@Aspect
public class SecurityLogAspect {
// 【切点定义】:分别匹配以 save、delete、modify 开头的方法
@Pointcut("execution(* com.zzz.service..save*(..))")
public void savePointcut(){}
@Pointcut("execution(* com.zzz.service..delete*(..))")
public void deletePointcut(){}
@Pointcut("execution(* com.zzz.service..modify*(..))")
public void modifyPointcut(){}
/**
* 【前置通知】:使用逻辑运算符 || 组合多个切点
* 只要满足其中任何一个切点,就会触发此增强逻辑
*/
@Before("savePointcut() || deletePointcut() || modifyPointcut()")
public void beforeAdvice(JoinPoint joinPoint){
// 1. 获取系统当前时间
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String nowTime = simpleDateFormat.format(new Date());
// 2. 获取目标信息
// getDeclaringTypeName() 获取类名
// getName() 获取方法名
String className = joinPoint.getSignature().getDeclaringTypeName();
String methodName = joinPoint.getSignature().getName();
// 3. 输出审计日志
System.out.println("【审计日志】" + nowTime + " 用户 [zhangsan] 访问了: " + className + "." + methodName);
}
}
6.4 测试代码 (SecurityLogTest.java)
我们需要测试两个维度:
- 访问
save/delete/modify时,日志是否正常打印。 - 访问
get方法时,日志是否被过滤(不打印)。
java
public class SecurityLogTest {
@Test
public void testLogAOP() {
ApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring.xml");
UserService userService = applicationContext.getBean("userService", UserService.class);
VipService vipService = applicationContext.getBean("vipService", VipService.class);
System.out.println("--- 场景 A:测试 User 模块的写操作 ---");
userService.saveUser();
userService.modifyUser();
System.out.println("\n--- 场景 B:测试 Vip 模块的写操作 ---");
vipService.deleteVip();
System.out.println("\n--- 场景 C:测试查询操作(预期不打印日志) ---");
userService.getUser();
vipService.getVip();
}
}
测试结果:
