【黑马JavaWeb+AI知识梳理】Web后端开发05-SpringAOP

SpringAOP

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


AOP

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

AOP 基础

AOP 快速入门
  • 需求:统计所有业务层方法的执行耗时
  • 步骤
    1. 导入依赖 :在 pom.xml 中引入 Spring AOP 依赖(Spring Boot Web 默认包含)
    2. 编写 AOP 程序:针对特定方法按需编程
java 复制代码
// AOP 程序 - RecordTimeAspect
@Aspect
@Component
public class RecordTimeAspect {

    @Around("execution(* com.itheima.service.impl.*.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        // 1. 记录方法运行的开始时间
        long begin = System.currentTimeMillis();

        // 2. 执行原始的方法
        Object result = pjp.proceed();

        // 3. 记录方法运行的结束时间,计算执行耗时
        long end = System.currentTimeMillis();
        log.info("方法 {} 执行耗时: {}ms", pjp.getSignature(), end - begin);
        return result;
    }
}
  • 常见应用场景
    • 记录系统操作日志
    • 事务管理
    • 权限控制
    • 性能监控
    • 异常统一处理

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(...) ------ 按方法签名匹配

语法:

text 复制代码
execution(访问修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?)

通配符:

  • *:匹配单个任意符号(如 *Servicesave*String 参数等)
  • ..:匹配任意层级包或任意数量/类型参数

示例:

java 复制代码
// 匹配 service.impl 下所有类的所有方法
execution(* com.itheima.service.impl.*.*(..))

// 匹配所有以 update 开头的方法
execution(* com.itheima..*.*update*(..))
2. @annotation(...) ------ 按注解匹配(推荐用于业务标记)
java 复制代码
// 匹配所有标注了 @LoginIn 的方法
@annotation(com.rudyj.anno.LoginIn)

最佳实践

  • 优先使用自定义注解方式,解耦且灵活
  • 避免过度使用 ..,缩小匹配范围提升性能
  • 方法命名规范(如 login, saveXxx),便于表达式匹配

切点复用:@Pointcut
java 复制代码
@Pointcut("@annotation(com.rudyj.anno.LoginIn)")
public void pt() {} // public 可被其他切面引用

@Around("pt()")
public Object recordLogin(ProceedingJoinPoint joinPoint) throws Throwable {
    // ...
}

⚠️ 注意

若在 @Around 中使用了额外参数(如 LoginIn anno),则 pt() 方法也必须声明对应参数,否则报错:
Unbound pointcut parameter 'xxx'


通知执行顺序
  • 多个切面匹配同一方法时
    • 默认按切面类名的字母顺序执行
    • 可用 @Order(n) 显式控制(数字越小,优先级越高)
      • 前置阶段@Order(1) 先于 @Order(2)
      • 后置阶段@Order(1) 后于 @Order(2)(类似栈)
java 复制代码
@Order(5)
@Aspect
@Component
public class RecordTimeAspect { ... }

连接点信息获取
  • JoinPoint :用于 @Before / @After 等通知,可获取:
    • getArgs():方法参数
    • getSignature():方法签名(含类名、方法名)
    • getTarget():目标对象
  • ProceedingJoinPointJoinPoint 的子类,仅用于 @Around ,额外提供:
    • proceed():执行原方法
    • proceed(Object[]):传入新参数执行原方法

AOP 实战案例:登录日志记录

需求:记录用户登录行为到数据库,包含用户名、是否成功、JWT、耗时等。

数据库表结构
sql 复制代码
-- 登录日志表
CREATE TABLE emp_login_log (
    id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT 'ID',
    username VARCHAR(20) COMMENT '用户名',
    password VARCHAR(32) COMMENT '密码(脱敏存储)',
    login_time DATETIME COMMENT '登录时间',
    is_success TINYINT UNSIGNED COMMENT '是否成功, 1:成功, 0:失败',
    jwt VARCHAR(1000) COMMENT 'JWT令牌',
    cost_time BIGINT UNSIGNED COMMENT '耗时, 单位:ms'
) COMMENT '登录日志表';
实体类
java 复制代码
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmpLoginLog {
    private Integer id;
    private String username;
    private String password;       // 注意:生产环境不应存明文!
    private LocalDateTime loginTime;
    private Short isSuccess;       // 1:成功, 0:失败
    private String jwt;
    private Long costTime;
}
控制器(已存在)
java 复制代码
@RestController
public class LoginController {
    @Autowired
    private EmpService empService;

