【黑马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 逻辑应尽量轻量(如异步写日志)
  • 若日志量大,可考虑使用消息队列解耦
相关推荐
BingoGo2 小时前
PHP True Async 最近进展以及背后的争议
后端·php
程序员码歌2 小时前
短思考第264天,每天复盘5分钟,胜过你盲目努力1整年(2)
前端·后端·ai编程
Victor3562 小时前
Hibernate(3)Hibernate的优点是什么?
后端
Victor3562 小时前
Hibernate(4)什么是Hibernate的持久化类?
后端
JaguarJack2 小时前
PHP True Async 最近进展以及背后的争议
后端·php
想不明白的过度思考者4 小时前
Spring Boot 配置文件深度解析
java·spring boot·后端
WanderInk9 小时前
刷新后点赞全变 0?别急着怪 Redis,这八成是 Long 被 JavaScript 偷偷“改号”了(一次线上复盘)
后端
吴佳浩11 小时前
Python入门指南(七) - YOLO检测API进阶实战
人工智能·后端·python
廋到被风吹走11 小时前
【Spring】常用注解分类整理
java·后端·spring