什么是面向切面编程AOP?

AOP实战这块,建议去读一下美团线上使用AOP的日志实战,非常经典,纯正干货,值得借鉴。

如果想完整知道AOP的一些知识和应用场景以及坑,或者想在面试中拿到高分的,那也可以看下我写的这篇,内容相对比较完整的。好,我们开始吧。

@Transactional加在方法上,事务自动生效。@Async加在方法上,异步执行。@Cacheable加在方法上,结果自动缓存。这些注解背后都是同一套机制:Spring AOP的代理和拦截器链。

面试里AOP的考察一般分三个层面:

  • 概念层(切面、通知、切入点是什么)
  • 原理层(代理对象怎么创建的、拦截器链怎么执行的、JDK代理和CGLIB怎么选的)
  • 实战层(你在项目中用AOP做过什么)。

前两层大部分人能答上一些,第三层往往只说一句「做过日志切面」就没了,面试官想听的是具体的设计思路以及实战。

下面的内容会把这三个层面都过一遍,从AOP要解决的问题讲起,到源码级的实现原理,再到两个可以直接用在项目里的实战方案。

横切关注点:AOP要解决什么问题

看一个典型的业务方法:

typescript 复制代码
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
    // 权限检查
    if (!permissionService.check(fromAccount)) {
        throw new BusinessException("无权操作");
    }

    // 记录日志
    log.info("转账开始, from={}, to={}, amount={}", fromAccount, toAccount, amount);

    // 事务管理
    TransactionStatus status = txManager.getTransaction(def);
    try {
        accountDao.deduct(fromAccount, amount);
        accountDao.increase(toAccount, amount);
        txManager.commit(status);
    } catch (Exception e) {
        txManager.rollback(status);
        throw e;
    }

    log.info("转账完成, from={}, to={}", fromAccount, toAccount);
}

整个方法里真正处理业务的只有两行:扣减转出方余额和增加转入方余额。权限检查、日志记录、事务管理占了绝大部分代码。

这些逻辑在支付方法、退款方法、充值方法里又各出现了一遍,代码几乎一样。

这类跟具体业务无关,但在每个业务方法里都要出现的逻辑,有个名字叫横切关注点。它们横跨在多个业务模块之上,不属于某一个特定的业务。

横切关注点概念图

AOP的思路是:把横切关注点从业务代码中抽出来,放到独立的模块里维护,在运行时自动织入到目标方法的前后。

抽取之后,业务方法只剩核心逻辑:

typescript 复制代码
@Transactional
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
    accountDao.deduct(fromAccount, amount);
    accountDao.increase(toAccount, amount);
}

事务、日志、权限这些逻辑各自定义在独立的切面中,框架在运行时自动把它们应用到目标方法上。业务代码干净了,横切逻辑也集中到一个地方统一维护。

用小区门卫理解代理机制

Spring AOP是基于代理实现的,理解了代理机制,后面所有的概念和问题都能自然串联起来。

可以把Spring AOP想成小区门卫的模式。

小区里住着各种住户,对应你写的各种Service类。外来访客想找某个住户办事,不能直接走进小区,必须先到门卫那里登记。门卫检查来访者的身份证件,确认没问题后放行。访客离开时,门卫登记离开时间。如果来访者身份可疑,门卫可以直接拒绝进入。

映射到Spring AOP:

  • 住户 = 你写的Service类(目标对象)
  • 门卫 = Spring在运行时生成的代理对象
  • 外来访客通过门卫进小区 = 其他Bean调用你的方法时,调到的是代理对象
  • 门卫检查证件后放行 = @Before通知,目标方法执行前运行
  • 门卫登记离开时间 = @After通知,目标方法执行后运行
  • 门卫拒绝来访者进入 = @Around通知,可以控制目标方法是否执行
  • 多个门卫有值班顺序 = @Order注解控制多个切面的优先级

这个比喻还能解释Spring AOP中最经典的一个坑:小区里住户之间互相串门,是不需要经过门卫的。 对应到代码里,同一个类内部用this调用自己的方法,这个调用不经过代理,切面逻辑不会生效。后面会详细讲这个问题。

门卫只管小区大门,管不了住户家里发生什么。Spring AOP也一样,只能拦截Spring Bean的方法调用,不能拦截字段访问、构造方法、静态方法。这是Spring AOP和AspectJ的核心区别。

代理机制示意图

@Transactional的AOP实现

@Transactional是AOP在Spring里最典型的应用。追踪一下它的底层实现,能帮助理解AOP的整个工作链路。

