本节摘要:切面 = 通知(做什么)+ 切入点(在哪做)。本节给出五种通知的执行顺序、切入点表达式的常用写法,以及一个可直接运行的慢调用监控切面;顺带说明 @annotation 与包路径两种定位方式的选择。
通知是切面在特定时机执行的动作,共五种:前置、后置、返回、异常、环绕。直接上代码看全貌:
@Aspect @Component public class SlowCallAspect { private static final Logger log = LoggerFactory.getLogger(SlowCallAspect.class); @Pointcut("within(com.example.orders..*)") public void orderLayer() {} @Around("orderLayer()") public Object time(ProceedingJoinPoint pjp) throws Throwable { long t = System.nanoTime(); try { return pjp.proceed(); } finally { long ms = (System.nanoTime() - t) / 1_000_000; if (ms > 300) { log.warn("慢调用 {} 耗时 {} 毫秒", pjp.getSignature().toShortString(), ms); } } } @AfterThrowing(pointcut = "orderLayer()", throwing = "ex") public void onError(JoinPoint jp, Exception ex) { log.error("调用 {} 失败", jp.getSignature().toShortString(), ex); } }
五种通知在一次调用中的顺序是:环绕进入 → 前置 → 目标方法 → (正常时)返回、后置 → 环绕退出;异常时则是异常、后置。环绕是能力最全的通知——能改参数、能改返回值、能吞异常、能量耗时,日常工程里一个环绕往往顶替其余四个的组合。新版本对同类中通知的默认顺序有调整,不同切面之间用 @Order 显式声明,别依赖默认。
表达式回答"拦谁"。工程里最常用的四种写法:
| 写法 | 含义 | 适用 |
|---|---|---|
| 按执行方法匹配 | 匹配方法签名模式 | 精确到方法层 |
| 按类型所在包匹配 | within 开头加包模式 | 整层拦截,上面示例用的就是它 |
| 按注解匹配 | 加在方法上的注解 | 业务代码打标,切面认标 |
| 按 Bean 名匹配 | 按容器内 Bean 名字 | 少用,改名易断 |
我更推荐注解驱动的切入点:定义一个语义化注解,切面拦注解,业务方法打标——切面与包结构解耦,移动类不会悄悄脱离监控:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AuditLog { String value() default ""; } @Around("@annotation(audit)") public Object audit(ProceedingJoinPoint pjp, AuditLog audit) throws Throwable { log.info("审计:{} 操作 {}", pjp.getSignature().toShortString(), audit.value()); return pjp.proceed(); }
业务方法上加一行 @AuditLog("创建订单") 即纳入审计,不加则完全不受影响,团队成员读注解名就能懂。

⚠️ 切面里不要吞异常再返回空值:调用方以为成功,数据却没落库。确需转换异常时,抛出带上下文的新异常,保留原因链。
背景:性能治理第一轮要一份"超过三百毫秒的接口清单",人工翻代码估不靠谱,需要实测数据。操作:把上文慢调用监控切面稍作升级——按方法签名聚合次数与总耗时,定期输出汇总:
@Aspect @Component public class SlowCallStatsAspect { private final Map<String, LongAdder> counts = new ConcurrentHashMap<>(); private final Map<String, LongAdder> totals = new ConcurrentHashMap<>(); @Around("within(com.example..*)") public Object stat(ProceedingJoinPoint pjp) throws Throwable { String key = pjp.getSignature().toShortString(); long t = System.nanoTime(); try { return pjp.proceed(); } finally { counts.computeIfAbsent(key, k -> new LongAdder()).increment(); totals.computeIfAbsent(key, k -> new LongAdder()) .add((System.nanoTime() - t) / 1_000_000); } } @Scheduled(fixedDelay = 60_000) // 每分钟输出一次榜单 public void report() { counts.keySet().stream() .sorted(Comparator.comparingLong( k -> -totals.get(k).sum()).limit(10)) .forEach(k -> System.out.printf("%s 次数%d 总耗时%d毫秒%n", k, counts.get(k).sum(), totals.get(k).sum())); } }
操作步骤:部署到预发环境,用回放流量跑半小时,收集输出的榜单。结果:得到一张按总耗时排序的十行清单,榜首通常是两三个聚合查询接口。解读:环绕通知在方法边界计时,量到的是完整业务耗时(含内部数据库往返),与第 1 章实验场里过滤器层的全程耗时相互印证,两层相减还能估出"分发与拦截"本身的开销。变式:把统计对象换成注解驱动(只统计带审计注解的方法),一份切面就同时服务了性能与合规两个诉求。