苏州市网站建设_网站建设公司_过渡效果_seo优化
2025/12/25 11:31:41 网站建设 项目流程

SpringAOP

本笔记整合了 AOP 基础理论、核心概念、进阶用法,并结合真实登录日志记录案例,采用“由外到内、逐步迭代”的开发思路,帮助理解如何从零构建一个健壮的 AOP 切面。


AOP

  • Aspect Oriented Programming(面向切面编程、面向方面编程),可简单理解为面向特定方法编程
  • 典型场景:部分业务方法运行较慢,需统计每个方法的执行耗时
  • 优势
    • 减少重复代码
    • 代码无侵入(不修改原有业务逻辑)
    • 提高开发效率
    • 维护方便

AOP 基础

AOP 快速入门
  • 需求:统计所有业务层方法的执行耗时
  • 步骤
    1. 导入依赖:在pom.xml中引入 Spring AOP 依赖(Spring Boot Web 默认包含)
    2. 编写 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 异常?)

通配符:

  • *:匹配单个任意符号(如*Servicesave*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():目标对象
  • ProceedingJoinPointJoinPoint的子类,仅用于@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
  • 错误密码 → 打印falsenull
    ⚠️ 注意:根据你项目的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);}}}

最终验证

  1. 成功登录 → 查数据库,is_success=1jwt非空
  2. 失败登录 →is_success=0jwt=null
  3. password字段为******(非明文)

补充说明(查漏补缺)

1. 关于ThreadLocal与 AOP 的关系

  • 你在LoginController中使用了CurrentHolder.setJwt(...),这是完全独立于 AOP 的逻辑

  • AOP不需要也不应该操作CurrentHolder

  • ThreadLocal的清理应由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 逻辑应尽量轻量(如异步写日志)
  • 若日志量大,可考虑使用消息队列解耦

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询