Spring的事务配置类ProxyTransactionManagementConfiguration注册了三个关键的Bean:

  • TransactionAttributeSource负责识别哪些方法标了@Transactional以及注解上配了什么属性。
  • TransactionInterceptor是真正干活的拦截器,它实现了MethodInterceptor接口,会被插入到AOP的拦截器链里。
  • BeanFactoryTransactionAttributeSourceAdvisor是Advisor,负责把切入点(哪些方法需要事务管理)和通知(TransactionInterceptor)绑在一起。

当代理对象拦截到一个标了@Transactional的方法调用时,TransactionInterceptor的invoke方法被执行。它从MethodInvocation中取出目标类信息,然后委托给父类TransactionAspectSupport的invokeWithinTransaction方法处理。核心流程(省略了非关键分支):

scss 复制代码
// 创建事务
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);

Object retVal;
try {
    // 这里是一个环绕通知:调用拦截器链中的下一个拦截器
    // 最终会执行到目标方法
    retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
    // 异常处理:根据回滚规则决定回滚还是提交
    completeTransactionAfterThrowing(txInfo, ex);
    throw ex;
}
finally {
    cleanupTransactionInfo(txInfo);
}

// 正常返回:提交事务
commitTransactionAfterReturning(txInfo);
return retVal;

源码注释里有这么一句:This is an around advice。@Transactional用的就是环绕通知的模式。TransactionInterceptor在目标方法执行前开启事务,在正常返回后提交事务,在捕获到异常后根据rollbackFor规则决定回滚还是提交。

Transactional调用链流程图

理解了这个链路,@Transactional很多看似奇怪的行为就有了合理的解释:

  • 内部调用不生效:this.methodB()不走代理,TransactionInterceptor根本没有机会执行
  • 默认只回滚运行时异常:completeTransactionAfterThrowing里的判断逻辑,默认配置下只有RuntimeException和Error会触发回滚,受检异常不会
  • protected方法在Spring 6.0之前不生效:AnnotationTransactionAttributeSource默认只扫描public方法,Spring 6.0开始放宽了这个限制(构造参数传false表示接受非public方法)

理解了代理机制,@Transactional、@Async、@Cacheable这些注解的行为和限制就不再是需要背的条目,而是能从同一个机制推导出来的结论。

代理对象的创建过程

代理对象是在Bean初始化阶段创建的。

Spring Boot引入spring-boot-starter-aop后,AopAutoConfiguration自动生效,默认启用CGLIB代理。它会注册一个叫AnnotationAwareAspectJAutoProxyCreator的Bean后处理器,在每个Bean初始化完成后介入。

这个后处理器的核心逻辑在AbstractAutoProxyCreator的wrapIfNecessary方法里:把容器中所有的Advisor和@Aspect类解析出来,和当前Bean的方法做切入点匹配。匹配上了就创建代理对象替换原始Bean,没匹配上就原样返回。代理创建完成后,其他Bean通过@Autowired注入时,拿到的已经是代理对象了。

拦截器链的执行机制

代理对象拦截到方法调用后,具体发生了什么?

JDK动态代理为例,JdkDynamicAopProxy实现了InvocationHandler接口,所有方法调用都会进入它的invoke方法。invoke方法的核心逻辑是:先获取当前方法对应的拦截器链,如果链为空,直接用反射调用目标方法(这是一个性能优化)。如果有匹配的拦截器,构造一个ReflectiveMethodInvocation对象,把代理、目标、方法、参数和拦截器链全部封装进去,然后调用proceed()启动整个链的执行。

proceed()的实现是一个递归调用的过程:

kotlin 复制代码
public Object proceed() throws Throwable {
    // 所有拦截器都执行完了,调用目标方法
    if (this.currentInterceptorIndex ==
            this.interceptorsAndDynamicMethodMatchers.size() - 1) {
        return invokeJoinpoint();
    }

    // 取出下一个拦截器
    Object interceptorOrInterceptionAdvice =
            this.interceptorsAndDynamicMethodMatchers.get(
                    ++this.currentInterceptorIndex);

    // 执行拦截器,拦截器内部会再次调用proceed()
    return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
}

currentInterceptorIndex从-1开始,每次proceed()先递增索引,取出当前拦截器并执行。拦截器内部在执行完自己的前置逻辑后,调用proceed()把控制权交给链上的下一个拦截器。所有拦截器执行完毕后,调用invokeJoinpoint()执行目标方法本身。返回值沿着调用栈逆向传回,每个拦截器都有机会对返回值做后置处理。

用一个具体场景串联一下。假设AccountService的transfer方法同时被日志切面和事务切面拦截,拦截器链里有两个拦截器。调用过程是这样的:

