AOP 实战四件套:日志、权限、限流与缓存切面
前两篇讲的是「切点怎么写」「代理怎么造」,这一篇只干一件事:把原理落成三个你明天就能抄进项目的真实切面——操作日志、耗时统计、权限校验。它们的共同套路只有一句:用一个自定义注解声明「哪个方法要这个能力」,再用一个切面实现「这个能力怎么做」。业务代码只多一行注解,规则集中在一个类里,改一次全项目生效。
这一篇你会反复遇到那五个词,这里一次性把它们钉在生活场景上。连接点(join point)= 一次真实发生的方法调用,像快递架上那个正在被扫描的包裹;切点(pointcut)= 决定哪些包裹进哪条滑道的分拣规则;通知(advice)= 滑道旁工人多做的那道工序,像给手机套壳时按下去的那层按键保护;切面(aspect)= 「规则 + 工序」打包成的那台机器;织入(weaving)= 把这台机器接到传送带上的动作,好比过安检——旅客(业务代码)本身没变,但每个人出门前都必须先经过同一个闸机。
洋葱讲的是「多个切面叠在一起时的顺序」。一次调用像一颗洋葱:最外层的皮最先被刀碰到,却最后一个被剥下来;最里层的皮最后进场,却第一个被剥掉,而洋葱芯就是目标方法。所以 @Order(1) 的日志切面先进后出,@Order(2) 的耗时切面后进先出,日志那条「总耗时」永远比耗时切面自己算出来的大一点点——差的就是外层壳的开销。谁离芯越近,谁越早结束,这句话能推出全部顺序题。
机场过安检讲的是「权限切面为什么要拦在门外」。@Before 型权限校验就是闸机:证件不对(缺权限码)当场拒绝,人根本进不了候机楼,也就不会产生后续成本。位置必须摆对——太靠外(排在日志切面之前)会被拒绝的旅客也记了一条「已放行」;太靠里(排在事务之后)就变成「已经办了登机牌、开了座位、启动了引擎」才说证件不行,白烧一次连接。这就是第六节那张 @Order 表的由来。

学完这一节,你要能回答三个问题:
- 我要记录返回值和耗时,为什么只能用
@Around?只想「不合格就挡掉」,为什么反而不该用它? - 三个切面同时命中一个方法,
@Order该怎么排?排错会引发哪一种「功能正常、语义错了」的事故? - 环绕通知里
try/catch到底该怎么写,才不会顺手把事务的回滚能力吃掉?
前面几节把原理讲透了,这一节直接上菜。四个切面在结构上共享同一套套路:用一个自定义注解声明「哪些方法需要这个能力」,再用一个切面实现「这个能力到底怎么做」。
为什么这是最优雅的组合?因为它把「意图」和「实现」彻底分开了:
- 业务代码只多一个注解,语义自解释:
@OpLog("删除用户")一眼就知道这是在记操作日志 - 切面代码只写一次,贴在注解上,所有需要它的方法自动获得能力
- 两者通过注解耦合,谁都不认识谁——换一套实现,业务代码一个字都不用改
先看注解长什么样。自定义注解的核心是「作用目标」和「生命周期」两个元注解:
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD) // 贴在方法上@Retention(RetentionPolicy.RUNTIME) // 运行期可读,切面才拿得到@Documentedpublic @interface OpLog { String value() default ""; // 操作描述,如「删除用户」 boolean recordArgs() default true; // 是否记录入参}@Target(METHOD)限定只能贴方法,防止误用到类或字段上@Retention(RUNTIME)是硬性要求:切面在运行期通过反射读注解,写成CLASS就读不到了value()给了默认值,于是@OpLog和@OpLog("删除用户")都是合法写法
提示:四个切面的注解可以放进同一个 anno 包里统一管理。命名上用动词短语(OpLog / RequirePerm),一眼能读出「这个注解要求什么」,比 LogAnnotation 这类名字更清楚。
需求:记录「谁在什么时候、调用了什么方法、参数是什么、返回了什么、耗时多少」。这正好是 @Around 的主场——只有它能同时拿到入参和返回值。

