15- [AOP] 面向切面编程

3. [AOP] 面向切面编程

摘要: 在我们的项目中,日志记录、事务管理、权限校验等通用逻辑散布在各个业务方法中,造成了代码冗余。本章我们将学习 Spring AOP,这是一种强大的编程范式,它能将这些横切关注点从主业务逻辑中优雅地分离出来。我们将通过实战,创建一个自定义的日志切面,并最终实现一个基于注解的声明式操作权限校验。

前置知识要求: 在深入学习本章之前,我们强烈建议您回顾或先行学习我们的 《Java微服务(二):3.0 SpringMVC - 前后端交互核心内核 章节,因为本章中的许多概念,如拦截器 (Interceptor),都与 AOP 的思想一脉相承。理解 Spring MVC 的请求处理流程将极大地帮助您 grasp(掌握)AOP 的核心精髓。

为了不影响读者的阅读和,这里提供一个整理好的仓库供读者快速Clone Spring_Mvc_Study: 教学用的SpringMVC文件 我们后续还会在这个项目的基础上加以改进

3.1. AOP 核心概念入门

3.1.1. 痛点:什么是横切关注点?(Why AOP)

让我们回顾一下之前的代码。在 UserServiceImpl 中,我们可能希望在每个公开的业务方法(如 findUserById, saveUser)执行前后都打印日志,用于追踪和调试。一个朴素的实现方式可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class UserServiceImpl implements UserService {

// 模拟一个简单的 Map 作为缓存
private final Map<Long, UserVO> userCache = new ConcurrentHashMap<>();

@Override
public UserVO findUserById(Long id) {
// 1. 先从缓存查找
if (userCache.containsKey(id)) {
log.info("命中缓存: {}", id);
return userCache.get(id);
}

// 2. 缓存未命中,查询数据库 (核心业务逻辑)
User user = userMapper.selectById(id);
if (user == null) {
return null;
}
UserVO userVO = convertToVO(user);

// 3. 将结果放入缓存
userCache.put(id, userVO);
log.info("查询数据库并写入缓存: {}", id);

return userVO;
}
}

这种写法暴露了严重的问题:

核心逻辑混杂:缓存处理的代码(从缓存读、写入缓存)与真正的业务逻辑(查询数据库、对象转换)紧紧地耦合在一起,使得代码难以阅读和维护。

代码重复:如果未来 findProductByIdfindOrderById 等方法也需要缓存,我们就必须在每个方法里都重复编写这套缓存逻辑。

像日志、事务、权限校验、性能监控这类需要“横向”地应用到多个业务模块中的功能,我们就称之为 横切关注点

AOP (Aspect-Oriented Programming, 面向切面编程) 的核心目标,就是将这些横切关注点从主业务逻辑中优雅地剥离出来,使它们模块化,从而降低代码的耦合度,提升系统的可维护性和可扩展性。


3.1.2. 核心术语:构建 AOP 的“积木”

要理解 AOP 是如何工作的,我们必须先掌握它的几个核心术语。您可以将它们想象成一套用于构建“切面”的乐高积木。

核心概念作用/比喻简明解释
连接点 (Join Point)所有可能的时机程序执行过程中可以插入切面的点,如方法调用或执行。
切点 (Pointcut)选定的具体时机一个或多个连接点的集合,它精确定义了通知将在哪里执行。
通知 (Advice)要做的具体事情在切点匹配的连接点上执行的代码,例如记录日志。
切面 (Aspect)事情和时机的组合切点通知的结合体,封装了一个完整的横切关注点功能。

关系总结

  • 切面 (Aspect) = 切点 (Pointcut) + 通知 (Advice)
  • 通知 (Advice) 被应用到由 切点 (Pointcut) 筛选出的一系列 连接点 (Join Point) 上。

1. 连接点 (Join Point)

定义: 程序执行过程中的一个明确的点,例如方法的调用、异常的抛出等。在 Spring AOP 中,连接点总是指代方法的执行

您可以把它想象成程序流程中的一个个“可以插入逻辑”的“时机点”。


2. 切点 (Pointcut)

定义: 一个谓词或表达式,它用于匹配和选中一组感兴趣的连接点。