    @LoginIn
    @PostMapping("/login")
    public Result login(@RequestBody Emp emp) {
        LoginInfo loginInfo = empService.login(emp);
        if (loginInfo == null) {
            CurrentHolder.setJwt("");
            return Result.error("用户名或密码错误");
        }
        CurrentHolder.setJwt(loginInfo.getToken());
        return Result.success(loginInfo);
    }
}

🧱 由外到内:五步构建登录日志 AOP

开发哲学:每一步只解决一个问题,验证通过后再进入下一步。

第 0 步:前提准备(已有)
  • ✅ 自定义注解 @LoginIn 已定义
  • ✅ Controller 方法已加 @LoginIn
  • EmpLoginLog 实体 + OperateLoginMapper.insert() 已实现
  • ✅ 项目能正常启动,登录接口可调用

🔹 Step 1:让 AOP "跑起来" ------ 最小可行切面

目标 :确认 AOP 能拦截到 /login 方法。

java 复制代码
@Aspect
@Component
public class OperateLoginAspect {

    @Pointcut("@annotation(com.rudyj.anno.LoginIn)")
    public void pt() {}

    @Around("pt()")
    public Object recordLogin(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("✅ AOP 拦截成功!方法: " + joinPoint.getSignature().getName());
        return joinPoint.proceed(); // 必须调用 proceed()!
    }
}

验证 :调用 /login,看控制台是否打印日志。

❌ 若未打印:检查 @Component 是否被扫描、Spring Boot 是否启用 AOP(默认开启)。


🔹 Step 2:提取方法参数(用户名、密码)

目标 :从 joinPoint.getArgs() 中拿到 Emp 对象。

java 复制代码
@Around("pt()")
public Object recordLogin(ProceedingJoinPoint joinPoint) throws Throwable {
    String username = null, password = null;
    for (Object arg : joinPoint.getArgs()) {
        if (arg instanceof Emp) {
            Emp emp = (Emp) arg;
            username = emp.getUsername();
            password = emp.getPassword();
            break;
        }
    }
    System.out.println("👤 用户名: " + username);
    return joinPoint.proceed();
}

验证 :传 { "username": "tom", "password": "123" },看是否打印 tom

❌ 若为 null:检查 Emp 是否有 getter 方法,或参数是否确实是 Emp 类型。


🔹 Step 3:计算耗时 + 捕获返回值

目标:记录执行时间,并确保异常时也能记录。

java 复制代码
@Around("pt()")
public Object recordLogin(ProceedingJoinPoint joinPoint) throws Throwable {
    // 提取参数(略)

    long start = System.currentTimeMillis();
    Object result = null;
    try {
        result = joinPoint.proceed();
    } finally {
        long cost = System.currentTimeMillis() - start;
        System.out.println("⏱️ 耗时: " + cost + "ms");
    }
    return result;
}

验证 :看控制台是否打印耗时(如 50ms)。

💡 finally 确保即使登录失败(抛异常),耗时也能记录。


🔹 Step 4:判断登录是否成功 + 提取 JWT

目标 :从 Result<LoginInfo> 中提取 token。

java 复制代码
@Around("pt()")
public Object recordLogin(ProceedingJoinPoint joinPoint) throws Throwable {
    // 提取参数(略)

    long start = System.currentTimeMillis();
    Object result = null;
    Short isSuccess = 0;
    String jwt = null;

    try {
        result = joinPoint.proceed();

        if (result instanceof Result) {
            Result<?> res = (Result<?>) result;
            // 根据实际 Result 结构调整判断逻辑
            if (res.getCode() != null && res.getCode() == 200) {
                isSuccess = 1;
                if (res.getData() instanceof LoginInfo) {
                    jwt = ((LoginInfo) res.getData()).getToken();
                }
            }
        }
        return result;
    } finally {
        long cost = System.currentTimeMillis() - start;
        System.out.println("✅ 成功: " + (isSuccess == 1) + ", JWT: " + jwt);
    }
}

