SpringAOP
本笔记整合了 AOP 基础理论、核心概念、进阶用法,并结合真实登录日志记录案例,采用“由外到内、逐步迭代”的开发思路,帮助理解如何从零构建一个健壮的 AOP 切面。
AOP
- Aspect Oriented Programming(面向切面编程、面向方面编程),可简单理解为面向特定方法编程
- 典型场景:部分业务方法运行较慢,需统计每个方法的执行耗时
- 优势:
- 减少重复代码
- 代码无侵入(不修改原有业务逻辑)
- 提高开发效率
- 维护方便
AOP 基础
AOP 快速入门
- 需求:统计所有业务层方法的执行耗时
- 步骤:
- 导入依赖:在
pom.xml中引入 Spring AOP 依赖(Spring Boot Web 默认包含) - 编写 AOP 程序:针对特定方法按需编程
- 导入依赖:在
// AOP 程序 - RecordTimeAspect@Aspect@ComponentpublicclassRecordTimeAspect{@Around("execution(* com.itheima.service.impl.*.*(..))")publicObjectrecordTime(ProceedingJoinPointpjp)throwsThrowable{// 1. 记录方法运行的开始时间longbegin=System.currentTimeMillis();// 2. 执行原始的方法Objectresult=pjp.proceed();// 3. 记录方法运行的结束时间,计算执行耗时longend=System.currentTimeMillis();log.info("方法 {} 执行耗时: {}ms",pjp.getSignature(),end-begin);returnresult;}}- 常见应用场景:
- 记录系统操作日志
- 事务管理
- 权限控制
- 性能监控
- 异常统一处理
AOP 核心概念
| 概念 | 说明 |
|---|---|
| 连接点(JoinPoint) | 可被 AOP 控制的方法(含执行时上下文信息) |
| 通知(Advice) | 重复的共性逻辑(最终体现为一个方法) |
| 切入点(Pointcut) | 匹配连接点的条件,决定哪些方法会被增强 |
| 切面(Aspect) | 描述“通知”与“切入点”的关系,用@Aspect声明 |
| 目标对象(Target) | 被通知所应用的对象(即原始业务对象) |
- AOP 执行流程:
- Spring 使用动态代理技术创建代理对象
- 代理对象在目标方法前后插入通知逻辑,实现“头+尾+原方法”结构
AOP 进阶
通知类型(按执行时机分类)
| 类型 | 注解 | 执行时机 | 特点 | 使用频率 |
|---|---|---|---|---|
| 环绕通知 | @Around | 目标方法前 + 后 | 可控制是否执行原方法,可获取返回值/异常 | ⭐⭐⭐⭐⭐(最常用) |
| 前置通知 | @Before | 目标方法执行前 | 无法阻止方法执行 | ⭐⭐ |
| 后置通知 | @After | 目标方法执行后(无论成功/异常) | 类似 finally | ⭐⭐ |
| 返回后通知 | @AfterReturning | 目标方法成功返回后 | 异常时不执行 | ⭐ |
| 异常后通知 | @AfterThrowing | 目标方法抛出异常后 | 仅异常时执行 | ⭐ |
💡重点:
@Around是唯一能控制原方法是否执行的通知类型- 对于
@Around,必须使用ProceedingJoinPoint并调用proceed(),否则原方法不会执行!
切点表达式(Pointcut Expression)
- 作用:描述哪些方法需要被增强
- 常见形式:
1.execution(...)—— 按方法签名匹配
语法:
execution(访问修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?)通配符:
*:匹配单个任意符号(如*Service、save*、String参数等)..:匹配任意层级包或任意数量/类型参数
示例:
// 匹配 service.impl 下所有类的所有方法execution(*com.itheima.service.impl.*.*(..))// 匹配所有以 update 开头的方法execution(*com.itheima..*.*update*(..))2.@annotation(...)—— 按注解匹配(推荐用于业务标记)
// 匹配所有标注了 @LoginIn 的方法@annotation(com.rudyj.anno.LoginIn)✅最佳实践:
- 优先使用自定义注解方式,解耦且灵活
- 避免过度使用
..,缩小匹配范围提升性能- 方法命名规范(如
login,saveXxx),便于表达式匹配
切点复用:@Pointcut
@Pointcut("@annotation(com.rudyj.anno.LoginIn)")publicvoidpt(){}// public 可被其他切面引用@Around("pt()")publicObjectrecordLogin(ProceedingJoinPointjoinPoint)throwsThrowable{// ...}⚠️注意:
若在@Around中使用了额外参数(如LoginIn anno),则pt()方法也必须声明对应参数,否则报错:Unbound pointcut parameter 'xxx'
通知执行顺序
- 多个切面匹配同一方法时:
- 默认按切面类名的字母顺序执行
- 可用
@Order(n)显式控制(数字越小,优先级越高)- 前置阶段:
@Order(1)先于@Order(2) - 后置阶段:
@Order(1)后于@Order(2)(类似栈)
- 前置阶段:
@Order(5)@Aspect@ComponentpublicclassRecordTimeAspect{...}连接点信息获取
JoinPoint:用于@Before/@After等通知,可获取:getArgs():方法参数getSignature():方法签名(含类名、方法名)getTarget():目标对象
ProceedingJoinPoint:JoinPoint的子类,仅用于@Around,额外提供:proceed():执行原方法proceed(Object[]):传入新参数执行原方法
AOP 实战案例:登录日志记录
需求:记录用户登录行为到数据库,包含用户名、是否成功、JWT、耗时等。
数据库表结构
-- 登录日志表CREATETABLEemp_login_log(idINTUNSIGNEDPRIMARYKEYAUTO_INCREMENTCOMMENT'ID',usernameVARCHAR(20)COMMENT'用户名',passwordVARCHAR(32)COMMENT'密码(脱敏存储)',login_timeDATETIMECOMMENT'登录时间',is_successTINYINTUNSIGNEDCOMMENT'是否成功, 1:成功, 0:失败',jwtVARCHAR(1000)COMMENT'JWT令牌',cost_timeBIGINTUNSIGNEDCOMMENT'耗时, 单位:ms')COMMENT'登录日志表';实体类
@Data@NoArgsConstructor@AllArgsConstructorpublicclassEmpLoginLog{privateIntegerid;privateStringusername;privateStringpassword;// 注意:生产环境不应存明文!privateLocalDateTimeloginTime;privateShortisSuccess;// 1:成功, 0:失败privateStringjwt;privateLongcostTime;}控制器(已存在)
@RestControllerpublicclassLoginController{@AutowiredprivateEmpServiceempService;@LoginIn@PostMapping("/login")publicResultlogin(@RequestBodyEmpemp){LoginInfologinInfo=empService.login(emp);if(loginInfo==null){CurrentHolder.setJwt("");returnResult.error("用户名或密码错误");}CurrentHolder.setJwt(loginInfo.getToken());returnResult.success(loginInfo);}}🧱 由外到内:五步构建登录日志 AOP
开发哲学:每一步只解决一个问题,验证通过后再进入下一步。
第 0 步:前提准备(已有)
- ✅ 自定义注解
@LoginIn已定义 - ✅ Controller 方法已加
@LoginIn - ✅
EmpLoginLog实体 +OperateLoginMapper.insert()已实现 - ✅ 项目能正常启动,登录接口可调用
🔹 Step 1:让 AOP “跑起来” —— 最小可行切面
目标:确认 AOP 能拦截到/login方法。
@Aspect@ComponentpublicclassOperateLoginAspect{@Pointcut("@annotation(com.rudyj.anno.LoginIn)")publicvoidpt(){}@Around("pt()")publicObjectrecordLogin(ProceedingJoinPointjoinPoint)throwsThrowable{System.out.println("✅ AOP 拦截成功!方法: "+joinPoint.getSignature().getName());returnjoinPoint.proceed();// 必须调用 proceed()!}}✅验证:调用
/login,看控制台是否打印日志。
❌ 若未打印:检查@Component是否被扫描、Spring Boot 是否启用 AOP(默认开启)。
🔹 Step 2:提取方法参数(用户名、密码)
目标:从joinPoint.getArgs()中拿到Emp对象。
@Around("pt()")publicObjectrecordLogin(ProceedingJoinPointjoinPoint)throwsThrowable{Stringusername=null,password=null;for(Objectarg:joinPoint.getArgs()){if(arginstanceofEmp){Empemp=(Emp)arg;username=emp.getUsername();password=emp.getPassword();break;}}System.out.println("👤 用户名: "+username);returnjoinPoint.proceed();}✅验证:传
{ "username": "tom", "password": "123" },看是否打印tom。
❌ 若为null:检查Emp是否有 getter 方法,或参数是否确实是Emp类型。
🔹 Step 3:计算耗时 + 捕获返回值
目标:记录执行时间,并确保异常时也能记录。
@Around("pt()")publicObjectrecordLogin(ProceedingJoinPointjoinPoint)throwsThrowable{// 提取参数(略)longstart=System.currentTimeMillis();Objectresult=null;try{result=joinPoint.proceed();}finally{longcost=System.currentTimeMillis()-start;System.out.println("⏱️ 耗时: "+cost+"ms");}returnresult;}✅验证:看控制台是否打印耗时(如
50ms)。
💡finally确保即使登录失败(抛异常),耗时也能记录。
🔹 Step 4:判断登录是否成功 + 提取 JWT
目标:从Result<LoginInfo>中提取 token。
@Around("pt()")publicObjectrecordLogin(ProceedingJoinPointjoinPoint)throwsThrowable{// 提取参数(略)longstart=System.currentTimeMillis();Objectresult=null;ShortisSuccess=0;Stringjwt=null;try{result=joinPoint.proceed();if(resultinstanceofResult){Result<?>res=(Result<?>)result;// 根据实际 Result 结构调整判断逻辑if(res.getCode()!=null&&res.getCode()==200){isSuccess=1;if(res.getData()instanceofLoginInfo){jwt=((LoginInfo)res.getData()).getToken();}}}returnresult;}finally{longcost=System.currentTimeMillis()-start;System.out.println("✅ 成功: "+(isSuccess==1)+", JWT: "+jwt);}}✅验证:
- 成功登录 → 打印
true和 token- 错误密码 → 打印
false和null
⚠️ 注意:根据你项目的Result实际结构(code/msg)调整成功判断逻辑。
🔹 Step 5:保存日志到数据库 + 安全加固
目标:构建EmpLoginLog并插入,同时避免记录明文密码。
@AutowiredprivateOperateLoginMapperoperateLoginMapper;@Around("pt()")publicObjectrecordLogin(ProceedingJoinPointjoinPoint)throwsThrowable{// 1. 提取参数Stringusername=null;for(Objectarg:joinPoint.getArgs()){if(arginstanceofEmp){Empemp=(Emp)arg;username=emp.getUsername();break;}}// 2. 初始化日志字段LocalDateTimeloginTime=LocalDateTime.now();longstart=System.currentTimeMillis();Objectresult=null;ShortisSuccess=0;Stringjwt=null;// 3. 执行原方法并解析结果try{result=joinPoint.proceed();if(resultinstanceofResult){Result<?>res=(Result<?>)result;if(res.getCode()!=null&&res.getCode()==200){isSuccess=1;if(res.getData()instanceofLoginInfo){jwt=((LoginInfo)res.getData()).getToken();}}}returnresult;}catch(Exceptione){throwe;// 重新抛出异常,保证原逻辑不变}finally{// 4. 构建日志实体(关键:密码脱敏!)EmpLoginLoglogEntry=newEmpLoginLog();logEntry.setUsername(username);logEntry.setPassword("******");// ⚠️ 绝不记录明文密码!logEntry.setLoginTime(loginTime);logEntry.setIsSuccess(isSuccess);logEntry.setJwt(jwt);logEntry.setCostTime(System.currentTimeMillis()-start);// 5. 保存日志(失败不影响主流程)try{operateLoginMapper.insert(logEntry);}catch(Exceptionex){log.error("❌ 保存登录日志失败",ex);}}}✅最终验证:
- 成功登录 → 查数据库,
is_success=1,jwt非空- 失败登录 →
is_success=0,jwt=nullpassword字段为******(非明文)
补充说明(查漏补缺)
1. 关于ThreadLocal与 AOP 的关系
你在
LoginController中使用了CurrentHolder.setJwt(...),这是完全独立于 AOP 的逻辑AOP不需要也不应该操作
CurrentHolderThreadLocal的清理应由Filter 或 Interceptor在请求结束后完成:// TokenFilter 示例try{chain.doFilter(request,response);}finally{CurrentHolder.remove();// 防止内存泄漏!}
2. 安全警告:永远不要记录明文密码
- 即使是开发/测试环境,也应养成习惯
- 如果业务确实需要记录“密码特征”(如哈希值),应在 Service 层处理,而非 AOP
3. 切点参数绑定错误(回顾)
若使用:
@Pointcut("@annotation(loginIn)")publicvoidpt(LoginInloginIn){}@Around("pt(loginIn)")publicObjectaround(ProceedingJoinPointpjp,LoginInloginIn){...}→ 必须保证pt()方法参数名与@Around中一致,否则编译报错:Unbound pointcut parameter
4. 异常处理原则
- AOP 中捕获异常后,必须 re-throw,否则会吞掉异常,导致前端收不到错误信息
- 日志保存等辅助操作应单独 try-catch,避免影响主流程
5. 性能建议
- AOP 逻辑应尽量轻量(如异步写日志)
- 若日志量大,可考虑使用消息队列解耦