java 技术随笔

Spring AOP 详解:切点表达式、五种通知与自定义注解实战

日志记录、权限校验、接口耗时统计、分布式锁、事务……这些“与核心业务无关却到处都要做”的逻辑,如果散落在每个方法里,代码会又臭又难维护。Spring AOP(面向切面编程)就是把这些横切关注点统一抽出来的利器。本文讲透 AOP 概念、注解用法、切点表达式,并手写一个自定义注解实现操作日志。

一、AOP 核心概念(先建立坐标系)

术语通俗解释
Aspect(切面)横切逻辑 + 切入规则的集合,如“所有 Controller 方法的耗时统计”
JoinPoint(连接点)能被切入的时机(Spring 中指方法执行)
Pointcut(切点)真正要切入哪些方法的表达式,如 execution(* com.demo..*.save(..))
Advice(通知)切入后执行的动作:前置/后置/环绕/异常/最终
Target(目标对象)被切面增强的业务对象
Weaving(织入)把切面应用到目标对象生成代理的过程

一句话:Pointcut 说“切哪里”,Advice 说“切了干什么”,两者组成 Aspect。

二、五种通知与执行顺序

@Aspect
@Component
public class LogAspect {

    // @Before:方法执行前(可校验参数、记录开始时间)
    @Before("pointcut()")
    public void before(JoinPoint jp) { ... }

    // @AfterReturning:方法正常返回后(拿得到返回值)
    @AfterReturning(pointcut = "pointcut()", returning = "result")
    public void afterReturning(JoinPoint jp, Object result) { ... }

    // @AfterThrowing:方法抛异常后
    @AfterThrowing(pointcut = "pointcut()", throwing = "ex")
    public void afterThrowing(JoinPoint jp, Throwable ex) { ... }

    // @After:无论正常还是异常都会执行(类似 finally)
    @After("pointcut()")
    public void after(JoinPoint jp) { ... }

    // @Around:最强通知,可完全接管方法(做前处理 -> proceed 放行 -> 后处理)
    @Around("pointcut()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();   // 放行执行目标方法
            return result;
        } finally {
            log.info("{} 耗时 {}ms", pjp.getSignature(), System.currentTimeMillis() - start);
        }
    }
}

同一方法上多个切面的执行顺序可用 @Order 控制(数字越小越先执行)。环绕通知的 proceed() 调用时机决定前/后逻辑的分界,proceed 之前相当于 @Before,之后相当于 @AfterReturning/@After。

三、切点表达式:execution 详解

// 格式:execution(修饰符? 返回类型 类路径.方法名(参数) 异常?)
// 最常用写法示例:
execution(* com.demo.service.*.*(..))                    // service 包下所有类的所有方法
execution(public * com.demo..OrderService.save*(..))     // 任意层级 com.demo,OrderService 以 save 开头的方法
execution(* com.demo.service.OrderService.*(Long, ..))   // 第一个参数为 Long 的方法

// @annotation:切“被指定注解标记的方法”(自定义注解方案常用)
@annotation(com.demo.annotation.OpLog)

// within / @within:切包、类、带某注解的类
within(com.demo.controller..*)

参数里的 .. 表示任意参数;* 匹配任意返回类型或类名/方法名。切点也可在方法上用 @Pointcut 声明复用:

@Pointcut("execution(* com.demo.service.*.*(..))")
public void servicePointcut() {}   // 空方法,仅作切点声明

@Before("servicePointcut()")        // 其他地方引用即可
public void doBefore(JoinPoint jp) { ... }

四、AOP 底层:动态代理

Spring AOP 基于代理实现(没有代理就没有 AOP):

  • JDK 动态代理:目标类实现了接口时默认使用,代理和目标是兄弟关系,只能代理接口声明的方法;
  • CGLIB 代理:目标类没有接口时使用(Spring Boot 默认强制使用),通过继承生成子类代理,可以代理普通类方法;
  • 开启方式:Spring Boot 默认 spring.aop.proxy-target-class=true;注解驱动可加 @EnableAspectJAutoProxy(proxyTargetClass = true)