如果说连接点是程序中所有可能插入逻辑的点,那么切点就是我们的“筛选器”,它精确地定义了我们到底要在哪些方法上应用我们的横切逻辑。例如,我们可以定义一个切点来选中 UserController 中所有以 get 开头的方法。


3. 通知 (Advice)

定义: 在切点所匹配的连接点上具体要执行的操作

通知定义了我们的横切逻辑“做什么”以及“什么时候做”。Spring AOP 提供了五种标准的通知类型:

  • @Before: 在目标方法执行之前执行。
  • @AfterReturning: 在目标方法成功返回之后执行。
  • @AfterThrowing: 在目标方法抛出异常之后执行。
  • @After: 无论目标方法是成功返回还是抛出异常,在它之后都会执行(类似于 finally 块)。
  • @Around: 环绕通知。这是最强大的通知类型,它能完全包裹目标方法的执行,我们可以在方法执行前后自定义逻辑,甚至可以决定是否执行目标方法。

4. 切面 (Aspect)

定义: 通知 (Advice)切点 (Pointcut) 的一个模块化组合。

一个切面将“在哪里做(切点)”和“做什么(通知)”这两件事有机地结合在了一起,形成了一个完整的横切关注点模块。例如,我们可以创建一个“日志切面”,它包含一个匹配所有 Service 层方法的切点,以及一个在方法执行前后打印日志的环绕通知。


3.2. [实战] 创建声明式缓存切面

现在,我们将亲手实践 AOP 的真正威力:创建一个自定义的 @SimpleCache 注解和一个配套的缓存切面。最终实现的效果是,任何方法只要加上 @SimpleCache 注解,就自动具备了专业的、带过期时间的缓存能力。

3.2.1. 引入 AOP Starter 依赖

首先,请确保 demo-framework 模块的 pom.xml 中已添加 spring-boot-starter-aop 依赖。

文件路径: demo-framework/pom.xml

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

3.2.2. 创建自定义缓存注解

我们在 demo-common 模块中创建 @SimpleCache 注解。

文件路径: demo-common/src/main/java/com/example/democommon/annotation/SimpleCache.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.democommon.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD) // 注解作用于方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,AOP才能读取到
public @interface SimpleCache {
/**
* 缓存的键名前缀
*/
String key();

/**
* 缓存过期时间, 单位秒, 默认1小时
*/
long timeoutSeconds() default 3600;
}

3.2.3. 技术选型:Hutool-Cache 简介

在实现切面之前,我们先来了解一下即将使用的强大工具——Hutool-cache。它提供了几种成熟的缓存策略,让我们可以轻松应对不同场景。

缓存策略核心思想淘汰机制容量限制
FIFOCache先进先出 (First In, First Out)缓存满时,淘汰最先存入的数据
LFUCache最少使用 (Least Frequently Used)缓存满时,淘汰使用频率最低的数据
LRUCache最近最久未使用 (Least Recently Used)缓存满时,淘汰最长时间未被访问的数据
TimedCache定时过期 (Time-based Expiration)数据达到设定的超时时间后自动过期

补充说明:

  • FIFOLFULRU 这三种策略都是容量驱动的缓存。它们的核心目标是在缓存达到容量上限时,决定应该淘汰哪些数据来为新数据腾出空间。
  • TimedCache时间驱动的缓存。它不关心容量是否已满,只关心数据是否“新鲜”,一旦数据过期就会被清理。这与我们 @SimpleCache 注解中的 timeoutSeconds 属性完美契合,因此是本次实战的最佳选择

3.2.4. 实现缓存切面

现在,我们来创建 SimpleCacheAspect,并使用 Hutool 的 TimedCache 来实现专业的缓存逻辑。

文件路径: demo-framework/src/main/java/com/example/demoframework/aspect/SimpleCacheAspect.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package com.example.demoframework.aspect;

