day16 面向切面编程

实战总结:AOP 面向切面编程

本文是对《13-后端Web进阶(AOP)》的总结,记录 AOP 的核心概念、通知类型、切入点表达式以及在项目中的实战应用(操作日志记录)。
项目地址:project/tlias-web-management(SpringBoot + AOP)
对应讲义:讲义/13-后端Web进阶(AOP).md


一、AOP 核心概念

1. 什么是 AOP

AOP (Aspect Oriented Programming) 即面向切面编程,是一种通过分离横切关注点(如日志、安全、事务等)来提高代码模块化程度的编程范式。

核心优势

  • 代码无侵入:在不修改原有业务代码的前提下,对功能进行增强。
  • 减少重复代码:将通用逻辑(如日志记录、权限校验)抽取出来,避免在每个业务方法中重复编写。
  • 提高开发效率与维护性:通用逻辑集中管理,修改时只需改一处。

2. 核心术语

术语 英文 描述 举例
连接点 JoinPoint 可以被 AOP 控制的方法 业务层的 list(), delete() 方法
通知 Advice 共性功能(重复逻辑) 记录方法执行耗时的代码
切入点 Pointcut 匹配连接点的条件,决定在哪些方法上应用通知 execution(* com.zhang.service.*.*(..))
切面 Aspect 通知 + 切入点的组合 日志切面 = 记录耗时逻辑 + 匹配业务方法的规则
目标对象 Target 被增强的原始对象 DeptServiceImpl 实例

关系总结:在切入点指定的连接点上,执行通知定义的逻辑,这个组合形成了切面,作用于目标对象


二、通知类型详解

1. 五种通知类型

类型 注解 执行时机 说明
前置通知 @Before 目标方法执行 常用于参数校验、权限检查
后置通知 @After 目标方法执行(无论是否异常) 常用于释放资源、清理操作
返回后通知 @AfterReturning 目标方法执行(无异常时) 常用于获取返回值、成功日志
异常通知 @AfterThrowing 目标方法执行(有异常时) 常用于异常处理、失败日志
环绕通知 @Around 目标方法执行前和后 功能最强大,可控制方法是否执行,获取返回值

2. 执行顺序表

正常执行(无异常)

1
2
3
4
5
6
1. @Around (前置部分)
2. @Before
3. 目标方法执行
4. @Around (后置部分)
5. @AfterReturning
6. @After

异常执行(有异常)

1
2
3
4
5
6
7
1. @Around (前置部分)
2. @Before
3. 目标方法执行 (抛出异常)
4. @Around (后置部分 - **不执行**)
5. @AfterReturning (**不执行**)
6. @AfterThrowing (执行)
7. @After (执行)

注意@Around 环绕通知中,若在 pjp.proceed() 前后的代码,当目标方法抛出异常时,proceed() 后的代码不会执行(除非捕获异常)。

3. 代码示例

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
@Aspect
@Component
@Slf4j
public class MyAspect {

// 1. 前置通知
@Before("execution(* com.zhang.service.*.*(..))")
public void before(JoinPoint joinPoint) {
log.info("Before: 方法执行前...");
}

// 2. 后置通知
@After("execution(* com.zhang.service.*.*(..))")
public void after(JoinPoint joinPoint) {
log.info("After: 方法执行后...");
}

// 3. 返回后通知
@AfterReturning("execution(* com.zhang.service.*.*(..))")
public void afterReturning(JoinPoint joinPoint) {
log.info("AfterReturning: 方法返回后...");
}

// 4. 异常通知
@AfterThrowing("execution(* com.zhang.service.*.*(..))")
public void afterThrowing(JoinPoint joinPoint) {
log.info("AfterThrowing: 方法异常时...");
}

// 5. 环绕通知 (最常用)
@Around("execution(* com.zhang.service.*.*(..))")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
log.info("Around Before: 进入方法...");

Object result = null;
try {
result = pjp.proceed(); // 执行目标方法
} catch (Throwable e) {
log.info("Around Exception: 方法异常...");
throw e;
}

log.info("Around After: 方法结束...");
return result;
}
}

4. 通知优先级 (@Order)

当多个切面或多个通知匹配同一个方法时,执行顺序由 @Order 注解控制:

  • 值越小,优先级越高
  • 前置通知:优先级高的先执行
  • 后置通知:优先级高的后执行(先进后出,类似栈结构)