由此引出一个经典结论(与事务失效同源):同类内部方法互调(this 调用)不走代理,AOP 不生效。例如一个方法里 this.doSomething() 调用了标了 @annotation 的私有切点方法,切面不会执行。解决:注入自身代理、拆到别的 Bean、或改用 AspectJ 编译期织入。

五、实战:自定义注解 + AOP 实现操作日志

第 1 步:定义注解

@Target(ElementType.METHOD)          // 只作用在方法上
@Retention(RetentionPolicy.RUNTIME)  // 运行时通过反射可见(AOP 需要)
public @interface OpLog {
    String module();                 // 模块名,如 "订单"
    String action() default "";      // 操作类型,如 "create"
}

第 2 步:写切面,拦截带注解的方法

@Aspect
@Component
public class OpLogAspect {
    @Resource private LogDao logDao;

    @Around("@annotation(opLog)")        // 绑定方法上的注解对象
    public Object around(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable {
        long start = System.currentTimeMillis();
        Object result;
        try {
            result = pjp.proceed();
            saveLog(pjp, opLog, true, null, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable ex) {
            saveLog(pjp, opLog, false, ex.getMessage(), System.currentTimeMillis() - start);
            throw ex;                     // 记得继续往外抛,别吞异常
        }
    }

    private void saveLog(ProceedingJoinPoint pjp, OpLog opLog,
                         boolean success, String error, long cost) {
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        String user = UserContext.get();   // 从上下文取当前用户
        String args = Arrays.toString(pjp.getArgs());
        logDao.insert(OperLog.builder()
                .module(opLog.module()).action(opLog.action())
                .method(sig.getDeclaringTypeName() + "#" + sig.getName())
                .params(truncate(args)).success(success)
                .error(error).costMs(cost).operator(user).build());
    }
}

第 3 步:业务方法上加注解即可(业务代码零侵入)

@Service
public class OrderService {
    @OpLog(module = "订单", action = "创建")
    public Long create(OrderCreateReq req) {
        // 只写核心业务逻辑,日志由切面自动记录
        return orderDao.insert(req.toPO());
    }
}

同样的套路还能做:@NeedLogin 权限校验(Around 里先验 token)、@Idempotent 防重(Around 里先抢 Redis 锁)、@DistributedLock@Timing 耗时告警。一套注解框架就能让团队“只写业务、统一横切”。

六、开发中的高频坑

  • 自调用失效:this 调 this 方法不会走代理(同事务失效原理),把增强方法拆到别的 Bean 或注入 ApplicationContext 取代理;
  • @Around 里务必调用 proceed 并 return 结果:漏了目标方法不执行,漏了 return 调用方拿到 null;
  • 别在切面里做耗时操作:每次方法调用都会执行切面,日志同步写库会拖慢业务,建议异步落库;
  • finally 中谨慎改返回值:finally 里 return 会覆盖 try 中的返回结果(Java 语法坑,和 AOP 无关但常同时出现);
  • 切面粒度别太粗:execution(* com.demo..*(..)) 这种全包切面会把 getter/setter、内部调用全部织入,性能与误伤都难控制。

七、AOP 与 Spring 事务的关系

你天天用的 @Transactional 底层就是 AOP:事务管理器作为“切面”包住方法,开启/提交/回滚事务。因此 AOP 的所有限制(自调用失效、public 方法、代理对象)对事务同样成立——理解了 AOP 就理解了大半的事务失效问题,两者放在一起学效率最高。

一句话收尾:业务管“做什么”,AOP 管“顺带做什么”。会写 execution 表达式、会用 @Around 组合前中后逻辑、懂得代理机制与自调用陷阱,Spring AOP 就能真正为你所用。

标签
SpringAOP切面注解