import cn.hutool.cache.Cache;
import cn.hutool.cache.CacheUtil;
import cn.hutool.core.util.StrUtil;
import com.example.democommon.annotation.SimpleCache;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Aspect // 1.声明这个类是一个切面。
@Component
@Slf4j
public class SimpleCacheAspect {
// 使用 Hutool 创建一个定时缓存,默认过期时间为1小时
// `CacheUtil.newTimedCache(...)`: 我们创建了一个 `TimedCache` 实例来存储我们的缓存数据。
private final Cache<String, Object> cache = CacheUtil.newTimedCache(3600 * 1000);

/**
* 定义一个切点,匹配所有被 @SimpleCache 注解标记的方法
*/
@Pointcut("@annotation(com.example.democommon.annotation.SimpleCache)")
public void cachePointcut() {
}

// `@Around`: 我们使用环绕通知,因为它能完全控制方法的执行流程:
// 先查缓存,如果缓存没有,再执行原方法 `joinPoint.proceed()`,最后将结果放入缓存。
@Around("cachePointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 获取注解和方法信息 在我们的这次调试中出发是的就是
// UserVO com.example.demosystem.service.impl.UserServiceImpl.findUserById(Long)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 方法:
// public com.example.demosystem.vo.UserVO com.example.demosystem.service.impl.UserServiceImpl.findUserById(java.lang.Long)
Method method = signature.getMethod();
// 通过反射获取我们方法上的注解
SimpleCache cacheAnnotation = method.getAnnotation(SimpleCache.class);
// 2. 根据方法和参数生成动态的缓存 Key
String keyPrefix = cacheAnnotation.key();
String argsString = StrUtil.join(",", joinPoint.getArgs());
// 获取我们请求中传入的UserID的值与方法参数名进行拼接,例如 user:1
String key = keyPrefix + ":" + argsString;

// 3. 检查缓存 (Hutool 的 get 方法会自动处理过期)
// `cache.get(key)`: Hutool 缓存的核心方法。如果键存在且未过期,它会返回值;
// 否则返回 `null`。这一个方法就代替了 `containsKey` 和 `get` 两步操作。
Object cachedResult = cache.get(key);
if (cachedResult != null) {
log.info("命中缓存: {}", key);
return cachedResult;
}
// 4. 缓存未命中,执行目标方法
log.info("查询数据库并写入缓存: {}", key);
Object actualResult = joinPoint.proceed();

// 5. 将结果放入缓存,并设置注解中指定的过期时间
if (actualResult != null) {
long timeoutMillis = cacheAnnotation.timeoutSeconds() * 1000;
// `cache.put(key, actualResult, timeoutMillis)`:
// 将数据存入缓存,并动态地为其指定一个从注解中读取的、毫秒级的过期时间。
cache.put(key, actualResult, timeoutMillis);
}
return actualResult;
}
}


3.3. 应用与测试

3.3.1. 在 Service 中应用注解

现在,我们回到 UserServiceImpl,移除之前手写的缓存代码,并换上我们崭新的 @SimpleCache 注解。

文件路径: demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.demosystem.service.impl;

import com.example.democommon.annotation.SimpleCache;
import com.example.demosystem.entity.User;
import com.example.demosystem.mapper.UserMapper;
import com.example.demosystem.service.UserService;
import com.example.demosystem.vo.UserVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

@Override
// 为方法加上注解,设置 key 前缀和 10 秒的过期时间
@SimpleCache(key = "user", timeoutSeconds = 10)
public UserVO findUserById(Long id) {
}

// ... 其他方法保持不变 ...
}

3.3.2. 回归测试:验证缓存与过期效果

重启您的 demo-admin 模块。

  1. 重复调用请求: 使用 Swagger UI 调用 GET /users/1。观察控制台日志,您会看到:

image-20250819103200837

您会发现,这次没有打印“正在执行 findUserById 核心业务逻辑…”,证明原方法根本没有被执行,数据直接从缓存返回。

在十秒后重新请求,可以看到耗时又变回去了,证明缓存条目已经因过期而被自动清除,程序重新执行了数据库查询。


3.3.3. AOP 与拦截器的对比总结

现在,我们可以清晰地总结 AOP 和拦截器的区别了,这对于选择正确的技术至关重要。