1
2
3
4
5
6
7
8
9
@Aspect
@Component
@Order(1) // 优先级最高
public class MyAspect1 { ... }

@Aspect
@Component
@Order(2) // 优先级次之
public class MyAspect2 { ... }

三、切入点表达式

1. 两种匹配方式

方式 语法 适用场景
execution execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern) throws-pattern?) 基于方法签名(类名、方法名、参数)匹配
@annotation @annotation(annotation-type) 基于自定义注解匹配

2. execution 表达式详解

语法结构

1
execution(访问修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?)

通配符

  • *:匹配任意单个字符
  • ..:匹配任意数量的字符或包
  • ?:表示该项可选

示例

表达式 含义
execution(* com.zhang.service.*.*(..)) 匹配 com.zhang.service 包下所有类的所有方法
execution(* com.zhang.service.impl.DeptServiceImpl.*(..)) 匹配 DeptServiceImpl 类的所有方法
execution(* com.zhang.service.*.*(String, Integer)) 匹配参数为 String 和 Integer 的方法
execution(void com.zhang.service.*.delete(*)) 匹配返回值为 void,方法名为 delete,一个参数的方法

3. @annotation 注解匹配

适用场景:当需要增强的方法名无规律时,通过自定义注解来标记哪些方法需要被拦截。

实现步骤

  1. 创建自定义注解
  2. 在需要增强的方法上添加该注解
  3. 切面类中使用 @annotation 匹配

代码示例

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
// 1. 创建自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}

// 2. 在业务方法上添加注解
@RestController
public class DeptController {

@LogOperation // 标记此方法需要记录日志
@PostMapping
public Result save(@RequestBody Dept dept) {
deptService.save(dept);
return Result.success();
}
}

// 3. 切面类使用 @annotation 匹配
@Aspect
@Component
public class OperationLogAspect {

// 使用 @annotation 匹配带有 @LogOperation 注解的方法
@Around("@annotation(com.zhang.anno.LogOperation)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// ... 记录日志逻辑
}
}

四、项目实战:操作日志记录

1. 需求分析

需求:记录 Tlias 系统中所有增、删、改操作的日志,便于后期数据追踪。

日志内容

  • 操作人 ID
  • 操作时间
  • 操作类名
  • 操作方法名
  • 方法参数
  • 返回值
  • 执行耗时

技术选型

  • 通知类型@Around 环绕通知(需要获取方法执行前后的信息)
  • 切入点@annotation 注解匹配(增删改方法名无统一前缀)

2. 项目实现代码

① 自定义注解 @LogOperation

1
2
3
4
5
// 文件:com.zhang.anno.LogOperation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}

② 日志实体类 OperateLog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 文件:com.zhang.pojo.OperateLog
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
private Integer id; // 主键
private Integer operateEmpId; // 操作人ID
private LocalDateTime operateTime; // 操作时间
private String className; // 操作类名
private String methodName; // 操作方法名
private String methodParams; // 方法参数
private String returnValue; // 返回值
private Long costTime; // 执行耗时(毫秒)
}

③ Mapper 接口 OperateLogMapper

1
2
3
4
5
6
7
8
// 文件:com.zhang.mapper.OperateLogMapper
@Mapper
public interface OperateLogMapper {

@Insert("INSERT INTO operate_log(operate_emp_id, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
"VALUES(#{operateEmpId}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime})")
void insert(OperateLog log);
}

④ 切面类 OperationLogAspect(核心)

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
// 文件:com.zhang.aop.OperationLogAspect
@Aspect
@Component
@Slf4j
public class OperationLogAspect {

@Autowired
private OperateLogMapper operateLogMapper;

@Around("@annotation(com.zhang.anno.LogOperation)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long startTime = System.currentTimeMillis();

Object result = null;
try {
result = pjp.proceed(); // 执行目标方法
} catch (Throwable e) {
// 异常时也要记录日志,继续抛出异常
throw e;
} finally {
long endTime = System.currentTimeMillis();
long costTime = endTime - startTime;

// 构建日志对象
OperateLog log = new OperateLog();
log.setOperateEmpId(CurrentHolder.getCurrentId()); // 从 ThreadLocal 获取当前用户ID
log.setOperateTime(LocalDateTime.now());
log.setClassName(pjp.getTarget().getClass().getName());
log.setMethodName(pjp.getSignature().getName());
log.setMethodParams(Arrays.toString(pjp.getArgs()));
log.setReturnValue(result != null ? result.toString() : null); // ⚠ 注意空指针判断
log.setCostTime(costTime);

// 保存日志到数据库
operateLogMapper.insert(log);
}

return result;
}
}

