第 43 章:AOP 面向切面
学习目标
- 理解 AOP 解决什么问题
- 掌握切点表达式与五种通知
- 实战:日志切面、限流切面
一、AOP 解决什么问题?
横切关注点:日志、事务、权限、限流、缓存——这些逻辑散落在每个方法里。
java
// ❌ 业务代码被非业务逻辑淹没
public void createOrder(Order order) {
log.info("开始创建订单: {}", order); // 日志
long start = System.currentTimeMillis(); // 耗时统计
if (!hasPermission("order:create")) { // 权限
throw new ForbiddenException();
}
TransactionStatus tx = txManager.getTransaction();// 事务
try {
orderMapper.insert(order); // ← 只有这一行是业务!
txManager.commit(tx);
} catch (Exception e) {
txManager.rollback(tx);
throw e;
} finally {
log.info("耗时 {}ms", System.currentTimeMillis() - start);
}
}AOP 的解法:把这些逻辑抽出去,用「切面」在运行时织入。
java
// ✅ 业务代码只剩业务
@Transactional
@RequiresPermission("order:create")
@LogExecution
public void createOrder(Order order) {
orderMapper.insert(order);
}二、核心概念
| 术语 | 英文 | 含义 |
|---|---|---|
| 切面 | Aspect | 横切逻辑的载体类(@Aspect) |
| 连接点 | JoinPoint | 可以被拦截的点(Spring 中只支持方法) |
| 切点 | Pointcut | 「哪些连接点要被拦截」的规则表达式 |
| 通知 | Advice | 拦截后做什么(前置/后置/环绕...) |
| 织入 | Weaving | 把切面应用到目标对象的过程(Spring 在运行期用动态代理) |
三、五种通知
java
@Aspect
@Component
@Slf4j
public class DemoAspect {
@Pointcut("execution(* com.taskflow.service..*.*(..))") // ① 定义切点,复用
public void serviceMethods() { }
@Before("serviceMethods()") // ② 方法执行前
public void before(JoinPoint jp) {
log.info("前置: {}", jp.getSignature().getName());
}
@AfterReturning(value = "serviceMethods()", returning = "result")
public void afterReturning(JoinPoint jp, Object result) { // ③ 正常返回后
log.info("返回值: {}", result);
}
@AfterThrowing(value = "serviceMethods()", throwing = "e")
public void afterThrowing(JoinPoint jp, Exception e) { // ④ 抛异常后
log.error("异常: {}", e.getMessage());
}
@After("serviceMethods()") // ⑤ 无论如何都执行(相当于 finally)
public void after(JoinPoint jp) {
log.info("后置");
}
@Around("serviceMethods()") // ⑥ 环绕:功能最强
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed(); // ⑦ 必须调用!否则目标方法不执行
log.info("耗时: {}ms", System.currentTimeMillis() - start);
return result; // ⑧ 必须返回!否则调用方拿到 null
}
}执行顺序
实践建议:
@Around一个就够用了。它能覆盖其他四种的所有能力,代码也更集中。
四、切点表达式
execution(最常用)
execution(修饰符? 返回类型 包名.类名.方法名(参数) 异常?)java
// 所有 public 方法
execution(public * *(..))
// service 包下所有类的所有方法(一个点 = 只有这层包)
execution(* com.taskflow.service.*.*(..))
// service 包及子包(两个点 = 含子包)
execution(* com.taskflow.service..*.*(..))
// 所有以 save 开头的方法
execution(* com.taskflow..*.save*(..))
// 指定返回类型和参数
execution(User com.taskflow.service.UserService.getById(Long))| 通配符 | 含义 |
|---|---|
* | 匹配任意一个(返回类型、类名、方法名) |
.. | 包位置:当前包及子包;参数位置:任意个参数 |
+ | 类名后:该类及其子类 |
@annotation(推荐,最精准)
java
// 拦截所有标注了 @LogExecution 的方法
@Around("@annotation(com.taskflow.annotation.LogExecution)")为什么推荐注解方式?
execution靠包路径匹配,改包名就失效,而且看代码时不知道这个方法被切了。注解方式在方法上直接可见,意图明确。
其他表达式
java
@within(org.springframework.stereotype.Service) // 类上有此注解
@args(com.taskflow.MyAnno) // 参数类型上有此注解
within(com.taskflow.service..*) // 限定包范围
args(Long, String) // 参数类型匹配
bean(*Service) // Bean 名称匹配组合使用:
java
@Around("within(com.taskflow.service..*) && @annotation(logExecution)")
public Object around(ProceedingJoinPoint pjp, LogExecution logExecution) {
// ① 注解对象可以直接作为参数拿到,读取注解属性
String module = logExecution.module();
}五、实战一:操作日志切面
定义注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String module(); // 模块名
String action(); // 操作类型
boolean saveParams() default true; // 是否记录入参
}切面实现
java
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class OperationLogAspect {
private final OperationLogService logService;
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint pjp, OperationLog operationLog)
throws Throwable {
long start = System.currentTimeMillis();
OperationLogEntity entity = new OperationLogEntity();
entity.setModule(operationLog.module());
entity.setAction(operationLog.action());
entity.setMethod(pjp.getSignature().toShortString());
entity.setOperator(SecurityUtils.getCurrentUsername()); // ① 从上下文取当前用户
entity.setIp(IpUtils.getClientIp());
if (operationLog.saveParams()) {
// ② 敏感字段脱敏后再记录
entity.setParams(JsonUtils.toJsonWithMask(pjp.getArgs()));
}
try {
Object result = pjp.proceed();
entity.setSuccess(true);
return result;
} catch (Throwable e) {
entity.setSuccess(false);
entity.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000)); // ③ 截断防超长
throw e; // ④ 必须往上抛,不能吞
} finally {
entity.setCostMs(System.currentTimeMillis() - start);
logService.saveAsync(entity); // ⑤ 异步落库,不阻塞主流程
}
}
}⑤ 为什么必须异步? 日志落库要几十毫秒。同步的话,每个接口都白白慢几十毫秒,而且日志库挂了会导致业务失败。
使用:
java
@OperationLog(module = "订单", action = "创建订单")
public void createOrder(Order order) { }六、实战二:接口限流切面
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int permits() default 10; // 允许次数
int seconds() default 1; // 时间窗口
LimitType type() default LimitType.IP; // 限流维度
}
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final StringRedisTemplate redis;
@Before("@annotation(rateLimit)")
public void check(JoinPoint jp, RateLimit rateLimit) {
// ① 构造限流 key:方法签名 + 维度标识
String key = "rate:" + jp.getSignature().toShortString() + ":"
+ resolveIdentifier(rateLimit.type());
// ② 计数 +1
Long count = redis.opsForValue().increment(key);
// ③ 第一次访问时设置过期时间(滑动窗口的简化版:固定窗口)
if (count != null && count == 1) {
redis.expire(key, rateLimit.seconds(), TimeUnit.SECONDS);
}
if (count != null && count > rateLimit.permits()) {
throw new BusinessException(ErrorCode.RATE_LIMITED);
}
}
}⚠️ 这是固定窗口限流,存在临界问题(窗口交界处可能通过 2 倍请求)。生产环境用 Redis + Lua 实现滑动窗口,或直接用 Sentinel(见第 69 章)。
七、切面执行顺序
多个切面作用在同一方法时,用 @Order 控制:
java
@Aspect
@Order(1) // ① 数字越小优先级越高,越先执行(外层)
@Component
public class LogAspect { }
@Aspect
@Order(2)
@Component
public class RateLimitAspect { }像洋葱一样:先进的后出。
八、常见坑
java
// 坑 1:@Around 忘记 return
@Around("...")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
pjp.proceed(); // ❌ 没有 return,调用方永远拿到 null
}
// 坑 2:自调用不生效(见第 34 章)
public void a() {
this.b(); // ❌ 切面不生效
}
// 坑 3:private / final 方法切不到
@LogExecution
private void doWork() { } // ❌ CGLIB 无法代理
// 坑 4:切面吞掉异常
@Around("...")
public Object around(ProceedingJoinPoint pjp) {
try {
return pjp.proceed();
} catch (Throwable e) {
log.error("出错", e);
return null; // ❌ 异常被吞,业务以为成功了
}
}
// 坑 5:切面里抛异常影响主流程
finally {
logService.save(entity); // ❌ 日志库挂了会导致业务接口 500
}
// ✅ 切面的辅助逻辑要自己 try 住
finally {
try { logService.saveAsync(entity); }
catch (Exception e) { log.warn("日志记录失败", e); }
}九、本章小结
| 要点 | 关键 |
|---|---|
| AOP | 抽离横切关注点 |
| 切点 | 推荐 @annotation 方式 |
| 通知 | @Around 最强,一个够用 |
proceed() | 必须调用且必须 return |
@Order | 控制多切面顺序,小的在外层 |
| 自调用 | 不走代理,切面失效 |
| 辅助逻辑 | 自己 try 住,不能影响主流程 |
动手练习
练习 1:基础题
写一个 @Timed 注解 + 切面,统计方法耗时。超过 1 秒时打 WARN 日志。
练习 2:进阶题
实现 @Retry(times = 3, delay = 1000) 切面:方法抛出指定异常时自动重试,支持指数退避(1s、2s、4s)。
下一章:第 44 章:第一个 REST 接口 →