代理.transfer() → 日志拦截器记录开始时间 → proceed() → 事务拦截器开启事务 → proceed() → 目标方法执行 → 返回结果 → 事务拦截器提交事务 → 返回结果 → 日志拦截器计算耗时并记录 → 返回最终结果

拦截器链执行流程图

这种递归调用结构保证了每个拦截器都能在目标方法执行前后插入自己的逻辑,而且拦截器之间彼此透明,不需要知道链上还有哪些其他拦截器。

JDK动态代理和CGLIB

Spring AOP有两种代理实现方式。代理类型的选择逻辑在DefaultAopProxyFactory的createAopProxy方法里:

arduino 复制代码
public AopProxy createAopProxy(AdvisedSupport config) {
    if (config.isOptimize() || config.isProxyTargetClass()
            || hasNoUserSuppliedProxyInterfaces(config)) {
        Class<?> targetClass = config.getTargetClass();
        if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)
                || ClassUtils.isLambdaClass(targetClass)) {
            return new JdkDynamicAopProxy(config);
        }
        return new ObjenesisCglibAopProxy(config);
    }
    else {
        return new JdkDynamicAopProxy(config);
    }
}

proxyTargetClass为true,或者目标类没有实现用户指定的接口时,选择CGLIB。如果目标类本身是接口、已经是JDK代理类或者是Lambda类,即使proxyTargetClass为true,也会退回到JDK动态代理。

两种代理方式的区别:

对比维度 JDK动态代理 CGLIB代理
实现方式 基于接口,用Proxy.newProxyInstance创建代理类 基于继承,在运行时生成目标类的子类
是否需要接口 需要,目标类必须实现接口 不需要
final类和final方法 不受影响(代理的是接口) 无法代理(子类无法重写)
Spring Boot默认 是(proxyTargetClass=true)

Spring Boot从2.0开始把proxyTargetClass的默认值改成了true。这是一个有实际背景的决定:早期很多项目的Service类没有接口定义,用JDK动态代理会因为缺少接口而导致代理创建失败。统一默认CGLIB后,不管目标类有没有接口都能正常代理,减少了开发者踩坑的概率。

在现代JVM上,两种代理方式的性能差异已经很小,不是选择的主要考量因素。绝大多数Spring Boot项目直接用默认的CGLIB就好。

Spring AOP和AspectJ

面试里经常被问到Spring AOP和AspectJ的区别。这两个是不同的AOP实现方案,设计目标和适用场景各有不同。

Spring AOP是运行时代理方案。它在Bean初始化阶段创建代理对象,通过代理拦截方法调用。回到门卫的比喻:门卫只管小区大门,住户家里发生什么他管不了。Spring AOP只能拦截Spring Bean的方法执行,不支持字段访问、构造方法、静态方法。

AspectJ是编译期/加载期字节码织入方案。它直接修改目标类的字节码,把切面逻辑编织到目标代码中。不需要代理对象,因为目标类本身的字节码已经被修改了。它支持方法执行、字段访问、构造方法、对象初始化等多种连接点类型。

对比维度 Spring AOP AspectJ
织入方式 运行时生成代理对象 编译期或加载期修改字节码
支持的连接点 方法执行 方法执行、字段访问、构造方法、静态方法等
是否需要特殊工具 不需要 需要ajc编译器或加载期织入Agent
是否依赖Spring容器 是,只能作用于Spring Bean 否,可以作用于任意Java对象
内部调用是否生效 不生效(代理的限制) 生效(字节码已被修改)
运行时性能开销 有代理调用的开销 无额外开销,织入在编译期完成

Spring AOP选择代理方案而不是字节码织入,是一个有意的设计取舍。代理方案和Spring容器的集成更自然,不需要额外的编译器插件,开发体验更简单。对绝大多数项目来说,方法级别的拦截完全够用。

需要AspectJ的场景确实存在:需要拦截非Spring Bean的对象、需要拦截字段访问或构造方法、对性能有极致要求(编译期织入没有运行时代理开销)。实际项目中也可以两者混用,Spring AOP处理日常的方法拦截,个别特殊需求用AspectJ补充。

AOP实战:两个高频场景

面试被问到「你在项目中用AOP做过什么」,如果只答事务管理,面试官不会满意,因为那是框架自带的,不算你的设计。下面两个场景是实际项目中最常见的自定义切面。

场景一:接口耗时日志

每个对外接口都需要记录调用日志:谁调的、参数是什么、执行了多长时间、成功还是失败。如果在每个Controller方法里手写log,代码重复且容易遗漏。用自定义注解+切面来做,方法上标一个注解即可。