⑤ 获取当前用户 ID(CurrentHolder

由于 AOP 切面无法直接获取 HttpServletRequest 中的登录信息,需要通过 ThreadLocal 在拦截器和切面之间传递数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 文件:com.zhang.utils.CurrentHolder
public class CurrentHolder {
private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();

public static Integer getCurrentId() {
return CURRENT_LOCAL.get();
}

public static void setCurrentId(Integer id) {
CURRENT_LOCAL.set(id);
}

public static void remove() {
CURRENT_LOCAL.remove();
}
}

在登录拦截器中设置用户 ID

1
2
3
4
5
6
7
8
// TokenInterceptor.preHandle() 方法中
Integer userId = /* 从 token 解析出的用户ID */;
CurrentHolder.setCurrentId(userId);
try {
// 继续执行...
} finally {
CurrentHolder.remove(); // 必须清理,防止内存泄漏
}

⑥ 在 Controller 中添加注解

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
// 文件:com.zhang.controller.DeptController
@RestController
@RequestMapping("/depts")
public class DeptController {

@Autowired
private DeptService deptService;

@LogOperation // 新增部门
@PostMapping
public Result save(@RequestBody Dept dept) {
deptService.save(dept);
return Result.success();
}

@LogOperation // 修改部门
@PutMapping
public Result update(@RequestBody Dept dept) {
deptService.update(dept);
return Result.success();
}

@LogOperation // 删除部门
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id) {
deptService.deleteById(id);
return Result.success();
}
}

五、项目文件清单

全限定类名 作用
anno com.zhang.anno.LogOperation 自定义注解,标记需要记录日志的方法
pojo com.zhang.pojo.OperateLog 日志实体类
mapper com.zhang.mapper.OperateLogMapper 日志持久化接口
aop com.zhang.aop.OperationLogAspect AOP 切面类,核心逻辑
utils com.zhang.utils.CurrentHolder ThreadLocal 工具类,传递用户 ID

六、易错点 & 复习提醒

1. 环绕通知的 proceed() 调用

  • 必须调用 pjp.proceed() 才能执行原始方法,否则原始方法不会执行。
  • 必须返回 proceed() 的返回值,否则原始方法的返回值会丢失。
  • 当原始方法抛出异常时,proceed() 后的代码不会执行(除非用 try-catch 包裹)。

2. 返回值空指针风险

1
2
3
4
5
// 错误写法
log.setReturnValue(result.toString()); // 当 result 为 null 时,抛出 NullPointerException

// 正确写法
log.setReturnValue(result != null ? result.toString() : null);

对于 void 返回值的方法,result 可能为 null,需要做空判断。

3. ThreadLocal 内存泄漏

CurrentHolder 使用 ThreadLocal 时,必须在使用完毕后调用 remove() 方法清理,否则会造成内存泄漏。

4. 切入点表达式书写规范

  • 优先使用接口execution(* com.zhang.service.DeptService.*(..)) 比使用实现类更好,提高可扩展性。
  • 缩小匹配范围:避免使用 .. 匹配所有包,应尽可能精确指定包路径。
  • 注释清晰:在切面类中注释该切面的功能,方便后期维护。

5. AOP 与事务的关系

Spring 的 @Transactional 注解底层也是基于 AOP 实现的。在使用自定义 AOP 时,需要注意事务注解的顺序,确保数据一致性。

6. 切面类必须是 Spring Bean

切面类必须添加 @Component 或其他注解,使其成为 Spring 管理的 Bean,否则切面不会生效。


七、一图回顾 AOP 流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌──────────────── AOP 执行流程 ────────────────┐
│ │
│ 请求到达 Controller 方法 │
│ ↓ │
│ AOP 切面拦截(匹配 @LogOperation 注解) │
│ ↓ │
│ @Around 环绕通知开始 │
│ ├── 记录开始时间 │
│ ├── 获取当前用户 ID(从 ThreadLocal) │
│ ├── 执行 pjp.proceed() → 原始业务方法 │
│ ├── 获取方法参数、返回值 │
│ ├── 计算耗时 │
│ ├── 构建 OperateLog 对象 │
│ └── 调用 OperateLogMapper 保存日志 │
│ ↓ │
│ 返回原始方法结果给前端 │
│ │
└────────────────────────────────────────────────┘