验证

  • 成功登录 → 打印 true 和 token
  • 错误密码 → 打印 falsenull
    ⚠️ 注意:根据你项目的 Result 实际结构(code/msg)调整成功判断逻辑。

🔹 Step 5:保存日志到数据库 + 安全加固

目标 :构建 EmpLoginLog 并插入,同时避免记录明文密码。

java 复制代码
@Autowired
private OperateLoginMapper operateLoginMapper;

@Around("pt()")
public Object recordLogin(ProceedingJoinPoint joinPoint) throws Throwable {
    // 1. 提取参数
    String username = null;
    for (Object arg : joinPoint.getArgs()) {
        if (arg instanceof Emp) {
            Emp emp = (Emp) arg;
            username = emp.getUsername();
            break;
        }
    }

    // 2. 初始化日志字段
    LocalDateTime loginTime = LocalDateTime.now();
    long start = System.currentTimeMillis();
    Object result = null;
    Short isSuccess = 0;
    String jwt = null;

    // 3. 执行原方法并解析结果
    try {
        result = joinPoint.proceed();

        if (result instanceof Result) {
            Result<?> res = (Result<?>) result;
            if (res.getCode() != null && res.getCode() == 200) {
                isSuccess = 1;
                if (res.getData() instanceof LoginInfo) {
                    jwt = ((LoginInfo) res.getData()).getToken();
                }
            }
        }
        return result;
    } catch (Exception e) {
        throw e; // 重新抛出异常,保证原逻辑不变
    } finally {
        // 4. 构建日志实体(关键:密码脱敏!)
        EmpLoginLog logEntry = new EmpLoginLog();
        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 (Exception ex) {
            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 在请求结束后完成:

    java 复制代码
    // TokenFilter 示例
    try {
        chain.doFilter(request, response);
    } finally {
        CurrentHolder.remove(); // 防止内存泄漏!
    }

2. 安全警告:永远不要记录明文密码

  • 即使是开发/测试环境,也应养成习惯
  • 如果业务确实需要记录"密码特征"(如哈希值),应在 Service 层处理,而非 AOP

3. 切点参数绑定错误(回顾)

若使用:

java 复制代码
@Pointcut("@annotation(loginIn)")
public void pt(LoginIn loginIn) {}

@Around("pt(loginIn)")
public Object around(ProceedingJoinPoint pjp, LoginIn loginIn) { ... }

→ 必须保证 pt() 方法参数名与 @Around 中一致,否则编译报错:Unbound pointcut parameter

4. 异常处理原则

  • AOP 中捕获异常后,必须 re-throw,否则会吞掉异常,导致前端收不到错误信息
  • 日志保存等辅助操作应单独 try-catch,避免影响主流程

5. 性能建议

  • AOP 逻辑应尽量轻量(如异步写日志)
  • 若日志量大,可考虑使用消息队列解耦
相关推荐
FAFU_kyp13 分钟前
Rust 结构体(struct)
开发语言·后端·rust
枫叶梨花16 分钟前
SpringBoot+Vue实现SM4加密传输
spring boot·后端
悟空码字19 分钟前
SpringBoot整合MyBatis-Flex保姆级教程,看完就能上手!
java·spring boot·后端
qq_25005686825 分钟前
SpringBoot 引入 smart-doc 接口文档插件
java·spring boot·后端
w***765529 分钟前
SpringBoot Test详解
spring boot·后端·log4j
掉头发的王富贵33 分钟前
【2025年终总结】对象有了,工作没了
java·后端·年终总结
lpfasd1231 小时前
Spring Boot + WebFlux 全面使用指南
java·spring boot·后端
Cosolar1 小时前
Java 后端访问 https接口报 SSLHandshakeException 你遇到过吗
java·后端·面试
m0_748252381 小时前
Foundation 表格的基本用法
开发语言·后端·rust
Mr.朱鹏1 小时前
Spring Boot 配置文件加载顺序与优先级详解
java·spring boot·后端·spring·maven·配置文件·yml