先定义注解:

less 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiLog {
    // 接口描述,用于日志标识
    String value() default "";
}

切面实现:

java 复制代码
@Aspect
@Component
public class ApiLogAspect {

    private static final Logger log = LoggerFactory.getLogger(ApiLogAspect.class);

    @Around("@annotation(apiLog)")
    public Object around(ProceedingJoinPoint point, ApiLog apiLog) throws Throwable {
        String methodName = point.getSignature().toShortString();
        String desc = apiLog.value().isEmpty() ? methodName : apiLog.value();
        long start = System.currentTimeMillis();

        try {
            Object result = point.proceed();
            log.info("{} 执行成功, 耗时={}ms", desc, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable ex) {
            log.error("{} 执行异常, 耗时={}ms, 异常={}", desc,
                    System.currentTimeMillis() - start, ex.getMessage());
            throw ex;
        }
    }
}

使用时在方法上加一行注解:

less 复制代码
@ApiLog("转账")
@Transactional
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
    accountDao.deduct(fromAccount, amount);
    accountDao.increase(toAccount, amount);
}

这个切面的设计有几个值得注意的点。用@Around而不是@Before+@After,是因为需要计算方法的执行耗时,必须在同一个通知里拿到开始和结束时间。切入点用@annotation(apiLog)绑定注解参数,可以直接读取注解上的描述信息。异常捕获后必须重新throw,不能吞掉异常,否则会影响上层的@Transactional等切面的正常工作。

如果需要记录入参和返回值,可以通过point.getArgs()获取参数,对result做JSON序列化。生产环境建议对参数做脱敏处理,避免日志泄露敏感信息。

美团技术团队在操作日志这个场景上做了更深入的实践。他们的方案是自定义@LogRecord注解,结合SpEL表达式实现动态日志模板,通过AOP拦截器在方法执行前后解析模板并记录日志。感兴趣可以看美团技术博客这篇文章:tech.meituan.com/2021/09/16/...

场景二:接口权限校验

业务系统里不同的接口需要不同的权限,比如转账需要「account:transfer」权限,查询余额需要「account:query」权限。把权限校验逻辑分散在每个方法里,维护成本高且容易漏。用自定义注解+切面集中处理。

定义权限注解:

less 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
    // 需要的权限标识
    String value();
}

切面实现:

less 复制代码
@Aspect
@Component
public class PermissionAspect {

    @Autowired
    private PermissionService permissionService;

    @Before("@annotation(permission)")
    public void check(JoinPoint point, RequirePermission permission) {
        String userId = UserContext.getCurrentUserId();
        if (!permissionService.hasPermission(userId, permission.value())) {
            throw new AccessDeniedException("权限不足: " + permission.value());
        }
    }
}

使用方式:

less 复制代码
@RequirePermission("account:transfer")
@Transactional
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
    accountDao.deduct(fromAccount, amount);
    accountDao.increase(toAccount, amount);
}

这个切面用@Before而不是@Around,因为权限校验只需要在方法执行前做一次判断,不需要包裹目标方法的执行过程。校验不通过直接抛异常,方法不会被执行。

如果切面之间有执行顺序要求,比如权限校验要在事务开启之前执行(权限校验不通过就不要开启事务),可以在PermissionAspect上加@Order(1),给事务切面一个更大的Order值,确保权限切面先执行。

阿里巴巴开源的Sentinel限流框架也是类似的思路。它提供了@SentinelResource注解,通过SentinelResourceAspect切面拦截方法调用,在方法执行前检查是否触发了限流或熔断规则,触发则抛出BlockException阻止方法执行。这和上面的权限切面是同一个模式:自定义注解标记目标方法,切面统一拦截处理。Sentinel的源码在GitHub上:github.com/alibaba/Sen...

两个切面的共同模式

回头看这两个实战案例,它们遵循同一个设计模式:自定义注解做标记,切面做拦截,注解参数做配置。 这个模式在实际项目中非常高频。接口限流、幂等校验、分布式锁、操作审计,都可以用这个模式来实现。面试时能把这个模式讲清楚,并且说出你在项目中用它解决过什么问题,面试官对你的AOP掌握程度就不会有疑问了。

生产环境容易遇到的AOP问题

同类内部调用不走代理

这是AOP中被踩得最多的坑。AccountService里batchTransfer调用了this.transfer(),transfer上标了@Transactional。从外部调用batchTransfer时,batchTransfer走了代理,但内部的this.transfer()是直接调用,不经过代理,transfer的事务不会生效。

typescript 复制代码
@Service
public class AccountService {