package com.example.aspect;import com.example.anno.OpLog;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.*;import org.aspectj.lang.reflect.MethodSignature;import org.slf4j.*;import org.springframework.stereotype.Component;import org.springframework.web.context.request.*;import jakarta.servlet.http.HttpServletRequest;import java.util.Arrays;import java.util.UUID;@Aspect@Componentpublic class OpLogAspect { private static final Logger log = LoggerFactory.getLogger(OpLogAspect.class); @Around("@annotation(opLog)") public Object around(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable { // 生成 traceId,串联这一次请求的所有日志 String traceId = UUID.randomUUID().toString().substring(0, 8); MDC.put("traceId", traceId); long start = System.nanoTime(); String op = opLog.value().isEmpty() ? ((MethodSignature) pjp.getSignature()).getMethod().getName() : opLog.value(); try { Object result = pjp.proceed(); if (opLog.recordArgs()) { log.info("[{}] 操作={} 参数={} 返回={} 耗时={}ms 操作人={}", traceId, op, Arrays.toString(pjp.getArgs()), result, (System.nanoTime() - start) / 1_000_000, currentUser()); } return result; } finally { MDC.clear(); // 线程复用场景下必须清理,否则 traceId 串味 } } private String currentUser() { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) { return "system"; // 异步 / 定时任务里没有请求上下文 } HttpServletRequest req = attrs.getRequest(); Object user = req.getSession().getAttribute("LOGIN_USER"); return user == null ? "anonymous" : user.toString(); }}@annotation(opLog)把方法上的@OpLog实例绑定到OpLog opLog形参——切点直接限定「只拦带这个注解的方法」pjp.getArgs()拿到入参数组,proceed()的返回值就是出参,前后时间戳差就是耗时,一次全拿到- 用
MDC打上traceId,同一次调用的所有日志都会带上它,排查时一条grep就能串起全链路 finally里MDC.clear()不能省:Web 容器复用线程,不清就会把上一个请求的 traceId 带到下一个
要点:日志里不要把密码、身份证这类敏感字段原样打出来。Arrays.toString(pjp.getArgs()) 会无差别输出所有参数,生产上建议按字段白名单或脱敏后再记录。
需求:调用方法前先看当前用户有没有权限,没有就抛业务异常。这类「不满足条件就拦住」的场景,@Before 就够用——它拿不到返回值,但也不需要。
package com.example.anno;import java.lang.annotation.*;@Target({ElementType.METHOD, ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface RequirePerm { String value(); // 需要的权限码,如 "user:delete"}package com.example.aspect;import com.example.anno.RequirePerm;import com.example.exception.ForbiddenException;import org.aspectj.lang.JoinPoint;import org.aspectj.lang.annotation.*;import org.springframework.stereotype.Component;import java.util.Set;@Aspect@Componentpublic class PermAspect { // 假设权限从登录态里取,这里用静态方法示意 @Before("@annotation(requirePerm)") public void check(JoinPoint jp, RequirePerm requirePerm) { Set<String> owned = CurrentUser.permissions(); if (!owned.contains(requirePerm.value())) { throw new ForbiddenException("缺少权限: " + requirePerm.value()); } }}package com.example.exception;public class ForbiddenException extends RuntimeException { public ForbiddenException(String message) { super(message); }}@Before在任何一步业务逻辑之前执行,抛出异常即「拦在门外」,目标方法根本不会被执行@Target同时允许方法和类:贴在类上是「这个类所有方法都要这个权限」,切点用@annotation只识别方法级,类级要用@within- 用自定义的
ForbiddenException而不是随便抛RuntimeException,才能让全局异常处理器把「403」和「500」区分开
说明:权限校验切面应该排在日志切面之内、事务切面之外。如果它排在最外层,被拒绝的请求也会被记一条「成功」日志;如果排在事务之内,就会在已经开启事务后才失败,白白浪费一次连接。
需求:限制某个接口的调用频率。下面用 ConcurrentHashMap + 时间窗口实现一个简易计数器版本,便于理解原理:
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface RateLimit { int limit() default 10; // 窗口内允许的次数 int seconds() default 1; // 窗口长度(秒)}package com.example.aspect;import com.example.anno.RateLimit;import com.example.exception.TooManyRequestsException;import org.aspectj.lang.annotation.*;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;@Aspect@Componentpublic class RateLimitAspect { // key = 方法标识 + 调用方,value = 当前窗口的计数与起点 private final Map<String, Window> windows = new ConcurrentHashMap<>(); @Around("@annotation(rateLimit)") public Object limit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String key = pjp.getSignature().toShortString() + "|" + CurrentUser.id(); long now = System.currentTimeMillis(); long span = rateLimit.seconds() * 1000L; Window w = windows.compute(key, (k, old) -> { if (old == null || now - old.start >= span) { return new Window(now, 1); // 新窗口,计数从 1 开始 } old.count++; return old; }); if (w.count > rateLimit.limit()) { throw new TooManyRequestsException("请求过于频繁,请稍后再试"); } return pjp.proceed(); } private static final class Window { final long start; int count; Window(long start, int count) { this.start = start; this.count = count; } }}compute是原子的,把「判断是否新窗口 + 计数」合成一步,避免并发下的竞态- 每个方法 + 每个用户的组合占一个
Window,窗口过期后自动重置计数 - 超限直接抛异常,交给全局异常处理器返回 429
计数这件事讲三遍不如点一遍。六帧动画走完「一次被拒的调用」,注意第 ② 帧——判新旧和加一是同一次原子操作,写成两步就是在给并发留口子:

limit 是这个切面唯一的数值旋钮,而它必须和 seconds 一起读才有意义。把它从 0 拖到 200,看每一档实际会发生什么:
- 典型对象:下单、发验证码、提现这类有副作用的接口
- key 里带用户 ID,所以限的是「一个人」而不是「一台机器」
- 超出即抛 TooManyRequestsException,由全局异常处理器转成 429
- 这一档要配告警:被拒次数突增通常说明有人在做号池
这个内存版限流只在单机内有效,而且 windows 会无限增长——键是按「方法 + 用户」来的,用户一多内存就涨。生产环境请换成 Redis + Lua:用 INCR 配 EXPIRE 做固定窗口,或用 Lua 脚本实现令牌桶 / 滑动窗口,这样多实例共享同一份计数、也能自然过期。切面的写法可以照搬,只把存储换成 Redis 即可。
需求:给查询方法加缓存,理解 Spring Cache 到底在做什么。手写一个 @MyCache 切面,key 生成、命中 / 未命中的流程立刻就清楚了:
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface MyCache { String key(); // 支持 SpEL,如 "#id" int ttl() default 60; // 过期秒数}package com.example.aspect;import com.example.anno.MyCache;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.*;import org.aspectj.lang.reflect.MethodSignature;import org.springframework.core.DefaultParameterNameDiscoverer;import org.springframework.expression.*;import org.springframework.expression.spel.standard.SpelExpressionParser;import org.springframework.expression.spel.support.StandardEvaluationContext;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;@Aspect@Componentpublic class MyCacheAspect { private final ExpressionParser parser = new SpelExpressionParser(); private final DefaultParameterNameDiscoverer names = new DefaultParameterNameDiscoverer(); private final Map<String, Entry> store = new ConcurrentHashMap<>(); @Around("@annotation(myCache)") public Object cache(ProceedingJoinPoint pjp, MyCache myCache) throws Throwable { MethodSignature sig = (MethodSignature) pjp.getSignature(); String key = sig.toShortString() + "#" + resolveKey(myCache.key(), sig, pjp.getArgs()); Entry hit = store.get(key); long now = System.currentTimeMillis(); if (hit != null && now - hit.time < myCache.ttl() * 1000L) { return hit.value; // 命中:直接返回,目标方法不执行 } Object value = pjp.proceed(); // 未命中:执行目标方法 store.put(key, new Entry(value, now)); return value; } private String resolveKey(String spEL, MethodSignature sig, Object[] args) { StandardEvaluationContext ctx = new StandardEvaluationContext(); String[] params = names.getParameterNames(sig.getMethod()); for (int i = 0; params != null && i < params.length; i++) { ctx.setVariable(params[i], args[i]); } return String.valueOf(parser.parseExpression(spEL).getValue(ctx)); } private record Entry(Object value, long time) {}}- key 的生成是缓存的灵魂:这里用「方法签名 + SpEL 求值结果」拼出唯一键,
@MyCache(key = "#id")就把id参数算进 key - 命中直接返回,目标方法完全不执行;未命中才
proceed(),并把结果写回缓存 - 这与 Spring Cache 的思路一致:
@Cacheable的key也支持 SpEL,底层同样是「查缓存 → 未命中调方法 → 写缓存」
提示:真实项目直接使用 Spring 的 @Cacheable + RedisCacheManager 即可,不必自己造。手写这个切面的价值在于理解「为什么缓存的 key 设计错了会导致脏数据」——key 少了参数,不同入参就会互相串数据。
resolveKey 那七行就是 Spring Cache 里 key = "#root.methodName + '#' + #id" 这类表达式的真实运行方式:把形参名逐个 setVariable 进上下文,然后交给 SpEL 求值。切到 collection 能看到 #users.与 #users.?[status=='VIP'](筛选)怎么算,切到 fail 能看到最经典的三种写错分别抛什么——这三条也正是 @Cacheable 里最常见的报错:
四个切面可能同时命中一个方法。谁先进谁先出,由 @Order 决定:数字越小越外层。下面是一套推荐的顺序:
| 切面 | @Order | 位置 | 理由 |
|---|---|---|---|
| 操作日志 | 1 | 最外层 | 无论后面成功失败都要记录 |
| 限流 | 2 | 次外层 | 早拒绝,省掉后续所有开销 |
| 权限 | 3 | 中间层 | 拒绝时不必开启事务 |
| 事务 | — | 最内层(贴近目标) | 只包住真正的业务逻辑 |

@Aspect@Order(1)@Componentpublic class OpLogAspect { /* 最外层:日志 */ }@Aspect@Order(2)@Componentpublic class RateLimitAspect { /* 次外层:限流 */ }@Aspect@Order(3)@Componentpublic class PermAspect { /* 中间层:权限 */ }- 进入顺序:日志 → 限流 → 权限 → 事务 → 目标方法
- 返回顺序正好相反:目标方法 → 事务 → 权限 → 限流 → 日志
- 把「越早能拒绝、越省资源」的切面放得越靠外,是排序的核心原则
这是切面把事务搞坏的最经典姿势。
// 错误写法:@Around 里 try/catch 吞掉了异常@Around("@annotation(opLog)")public Object wrong(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable { try { return pjp.proceed(); } catch (Exception e) { log.error("方法执行失败", e); return null; // 异常被吃掉,事务切面永远看不到它 }}// 正确写法:记录后原样抛出,让事务拿到异常才能回滚@Around("@annotation(opLog)")public Object right(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable { try { return pjp.proceed(); } catch (Throwable e) { log.error("方法执行失败", e); throw e; // 关键:继续向上抛,事务边界才能感知失败 }}- 上面的「错误写法」里,异常被
catch吞掉并返回了null,外层事务切面看到的是「正常返回」,自然不会回滚 - 「正确写法」把异常原样
throw出去,事务切面才能执行回滚 - 一句话:环绕通知要做的是「记录并转发」,不是「消化异常」。真要吞异常,就必须自己承担事务一致性
坑:@Around 的 catch 里如果写的是 catch (Exception e),会漏掉 Error 和部分 Throwable。既然环绕通知处在链路上,捕获范围就该足够宽,或者干脆只 log 不动异常。
这个坑的成因只有一句话:吞异常的那一层,是不是站在事务边界的里面。同一张图把两种摆法并排放在一起——左边会写出脏数据,右边什么都不会坏:

想彻底记住它,就得跟着一次「抛异常」逐层走。下面是单步台,右边同步刷新此刻的事务状态;连点下一步,盯住第 ⑥ 步那句 return null——它就是脏数据诞生的地方:
orderService.createWithLog(dto); // ① 调用打到代理,先进事务[事务 @Order(0)] tx.begin(); // ② 事务在最外层,边界比日志宽[日志 @Order(100)] try { // ③ 日志切面贴着目标方法 Object r = pjp.proceed(); // ④ 再往里走,才是目标 [target] orderRepository.save(order); // ⑤ 这里抛 DataIntegrityViolationException} catch (Exception e) { log.error(「执行失败」, e); return null; } // ⑥ 异常在这一层被吃掉// 控制权回到事务切面:proceed() 正常返回了一个 null,没有任何异常[事务 @Order(0)] tx.commit(); // ⑦ 它没看见失败,于是提交return null; // ⑧ 调用方以为「成功了但没数据」| orderService | OrderServiceImpl$$SpringCGLIB$$0 |
| 切面 | 事务(外层) + 日志(内层) |
OrderController.createproxy.createWithLog切面里想拿当前请求,最常见的是 RequestContextHolder.getRequestAttributes()。但这个方法在异步线程、定时任务、MQ 消费、单元测试里统统返回 null——因为请求上下文默认存在 ThreadLocal 里,换线程就丢了。直接强转再 getRequest() 会当场 NullPointerException。所以每个从上下文取请求的切面,都必须先判空:拿不到就降级(比如记录为 system),而不是崩掉。同理,SecurityContextHolder 在异步线程里也拿不到登录态,需要用 DelegatingSecurityContextRunnable 之类的包装器传递。
下面这个演示先展示正常的通知链,再切到「自调用」,可以直观看到——一旦方法被同类内部调用,所有通知会全部失效,因为调用根本没经过代理:
第七节那段单步台讲的是「异常往哪走」,这一支是它的对照实验:同一段代码,error 模式下 @AfterThrowing 有没有把异常原样放出去、@Around 的 catch 会不会截断它。看完这一支再回头看第七节的 throw e,就知道那一行为什么是纪律而不是风格:
这一节的四个实验分别对应本篇的四条主线:顺序(aoporder)、在哪拦(pointcut)、织入前后差什么(weave)、日志这条链本身怎么落地(logchain)。
第一个实验是第六节那张 @Order 表的动态版。四个分支建议全点一遍:先看 @Order 生效 建立「小的在外层」的直觉,再看 嵌套执行时序 把完整链条一步步列出来,然后看 顺序颠倒的坑 明白忘写 @Order 为什么等于把顺序交给运气,最后看 @Transactional 与自定义切面——它解释了一个真实事故:消息已经发出去、数据还没提交:
第二个实验解决「这三个切面各该拦谁」。操作日志用 @annotation(opLog) 精确到方法、权限用 @within 覆盖整类、耗时统计用 execution( com.example.service...*(..)) 扫全包——三种写法命中范围完全不同,切到对应分支逐一对照:
第三个实验回答「加了切面到底赚了什么、付了什么」。切到「没有切面时」你会看到调用栈只有一层但方法体被 log/try-catch/计时搅浑;切到「织入切面后」同样一次调用多了三层横切,而业务代码一行未改;「通知链内部」则展示 proceed() 是怎么一层层把控制权交下去又还回来的:
第四个实验补上实战里最容易被忽略的一环:日志切面打印出来的那行字,究竟是怎么变成文件里的一行、又怎么带上 traceId 的。切到「输出格式怎么拼」能看到 %X{traceId} 的位置,切到「级别过滤」能理解为什么生产环境把 INFO 降成 WARN 就能让审计日志瞬间消失:

四支实验跑完,本篇的四个配方就只剩下「遇到需求该抄哪一招」这一件事。这一局左边是真实需求(或真实事故),右边是它对应的写法:
实验做到这里,可以换成命令行自己敲。这台控制台连着浏览器里的同一个容器,回显全部由内核算出来——boot 起容器,beans 看谁被套壳,剩下的按本篇主线一条一条敲:
左边选一套顺序方案,右边立刻给出「进入序列 / 退出序列 / 会不会出事」。同一批切面,仅仅换个数字,就会出现「被拒绝的请求也记了一条成功日志」「事务里白锁了行」这类功能正常、语义错了的事故:
enter LOG(1) -> RATE(2) -> AUTHZ(3) -> TX -> targetexit TX -> AUTHZ -> RATE -> LOG[LOG] op=下单 args=[42] result=Order(id=42) took=86ms user=zhang
沙盘里 no-order 那一格值得多说一句。@Order 不写不代表「默认最外层」或「默认最先」,而是取 Ordered.LOWEST_PRECEDENCE(也就是 Integer.MAX_VALUE);多个切面同为最大值时,Spring 只能按收集到的先后排,而这个先后来自组件扫描遍历、@Configuration 声明顺序和自动配置排序结果——三者都可能随环境与构建变化。唯一的解法是每个切面都显式写数字,并且留出间隔(10、20、30),以后插新切面不必整体重排。
以下片段都可整段粘进搜索框;这张表专门收「实战切面才会遇到」的那几类:
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
接口返回 200 但 data 为 null、数据库没新增行、下游 MQ 没收到消息,且全程无任何异常 | @Around 里忘了调 pjp.proceed():通知链在这一层断了,目标方法一次都没执行 | 在 @Around 第一行打断点确认 proceed() 是否被调到;写法定死:方法体里必须有 return pjp.proceed();,其余逻辑围绕它包 try/finally | #13 @AspectJ 细节第八节 · 本篇第十一节 weave 实验 chain 分支 |
BeanCurrentlyInCreationException: Error creating bean with name 'permAspect': Requested bean is currently in creation: Is there an unresolvable circular reference? | 切面里 @Autowired 注入了某个 Service,而那个 Service 又要被这个切面代理,于是「切面 → Service → 切面的代理决策」形成环 | 首选把业务查询下沉成一个不依赖被切 Bean 的小组件;确实要用 Service 就在字段上加 @Lazy(配合 ObjectProvider<T> 更好),让注入发生在真正需要时 | #10 循环依赖 · #14 AOP 内核第十一站 |
日志顺序在不同机器 / 不同次启动之间飘移(@Order 没写导致顺序随机) | 未标注 @Order 也未实现 Ordered 的切面一律取 Ordered.LOWEST_PRECEDENCE,相对顺序退化为 Advisor 收集顺序 | 每个切面显式写 @Order(n) 并留大间隔(10、20、30);在集成测试里断言日志序列,而不是单测 | 本篇第十二节沙盘 no-order · #13 第六节 |
| 事务「不生效」:数据写了进去,异常也已经抛出,但没有任何回滚 | 排在事务外层的 @Around 在 catch 里把异常吃掉(return null),外层事务边界看到的是正常返回 | 环绕通知只「记录并原样转发」:catch (Throwable e) { log.error(...); throw e; };确需吞掉时,自己承担一致性 | 本篇第七节 · #31 事务内幕 |
NullPointerException 出现在切面里 (ServletRequestAttributes) RequestContextHolder.getRequestAttributes() 之后 | 异步线程、定时任务、MQ 消费、单元测试里没有请求上下文,该方法返回 null;上下文存在 ThreadLocal,换线程即丢 | 先判空再取,拿不到就降级为 system;跨线程传递登录态用 DelegatingSecurityContextRunnable 之类包装器 | 本篇第八节 · #40 异步与定时 |
java.lang.ClassCastException: class com.sun.proxy.$Proxy42 cannot be cast to class com.example.service.UserServiceImpl | 你在切面里把 pjp.getTarget() 强转成实现类,但容器给的是 JDK 代理 | 通过接口调用;或开 spring.aop.proxy-target-class=true(Boot 默认已 true);只想读字段就改用反射工具 AopUtils.getTargetClass() | #14 AOP 内核第五节 · #12 动态代理 |
IllegalArgumentException: error at anonymous pointcut expression around this | 切点字符串写坏:@annotation(opLog) 里的变量名与形参不一致、括号不成对,或注解写成简单名 | 逐段核对括号与全限定名;注解绑定用形参名原样拷贝 | #13 @AspectJ 细节第十三节 |
| 缓存切面命中后仍然把方法执行了一遍(或反过来:明明该刷新却返回旧值) | key 生成漏了参数,不同入参互相串数据;或 TTL 判断写在 proceed() 之后 | key 必须包含「方法签名 + 全部影响结果的参数」;命中分支要在任何副作用之前直接 return | 本篇第五节 · #32 Redis 与缓存 |
| 敏感字段(密码、身份证)被原样打进操作日志 | Arrays.toString(pjp.getArgs()) 无差别输出所有参数 | 按字段白名单序列化,或对指定字段脱敏;@OpLog(recordArgs = false) 也要保留为逃生开关 | 本篇第二节要点 |
@Around 的 catch 如果写成 catch (Exception e),会漏掉 Error 与其他 Throwable;但如果为了「不漏」而在 catch 里 return null,就把上面第一、第四行两个坑同时踩了。安全写法只有两种:要么根本不 catch、只在 finally 里收尾;要么 catch 宽到 Throwable 并且一定 throw e。
上面第二档「注入自身代理」的修法,如果忘了开开关,会给你一段非常直的栈——报错里连修法都写出来了,问题是新手读不出它在讲什么。练一次:
为了绕开同类自调用失效,你把 this.createTwice() 改成 ((OrderService) AopContext.currentProxy()).createTwice(),本地编译通过,线上第一次调用就炸。
目标:做一个能直接抄进项目的耗时统计切面,跑通「注解 → 切点绑定 → 分级日志 → 汇总」四步,并亲眼看到它与操作日志切面的嵌套顺序。以下代码放进一个 Spring Boot 项目(需 spring-boot-starter-aop)即可运行。
package com.example.anno;import java.lang.annotation.*;@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME) // 必须是 RUNTIME,否则切面反射读不到@Documentedpublic @interface Cost { /** 业务动作名,用于日志与汇总,缺省用方法名 */ String value() default ""; long warnMs() default 200; long errorMs() default 1000;}package com.example.aspect;import com.example.anno.Cost;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.LongAdder;@Aspect@Component@Order(2) // 与 OpLogAspect(@Order(1)) 组成洋葱的两圈皮public class CostAspect { private static final Logger log = LoggerFactory.getLogger(CostAspect.class); private final Map<String, LongAdder> calls = new ConcurrentHashMap<>(); private final Map<String, LongAdder> totalMs = new ConcurrentHashMap<>(); @Around("@annotation(cost)") // 变量名 cost 必须与下面的形参名完全一致 public Object around(ProceedingJoinPoint pjp, Cost cost) throws Throwable { String action = cost.value().isEmpty() ? pjp.getSignature().getName() : cost.value(); long start = System.nanoTime(); calls.computeIfAbsent(action, k -> new LongAdder()).increment(); try { return pjp.proceed(); // 必填项:不调它,目标方法一次都不会执行 } finally { long ms = (System.nanoTime() - start) / 1_000_000; totalMs.computeIfAbsent(action, k -> new LongAdder()).add(ms); String line = "[COST] {} 耗时={}ms 阈值(warn={}, error={})"; if (ms >= cost.errorMs()) { log.error(line, action, ms, cost.warnMs(), cost.errorMs()); } else if (ms >= cost.warnMs()) { log.warn(line, action, ms, cost.warnMs(), cost.errorMs()); } else { log.info(line, action, ms, cost.warnMs(), cost.errorMs()); } } } /** 供练习与排查使用:打印累计值 */ public String summary() { StringBuilder sb = new StringBuilder("[SUMMARY] "); calls.forEach((k, v) -> sb.append(k).append("=").append(v.sum()) .append("次/").append(totalMs.get(k).sum()).append("ms ")); return sb.toString(); }}package com.example.service;import com.example.anno.Cost;import org.springframework.stereotype.Service;@Servicepublic class OrderService { @Cost("下单") public Long create(String sku) throws InterruptedException { Thread.sleep(50); // 模拟正常:INFO return 42L; } @Cost(value = "对账", warnMs = 20) public String reconcile() throws InterruptedException { Thread.sleep(300); // 超过 warnMs:WARN return "done"; } @Cost(value = "报表", errorMs = 100) public String report() throws InterruptedException { Thread.sleep(150); // 超过 errorMs:ERROR return "big-report"; }}// 启动并依次调用三个方法@SpringBootApplicationpublic class CostDemoApplication { public static void main(String[] args) throws Exception { try (ConfigurableApplicationContext ctx = SpringApplication.run(CostDemoApplication.class, args)) { OrderService svc = ctx.getBean(OrderService.class); svc.create("A-1"); svc.reconcile(); svc.report(); System.out.println(ctx.getBean(com.example.aspect.CostAspect.class).summary()); } }}预期日志(时间戳与前缀省略):
[COST] 下单 耗时=51ms 阈值(warn=200, error=1000)[COST] 对账 耗时=301ms 阈值(warn=20, error=1000)[COST] 报表 耗时=151ms 阈值(warn=200, error=100)[SUMMARY] 下单=1次/51ms 对账=1次/301ms 报表=1次/151ms- 三行的级别分别是 INFO / WARN / ERROR,用 IDE 的控制台颜色一眼可辨
@Order(2):如果你再把第二节的OpLogAspect标成@Order(1)并贴在同一个方法上,日志顺序必然是[AROUND-IN(OpLog)] → [COST-IN?] …… → [COST] → [AROUND-OUT(OpLog)],即洋葱的两圈皮@annotation(cost)的变量名与形参Cost cost完全一致——把它改成Cost c就会当场报绑定失败,这正是第十四节第一题的另一半答案
- 把
CostAspect的@Order(2)删掉,同时给OpLogAspect也只留@Component。你会观察到:连续重启三次,进入与退出顺序可能变化;一旦日志被排到耗时切面内层,[COST]的数值会比实际用户感受小一截——这就是第十二节沙盘no-order那格的现场版。 - 把
around里的return pjp.proceed();换成return 999L;。你会观察到:OrderService#create里的Thread.sleep不再发生(耗时瞬间变成 0ms),返回值变成 999,而程序没有任何异常。顺手体会缓存切面为什么「故意这么做」是正确的。 - 给
create加一个@Transactional,并在report()里抛IllegalStateException,同时把CostAspect的@Order改成-1(比事务更外层)。你会观察到:[COST]统计到的耗时包含了提交/回滚时间;再把finally改成catch (Exception e) { log...; return null; },脏数据竟然落进了库——因为异常没能传到事务边界。这条就是本节点的铁律。 - 把
logging.level.com.example.aspect=warn设进配置文件。你会观察到:INFO 那行彻底消失,只剩 WARN 与 ERROR。回到第十一节的logchain实验看「级别过滤」,就知道这不是切面没跑,而是被门挡在了外面。
把本篇三个切面合成一套最小可上线的操作审计套件:@OpLog(操作日志)+ @Cost(耗时)+ @RequirePerm(权限),并让它们互不打架。
- 三个切面各自显式
@Order:日志 10、权限 20、耗时 30(间隔留大,方便以后插队) - 统一 traceId:由最外层日志切面写入
MDC,另外两个切面只读不写,finally里由最外层负责MDC.remove("traceId")(不要用clear(),会误伤别人的键) - 权限切面禁止依赖任何会被自己代理的 Bean:把「查权限」抽成
PermissionReader接口,用一个不命中切点的实现类 - 提供一个
/actuator之外的简易端点或启动时打印:三个切面各自的命中次数与被拦截次数 - 敏感字段脱敏:
@OpLog(recordArgs = false)之外再加一个String[] maskFields(),对被标记的字段打印***
验收清单:① 一次正常调用产生三条带同一 traceId 的日志,顺序符合洋葱模型;② 一次越权调用不产生任何数据库写入,且审计日志里有「谁被拒了」的记录;③ 关掉任意一个切面(去掉它的 @Component),另两个行为与日志完全不变,证明彼此无耦合;④ 写一个集成测试断言这三行日志的顺序,删掉某个 @Order 后该测试必须失败——这样顺序回归才能被 CI 拦住。
不看上文,说出「注解 + 切面」这套套路为什么能把意图与实现分开,以及 @Retention(RUNTIME) 在这里为什么是硬性要求。
三个切面同时命中一个方法时,推荐的 @Order 排法是什么?理由分别一句话。
@Around 里正确的异常处理姿势只有两种,分别是什么?为什么第三种(catch 后 return null)会连带弄坏事务?
切面里 @Autowired 注入一个 Service 会带来什么风险?两种解法分别是什么?
从 RequestContextHolder 取请求为什么要判空?哪些线程里它一定是 null?
想改行为用 Around,只想观察用其余;越能早拒绝,越往外层放;Around 必须 proceed,异常原样往上抛。
这一节给了四个可直接复用的切面——操作日志用 @Around(记录操作人、参数、返回值、耗时,用 MDC 串 traceId)、权限校验用 @Before(无权限抛业务异常)、接口限流用 @Around(ConcurrentHashMap + 时间窗口,生产换 Redis + Lua)、缓存用 @Around(SpEL 生成 key,命中即返回)。多切面用 @Order 排队,数字越小越外层;推荐的顺序是「日志 > 限流 > 权限 > 事务」。最后记住两条铁律:环绕通知记录后必须原样抛异常,否则事务不会回滚;从 RequestContextHolder 取请求必须判空,异步与定时任务里它一定是 null。