本文是阶段 3 AOP的技术复盘,涵盖 AOP 切面编程、自定义
@Log注解、环绕通知 vs 前置通知的选择、JWT userId 全链路增强、SecurityContextHolder 取身份、Filter vs AOP 对比等实战要点。该篇从【为什么 → 怎么做 → 原理是什么 → 踩了什么坑】四个维度展开,适合正在学习 Spring AOP 和操作日志的同学阅读。
一、前言:从阶段 2 到阶段 3
上一篇文章我们给系统装上了【缓存便签本】------用 Spring Cache + Redis 缓存高频查询结果。系统快了,但还缺一个东西。
来看一个真实场景:
管理员删除了张三的健康记录
家属投诉:"我的数据怎么没了?"
管理员:"我没有删过啊"
------死无对证,因为没有操作日志。
没有操作日志的系统,就像没有监控的超市------出事了只能靠嘴说。
阶段 3 的目标就是给系统装上这层【监控摄像头】:
-
自定义 @Log 注解:像贴标签一样标记需要监控的方法
-
AOP 环绕通知:自动捕获操作人、操作类型、参数、结果
-
SecurityContext 取用户身份:日志中记录【谁】做了什么
-
顺便修了一个安全漏洞:AuthController 的角色伪造漏洞
技术栈:Spring Boot 3.5 + Spring AOP + Spring Security + MyBatis Plus + Knife4j
二、整体架构:一次操作如何变成一条日志
在深入每个组件之前,先用一张图建立全局认知:
HTTP 请求(POST /api/elderly 新增老人)
│
▼
┌──────────────────────────────────────────┐
│ JwtAuthenticationFilter │
│ 解析 Token → 写入 SecurityContext │
│ username="admin", userId=1, role=ADMIN │
└──────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Controller │
│ ElderlyController.addElderly() │
└──────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Service │
│ ElderlyServiceImpl.addElderly() │
│ ⬆ 贴了 @Log(action="添加", target="老人")│
│ ⬆ 贴了 @CacheEvict │
│ │
│ ← AOP 在这里拦截! │
└──────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ LogAspect.@Around │
│ ├── ① 读 @Log 注解 → action, target │
│ ├── ② SecurityContextHolder 取用户 │
│ ├── ③ RequestContextHolder 取 IP │
│ ├── ④ joinPoint.proceed() 执行方法 │
│ │ → 成功 → result = "成功" │
│ │ → 异常 → result = "失败" │
│ └── ⑤ finally: 写 operation_log 表 │
└──────────────┬───────────────────────────┘
▼
返回给前端
核心思路 :AOP 切面像一个【监控摄像头】,贴了 @Log 注解的方法就是【监控区域】。一旦有人进入监控区域(调用方法),摄像头自动记录:谁、什么时候、干了什么、结果如何。
几个关键角色:
| 角色 | 对应代码 | 职责 |
|---|---|---|
| 🏷️ 标签 | @Log 注解 |
标记哪些方法需要记录,记录什么内容 |
| 📹 摄像头 | LogAspect 切面类 |
拦截方法调用,采集上下文信息,写日志 |
| 🎫 访客牌 | SecurityContextHolder |
提供当前用户身份信息 |
| 📋 记录本 | OperationLog + operation_log 表 |
持久化存储操作日志 |
下面逐个展开。
三、AOP 前置知识:三个核心概念一句话讲清
如果你第一次接触 AOP,先理解这三个概念:
3.1 切面(Aspect)= 监控摄像头
java
@Aspect // ← "我是一个切面(摄像头)"
@Component // ← "把我交给 Spring 管理"
public class LogAspect { ... }
切面 = 在什么地方 + 什么时候 + 做什么事。
3.2 切点(Pointcut)= 监控区域
java
@Pointcut("@annotation(com.liu.healthback.annotation.Log)")
public void logPointcut() {}
这个表达式翻译成人话:"所有贴了 @Log 注解的方法,就是我要监控的地方"。
Spring AOP 支持多种切点表达式:
| 表达式 | 含义 | 示例 |
|---|---|---|
@annotation(...) |
贴了某个注解的方法 | @annotation(com.liu.healthback.annotation.Log) |
execution(...) |
按方法签名匹配 | execution(* com.liu..service.*.*(..)) |
within(...) |
某个包/类下所有方法 | within(com.liu.healthback.services.*) |
@within(...) |
贴了某个注解的类下所有方法 | @within(org.springframework.stereotype.Service) |
项目中用 @annotation,因为最精确、最灵活------想让哪个方法被监控,贴个标签就行。
3.3 通知(Advice)= 具体拍什么
| 通知类型 | 注解 | 执行时机 | 能看到返回值 | 能捕获异常 |
|---|---|---|---|---|
| 前置通知 | @Before |
方法执行前 | ❌ | ❌ |
| 后置通知 | @After |
方法执行后(不管成败) | ❌ | ❌ |
| 返回通知 | @AfterReturning |
方法成功返回后 | ✅ | ❌ |
| 异常通知 | @AfterThrowing |
方法抛异常时 | ❌ | ✅ |
| 环绕通知 | @Around |
全程包住方法 | ✅ | ✅ |
我们为什么选 @Around?
因为操作日志需要记录执行结果 (成功 / 失败),@Before 看不到结果,@After 拿不到返回值。只有 @Around 能在方法执行后根据返回值或异常判断成功与否。
四、自定义注解:@Log
4.1 注解的本质
很多人写惯了注解但没想过注解到底是什么。一句话:
注解 = 标签(数据载体),不包含任何逻辑。
java
@Log(action = "添加老人信息", target = "老人信息")
public boolean addElderly(Elderly elderly) { ... }
这行代码的意思是:【给 addElderly 方法贴个标签,标签上写着 action="添加老人信息"、target="老人信息"】。
标签本身不会做任何事。就像快递盒上的【易碎品】标签------标签不会让盒子变结实,是快递员看到了这个标签后采取了轻拿轻放的行为。
AOP 切面就是那个【快递员】------扫描到 @Log 标签后,执行对应的日志记录逻辑。
4.2 注解定义
java
@Target(ElementType.METHOD) // 只能贴在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时可通过反射读取
public @interface Log {
String action(); // 操作类型:"新增"、"修改"、"删除"
String target(); // 操作对象:"老人信息"、"健康记录"
}
两个元注解的解释:
| 元注解 | 值 | 含义 |
|---|---|---|
@Target |
ElementType.METHOD |
这个注解只能贴在方法上,贴类上编译报错 |
@Retention |
RetentionPolicy.RUNTIME |
运行时保留,@Around 通过反射读取。如果写 SOURCE,编译后就丢了,切面读不到 |
@Retention 的三种策略:
-
SOURCE:只保留在源码中,编译时丢弃。例如@Override、@SuppressWarnings,编译器看完就扔。 -
CLASS:保留到字节码,但运行时 JVM 不加载。默认值,用得少。 -
RUNTIME:保留到运行时,可通过反射读取。Spring 的@Autowired、@Transactional、我们的@Log都是这个级别。AOP 必须用 RUNTIME。
4.3 注解的使用
java
// ElderlyServiceImpl
@Log(action = "添加老人信息", target = "老人信息")
@CacheEvict(value = "elderly:list", allEntries = true)
public boolean addElderly(Elderly elderly) { ... }
@Log(action = "更新老人信息", target = "老人信息")
@Caching(evict = {...})
public boolean updateById(Elderly entity) { ... }
@Log(action = "删除老人信息", target = "老人信息")
@Caching(evict = {...})
public boolean removeById(Serializable id) { ... }
// HealthRecordServiceImpl
@Log(action = "添加或更新健康记录", target = "健康记录")
@Caching(evict = {...})
public boolean saveOrUpdate(HealthRecord entity) { ... }
// ThresholdConfigServiceImpl
@Log(action = "更新保存健康阈值", target = "健康阈值")
@CacheEvict(value = "threshold", key = "'config'")
public boolean saveOrUpdateConfig(ThresholdConfig config) { ... }
一个方法可以贴多个注解,各自独立工作。 addElderly 同时有 @Log(记录日志)和 @CacheEvict(清除缓存),AOP 有两个切面各自拦截,互不影响。
五、LogAspect 切面:核心实现逐行解析
java
@Component // 交给 Spring 管理
@Aspect // 声明这是一个切面
public class LogAspect {
private final OperationLogMapper operationLogMapper;
public LogAspect(OperationLogMapper operationLogMapper) {
this.operationLogMapper = operationLogMapper;
}
// ① 定义切点:所有贴了 @Log 注解的方法
@Pointcut("@annotation(com.liu.healthback.annotation.Log)")
public void logPointcut() {}
// ② 环绕通知
@Around("logPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// ... 下面逐块解析
}
}
5.1 第一步:从方法上读 @Log 注解
java
// 拿到方法签名(包含方法名、返回值类型、参数类型、注解等信息)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 从签名上取出 @Log 注解实例
Log log = signature.getMethod().getAnnotation(Log.class);
// 读注解属性
String action = log.action(); // 例如:"添加老人信息"
String target = log.target(); // 例如:"老人信息"
关键理解 :getAnnotation() 是 Java 反射 API,运行时动态读取类/方法上贴的注解。@Retention(RUNTIME) 就是为了让这个方法能读到。
5.2 第二步:从 SecurityContext 拿当前用户
java
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName(); // "admin"
Long userId = (Long) auth.getDetails(); // 1
// role 取出来带 ROLE_ 前缀:"ROLE_ADMIN",写日志前要去掉
String role = auth.getAuthorities()
.iterator().next()
.getAuthority();
role = role.replace("ROLE_", ""); // "ADMIN"
为什么任何地方都能取到 SecurityContext?
底层是 ThreadLocal:
java
ThreadLocal = 当前线程的"私人储物柜"
Tomcat 每个请求分配一个独立线程
→ JwtAuthenticationFilter 在处理请求时把身份存进 ThreadLocal
→ 同一条请求链路:Filter → Controller → Service → AOP,全在同一个线程
→ 任何环节都能从 ThreadLocal 取出当前用户 ✅
生活类比 :进了公司大楼,前台发给你一个访客牌挂在胸前。之后你走到任何部门(Controller、Service、AOP),别人看到你的访客牌就知道你是谁。ThreadLocal 就是这个访客牌。
5.3 第三步:拿 IP 地址
java
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String ip = request.getRemoteAddr();
RequestContextHolder 和 SecurityContextHolder 一样,底层也是 ThreadLocal,但存的是 HTTP 请求对象而非用户信息。
5.4 第四步:组装 + 写入
java
OperationLog logEntity = new OperationLog();
logEntity.setUserId(userId);
logEntity.setUsername(username);
logEntity.setRole(role);
logEntity.setAction(action);
logEntity.setTarget(target);
logEntity.setIp(ip);
try {
Object result = joinPoint.proceed(); // ← 真正执行目标方法!
logEntity.setResult("成功");
return result; // 返回值原样返回给调用方
} catch (Throwable e) {
logEntity.setResult("失败");
logEntity.setErrorMsg(e.getMessage());
throw e; // 异常要重新抛,不能吞!
} finally {
logEntity.setCreateTime(LocalDateTime.now());
operationLogMapper.insert(logEntity); // 不管成败都写日志
}
三个关键理解:
-
joinPoint.proceed()是这行代码真正去调addElderly()/updateById()的地方。不写这一行,目标方法永远不会执行。 -
finally块保证不管成功还是抛异常,日志都会写入数据库。 -
异常不能吞 :
catch后必须重新throw e。如果吞了异常,上层 Controller 看到的是成功返回,事务也不会回滚。
5.5 完整执行流程
java
around() 被触发
│
├── ① 读 @Log 注解 → action, target
├── ② SecurityContextHolder.getAuthentication() → username, userId, role
├── ③ RequestContextHolder.getRequestAttributes() → IP
├── ④ new OperationLog() → set 各个字段
├── ⑤ joinPoint.proceed() ←────────────┐
│ │ │
│ ┌───┴───┐ │
│ ▼ ▼ │
│ 正常返回 抛异常 │
│ │ │ │
│ result= errorMsg= │
│ "成功" e.getMessage() │
│ │ │ │
│ └───┬───┘ │
│ ▼ │
└── ⑥ finally: insert(operationLog) ────┘
六、顺便修了一个安全漏洞
AuthController 原来的写法:
java
// 有漏洞的写法
String accessToken = jwtTokenProvider.generateAccessToken(
loginDto.getUsername(),
loginDto.getRole(), // ← 用户提交的表单数据!
loginDto.getUserId() // ← 用户提交的表单数据!
);
漏洞 :FAMILY 用户在登录表单里人工填 "role": "ADMIN" → Token 里存了 ADMIN → 后续请求被识别为管理员 → 获得管理员权限。
就像你去酒店前台,自己和服务员说"给我总统套房的房卡"---如果前台不查系统就照办,那就出事了。
修复后的写法:
java
// 安全的写法
Authentication authentication = authenticationManager.authenticate(unauthenticated);
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
// 用数据库查出的真实数据,不用用户提交的
String accessToken = jwtTokenProvider.generateAccessToken(
userDetails.getUsername(), // 数据库的
userDetails.getRole(), // 数据库的,不是用户填的
userDetails.getUserId() // 数据库的
);
根因分析 :JWT 签名只能防客户端篡改 Token,防不了【服务端自己签发 Token 时写入了不可信的值】。
教训:构建 Token 时,所有数据必须来自可信数据源(数据库),不能来自用户提交的表单。
七、踩坑记录
坑 1:@Aspect 注解无法解析
现象 :IDEA 提示 Cannot resolve symbol 'Aspect'。
根因 :pom.xml 缺少 spring-boot-starter-aop 依赖。AOP 不是 Spring Boot 自动引入的(不像 MVC 和 Web),需要手动添加。
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
教训:Spring Boot 的 starter 不是万能的,只带核心功能。AOP、Validation、Actuator 等都需要显式引入依赖。
坑 2:GlobalExceptionHandler 导包错误
现象:认证失败返回 500 而不是 401。
根因 :AuthenticationException 有多个同名类------javax.security.sasl、org.apache.tomcat.websocket、org.springframework.security.core。IDE 自动补全时导错了包,编译不报错,但运行时永远匹配不上。
教训 :遇到 @ExceptionHandler 不生效,第一反应检查导包!
八、Filter vs AOP:两个拦截器的根本区别
Filter 和 AOP是两个类似的家伙。我们来放在一起对比:
| Filter(过滤器) | AOP(切面) | |
|---|---|---|
| 拦截级别 | HTTP 请求级 | 方法级 |
| 所在层次 | Spring MVC 外面(Servlet 容器层) | Spring 容器里面 |
| 核心对象 | HttpServletRequest / HttpServletResponse |
ProceedingJoinPoint(被拦截的方法) |
| 典型用途 | 认证、鉴权、编解码、CORS | 日志、事务、权限校验、性能监控 |
| 能否拿到方法参数 | ❌ | ✅ |
| 能否拿到注解信息 | ❌ | ✅ |
| 执行顺序 | 先于 DispatcherServlet | 在 Spring Bean 方法调用时 |
XML
请求 → Filter → DispatcherServlet → Interceptor → Controller → AOP(Service方法) → 返回
一句话区分:Filter 管的是【你能不能进大楼】(HTTP 层面),AOP 管的是【你进了大楼后在哪个房间做了什么】(方法层面)。
九、阶段收获
理解层面
-
AOP 三大核心概念:Aspect(切面 = 在哪 + 何时 + 做什么)、Pointcut(切点 = 拦截哪些方法)、Advice(通知 = 具体做什么)
-
五种通知类型的区别 :
@Before/@After/@AfterReturning/@AfterThrowing/@Around,各自适用场景和能获取的信息不同 -
ThreadLocal 在安全体系中的应用:SecurityContextHolder 和 RequestContextHolder 都是基于 ThreadLocal,实现请求级别的数据隔离
-
Filter vs AOP 的根本区别:拦截级别不同(HTTP vs 方法)、层次不同(Servlet 容器 vs Spring 容器)、适用场景不同
工程层面
-
能从零搭建自定义注解 + AOP 切面:定义注解 → 写切面 → 贴注解 → 验证生效
-
理解了 userId 全链路贯通的实现:数据库 → JWT → SecurityContext → AOP,并掌握了依赖关系驱动的开发顺序思维
-
掌握了
@Around的 try-catch-finally 标准写法,理解为什么异常要重新抛、为什么 finally 里写日志 -
学会了排查 AOP 相关问题的思路:依赖是不是缺了 → 导包对不对 → 代理有没有生效
-
建立了安全意识:Token 中的数据必须来自可信数据源,不能直接用用户提交的内容
十、自检清单
学完这部分,你应该能回答以下问题。建议对着口述一遍------能说出来 = 真懂了:
- AOP 的 Aspect、Pointcut、Advice 分别是什么?它们三个是什么关系?
- 五种通知(Before / After / AfterReturning / AfterThrowing / Around)的区别?
- 为什么操作日志必须用 @Around 而不能用 @Before?
@Target和@Retention分别控制什么?RUNTIME和SOURCE的区别?- 自定义注解的
action()和target()在切面里怎么读到的?(反射 API) joinPoint.proceed()是干什么的?不写会怎样?SecurityContextHolder.getContext().getAuthentication()背后是什么原理?- 为什么切面里 catch 了异常要重新 throw?
- userId 全链路经过哪几个环节?列出数据流路径和开发顺序。
- Filter 和 AOP 的区别?分别在什么场景使用?
- AuthController 的角色伪造漏洞是怎么产生的?怎么修复的?
spring-boot-starter-aop依赖是必须手动添加的吗?为什么?
developing----------