    public void batchTransfer(List<TransferRequest> requests) {
        for (TransferRequest req : requests) {
            // this调用,不走代理
            this.transfer(req.getFromAccount(), req.getToAccount(), req.getAmount());
        }
    }

    @Transactional
    public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
        accountDao.deduct(fromAccount, amount);
        accountDao.increase(toAccount, amount);
    }
}

回到门卫的比喻:住户之间互相串门不经过门卫。

推荐的解决方式是把transfer挪到另一个Service里,通过@Autowired注入后调用,这样调用会经过代理。另一种方式是在启动类上加@EnableAspectJAutoProxy(exposeProxy = true),然后用AopContext.currentProxy()获取代理对象来调用,但这种做法会让业务代码和AOP框架产生耦合,不是首选。

@Transactional失效的常见场景

除了内部调用之外,@Transactional还有几个容易失效的场景:

方法不是public的。在Spring Boot 2.7(Spring Framework 5.3)及之前的版本中,AnnotationTransactionAttributeSource默认只扫描public方法,非public方法上的@Transactional会被忽略,不报错也不生效。Spring Framework 6.0放宽了这个限制,CGLIB代理下protected方法也能生效。

异常类型不匹配。@Transactional默认只在RuntimeException和Error时回滚。如果方法抛出的是受检异常(比如IOException),事务不会回滚。需要显式指定@Transactional(rollbackFor = Exception.class)来覆盖所有异常类型。

类没有被Spring管理。如果一个类没有加@Service、@Component这类注解,或者是通过new直接创建的对象,Spring不会为它创建代理,@Transactional自然不会生效。

@Async失效

@Async的底层也是AOP代理,和@Transactional的机制完全一致。内部调用时异步不会生效,方法必须是public的(Spring 6.0之前),目标类必须是Spring Bean。排查方式和@Transactional一样。

多个切面的执行顺序

当多个切面同时作用在一个方法上,执行顺序通过@Order注解或实现Ordered接口来控制。数值越小优先级越高。对于@Around和@Before类型的通知,优先级高的先执行。对于@After和@AfterReturning类型的通知,优先级高的后执行(因为它在拦截器链的外层)。

不显式指定@Order时执行顺序不确定。如果切面之间有依赖关系,比如安全检查必须在事务开启之前执行,必须用@Order显式指定。

切面范围过大

切入点表达式写得太宽泛会有性能影响。比如execution(* com.example..*.*(..))匹配了项目下所有类的所有方法,大量不需要拦截的方法也会经过拦截器链的匹配判断。虽然单次开销不大,但在高并发场景下累积起来会有可观的影响。切入点表达式应该尽量精确,只匹配真正需要拦截的方法。

小结

Spring生态里很多注解驱动的功能,底层都是AOP在支撑。@Transactional管理事务、@Async实现异步调用、@Cacheable处理缓存、@Retryable实现重试,它们共享同一套代理机制和拦截器链执行流程。把这套机制理解透了,遇到这些注解的各种失效问题,不需要去搜索引擎查答案,从代理的工作原理就能推断出原因。面试时能从这个角度去回答,比背条目要有说服力得多。

AOP代理的设计有一个隐性的代价:代码的执行流程变成了隐式的。方法上看不到任何痕迹,但执行时有额外的逻辑在运行。用在基础设施层面(事务、日志、监控、限流)时收益大于成本,因为这些逻辑本来就不该和业务耦合在一起。如果发现自己在用AOP处理业务规则,比如用切面做业务校验或数据转换,值得重新评估一下是不是选错了工具。

相关推荐
倾颜1 小时前
从手写 Runner 到 LangGraph:受控 Agent 接入 LangGraph
前端·后端·langchain
谁在黄金彼岸1 小时前
Lance模型解读
后端
神奇小汤圆2 小时前
深入理解MySQL事务隔离级别:MVCC机制与Next-Key Lock如何解决幻读问题?
后端
万少2 小时前
一封邮件,让我重新打开了搁置半年的鸿蒙应用
前端·javascript·后端
Java编程爱好者2 小时前
手把手看懂 Java 字节码:讲透 Integer 判等、静态方法重写与 try-finally 核心底层
后端
踏浪无痕2 小时前
k8s发布服务,nacos未服务未下线紧急处理流程
后端
TYKJ0232 小时前
物理安全:顶级机房为什么需要刷脸+指纹+工牌
后端
程序员黑豆2 小时前
AI全栈开发 - Java:注释
前端·后端·ai编程
小二·2 小时前
Spring Boot 3 + Vue 3 全栈开发实战
vue.js·spring boot·后端