实战总结: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 {
@Before("execution(* com.zhang.service.*.*(..))") public void before(JoinPoint joinPoint) { log.info("Before: 方法执行前..."); }
@After("execution(* com.zhang.service.*.*(..))") public void after(JoinPoint joinPoint) { log.info("After: 方法执行后..."); }
@AfterReturning("execution(* com.zhang.service.*.*(..))") public void afterReturning(JoinPoint joinPoint) { log.info("AfterReturning: 方法返回后..."); }
@AfterThrowing("execution(* com.zhang.service.*.*(..))") public void afterThrowing(JoinPoint joinPoint) { log.info("AfterThrowing: 方法异常时..."); }
@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 注解匹配
适用场景:当需要增强的方法名无规律时,通过自定义注解来标记哪些方法需要被拦截。
实现步骤:
- 创建自定义注解
- 在需要增强的方法上添加该注解
- 切面类中使用
@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
| @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogOperation { }
@RestController public class DeptController { @LogOperation @PostMapping public Result save(@RequestBody Dept dept) { deptService.save(dept); return Result.success(); } }
@Aspect @Component public class OperationLogAspect { @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
| @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogOperation { }
|
② 日志实体类 OperateLog
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Data @NoArgsConstructor @AllArgsConstructor public class OperateLog { private Integer id; private Integer operateEmpId; 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
| @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
| @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()); 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
| 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
| Integer userId = ; 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
| @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());
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 保存日志 │ │ ↓ │ │ 返回原始方法结果给前端 │ │ │ └────────────────────────────────────────────────┘
|