对比维度拦截器 (Interceptor)Spring AOP (Aspect)
作用层面Web 层,与 HttpServletRequest 强绑定Spring Bean 的方法执行层面,与 Web 无关
粒度粗粒度,拦截所有匹配的 HTTP 请求细粒度,可精确到具体类的具体方法
能力可获取和修改 HTTP 请求和响应对象可获取和修改方法参数、返回值;可决定是否执行原方法
典型场景用户认证全局日志、CORS、解决重复提交事务管理缓存权限校验、性能监控等业务横切点

3.4. [实战] 使用 execution 监控 Service 层性能

在之前的缓存案例中,我们使用了 @annotation 来精确地“定点”增强某一个方法。现在,我们将学习如何使用 execution 来进行“范围”增强,实现一个对整个 Service 层所有公共方法进行性能监控的切面。

我们的目标:自动记录 demo-system 模块下,service 包及其子包内所有 public 方法的执行耗时,而无需修改任何 Service 代码。

3.4.1. 创建性能监控切面

文件路径: demo-framework/src/main/java/com/example/demoframework/aspect/PerformanceAspect.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.demoframework.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Slf4j
public class PerformanceAspect {

// * 这是本节的核心。我们使用 `execution` 指示符,精确地描述了一个“范围”。
// * `public *`: 匹配所有 `public` 方法,返回任意类型。
// * `com.example.demosystem.service..`: 匹配 `service` 包 及其所有子包。这里的 `..` 至关重要。
// * `*.*(..)`: 匹配所有类的所有方法,接受任意参数。
@Pointcut("execution(public * com.example.demosystem.service..*.*(..))")
public void serviceMethods() {}

@Around("serviceMethods()")
public Object profile(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
// 获取被拦截的目标对象的类名。
String className = pjp.getTarget().getClass().getSimpleName();
// 获取被拦截的方法名。
String methodName = pjp.getSignature().getName();

// 执行原始方法
Object result = pjp.proceed();
long end = System.currentTimeMillis();
log.info("PERFMON: {}.{} 执行耗时: {} ms", className, methodName, (end - start));
return result;
}
}


3.4.2 回归测试

无需做任何额外配置!因为这个切面已经被注册为 Spring Bean,并且它的切点会自动匹配所有符合条件的 Service 方法。

  1. 重启 demo-admin 应用。
  2. 使用 API 工具调用任意一个会触发 Service 层方法的接口,例如 GET /usersPOST /users
  3. 观察控制台日志,您会看到除了我们之前的 Web 日志,现在还多出了性能监控日志:

image-20250819105608908

结论execution 指示符为我们提供了一种极其强大的、基于包和方法签名进行“范围扫描”的能力。它与 @annotation 的“定点精确打击”形成了完美互补,两者结合,可以让我们随心所欲地将 AOP 的能力应用到项目的任何一个角落。

解惑:@Around 是否能替代所有其他通知?

从技术上讲,@Around 环绕通知确实是功能最强大的,它可以完全模拟 @Before, @AfterReturning, @AfterThrowing@After 的所有功能。

既然如此,为什么还需要其他通知注解呢?

答案是:为了代码的简洁性、可读性和意图的清晰性。

我们可以把这看作是“瑞士军刀”和“专用工具”的区别:

  • @Around (瑞士军刀): 功能万能,但使用起来也最复杂。您必须手动管理目标方法的执行(通过调用 pjp.proceed()),并且需要自己处理异常。如果忘记调用 pjp.proceed(),原始方法将永远不会被执行,这可能是一个难以发现的严重 bug。

  • @Before, @AfterReturning 等 (专用工具): 功能专一,但使用起来非常简单安全。它们清晰地表达了您的意图,并且您无需关心如何以及何时执行原始方法,Spring 框架会为您处理好一切。

最佳实践建议:

当您只想…最佳选择为什么?
在方法执行做些事@Before最简单,意图最明确,不会意外影响方法执行。
在方法成功返回后做些事@AfterReturning可直接获取返回值,代码简洁。
只在方法抛出异常时做些事@AfterThrowing专门的异常处理通道,逻辑清晰。
必须在方法执行前后都操作,或需要控制/改变方法执行流程时@Around只有在这种复杂场景下,才动用功能最强大的工具。