讲透面向切面编程 AOP

内容分享1个月前发布
0 0 0

我把 AOP(面向切面编程) 在 Spring Boot 里的方方面面讲透:从概念、实现原理、常见切点表达式、Advice 类型、与事务/代理的交互,到常见坑、调试与生产实践。篇幅会比较长,但尽量条理清楚、实用可复制。

核心概念(一句话)

AOP 的目的就是把“横切关注点”(日志、事务、权限、限流、埋点、监控等)从业务代码中分离出来,通过“切面(Aspect)”在运行时把这些代码织入到目标方法的执行流程中,从而实现横向复用与解耦。


为什么用 AOP?常见用途

  • 日志记录(入口/出口/耗时/参数)
  • 事务管理(在 Spring 中一般由 AOP 实现)
  • 权限/鉴权检查
  • 重试/限流/熔断(或配合 Resilience4j)
  • 性能埋点 / 指标上报(Micrometer)
  • 缓存切面(缓存读取/更新/失效)
  • 事件发布 / 异常统一处理

Spring AOP vs AspectJ(重大的选择)

  • Spring AOP(默认)
    • 实现方式:基于代理(proxy-based),Spring 在运行时为目标对象生成代理并在代理里织入 advice。
    • 支持的 join point:仅限方法执行(method execution)
    • 适用场景:绝大多数 Web/Service 场景,轻量、简单配置(spring-boot-starter-aop)。
    • 优点:集成简单、对性能影响小、与 Spring 容器紧密集成。
    • 限制:不能拦截 private/final 方法(proxy 限制),无法拦截构造器、字段访问等。
  • AspectJ(完整 AOP)
    • 实现方式:编译时织入 (CTW)、后编译织入 或 加载时织入 (LTW)(需要 aspectjweaver)。
    • 支持的 join point:超级全面(方法、构造器、字段、异常处理点等)。
    • 适用场景:需要拦截 private 方法、构造器、字段访问,或必须避免代理带来的自调用问题时。
    • 成本:配置复杂些、可能需要 -javaagent 或编译时支持。

实务提议:先用 Spring AOP,只有当的确 需要拦截 private/构造/字段或必须要在类内部也被织入时,才思考 AspectJ。


Advice(通知)类型(行为与用法)

  1. @Before:在目标方法执行前执行(不能修改目标方法返回值)。
  2. @AfterReturning:目标方法正常返回后执行(可以拿到返回值)。
  3. @AfterThrowing:目标方法抛异常时执行(可以拿到异常)。
  4. @After(finally):目标方法执行结束后执行(无论正常或异常)。
  5. @Around:最强劲的类型,可以在目标方法前后环绕、决定是否执行目标方法、修改参数与返回值(需要 ProceedingJoinPoint.proceed())。

注意:@Around 可以替代 @Before/@After 的许多场景,但写 @Around 时要小心异常传播和 finally 逻辑。

示例(@Around):

@Around("execution(public * com.example..service..*(..))")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
    long t0 = System.currentTimeMillis();
    try {
        Object result = pjp.proceed();
        return result;
    } finally {
        long t1 = System.currentTimeMillis();
        log.info("{} executed in {} ms", pjp.getSignature(), (t1 - t0));
    }
}

Pointcut(切点)表达式实战

常用语法与示例:

  • execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern) throws-pattern?)
    • 例:execution(public * com.example.service.*.*(..)) —— 所有 com.example.service 包下 public 方法
  • within(typePattern):限制在某个类/包内
    • within(com.example..controller..*)
  • args(…):根据运行时参数类型匹配
    • args(java.lang.String, ..)
  • @annotation(…):方法上有某注解
    • @annotation(org.springframework.transaction.annotation.Transactional)
  • @within(…):类上有注解
  • bean(beanName):根据 Spring bean 名称匹配
  • target(type) / this(type):target 表明目标对象类型,this 表明代理对象类型(proxy)
  • 复合写法:
    • @Pointcut(“execution(* com.example..*(..)) && @annotation(com.example.Loggable)”)

注意:

  • execution 的匹配是基于方法签名(编译时),args 是基于运行时参数类型。
  • @annotation 超级方便:自定义注解 + 基于注解切面是最常见的实践(显式、可读)。

Spring AOP 的实现细节(代理类型、开启方式)

启用 AOP

@SpringBootApplication
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
public class DemoApplication { ... }
  • proxyTargetClass = true:强制使用 CGLIB(类代理),即使目标有接口。否则默认优先用 JDK 动态代理(若有接口)。
  • exposeProxy = true:允许在运行时通过 AopContext.currentProxy() 获取当前代理(用于解决自调用问题的一种方法)。

代理类型区别

  • JDK 动态代理:生成实现目标接口的代理类。只有通过接口访问的方法会被拦截,目标类没有接口则不能用。
  • CGLIB(子类代理):通过继承目标类创建子类并覆盖方法(目标方法不能是 final)。可拦截更多场景(没有接口的类)。

能被拦截的方法

  • 代理必须处理“通过代理对象调用”的方法。因此:
    • 外部通过代理调用 public/protected(非 final)方法会触发 advice(CGLIB);
    • JDK 代理只能拦截接口声明的方法;
    • 私有方法、final 方法、类构造器、类初始化块不能被 Spring AOP 拦截(除非用 AspectJ)。

常见坑与“自调用”问题(最容易踩的)

场景:

@Service
public class AService {
    @Transactional
    public void outer() {
        // ...
        inner(); // <-- 直接调用,不会走代理 => inner 上的 @Transactional/@Aspect 都不会被触发
    }

    @Transactional
    public void inner() { ... }
}

缘由:this.inner() 是在同一实例内直接调用,没有经过代理,因此 AOP 拦截器不会触发(事务、切面都绕过)。

解决方案(按难易/推荐顺序):

  1. 把 inner 提取到另一个 Bean(最推荐):OtherService.inner(),通过 Spring 注入并调用,这样能走代理。
  2. 通过 AopContext.currentProxy() 使用代理来调用(需设置 exposeProxy=true):((AService) AopContext.currentProxy()).inner()
  3. 使用 AspectJ 的编织(AspectJ LTW / CTW):不依赖代理,能织入类内部调用。
  4. 重构设计:思考方法是否应为公共/独立职责,尽量避免需要自调用触发切面。

Advice 顺序(@Order)和嵌套 @Around 的行为

  • 在多个切面/Advice 同时匹配一个 join point 时,@Order(或实现 Ordered)控制优先级:数值越小越优先(越“外层”)
  • 对于多个 @Around,假设有 A(order=1) 和 B(order=2),它们的调用顺序是:
    • A 的 @Around 先执行(外层),在 A 的 proceed() 中调用 B 的 @Around,最后到目标方法。
  • 这种嵌套会影响异常的传播与处理(外层可以捕获并处理异常,阻止内层看到异常)。

AOP 与 @Transactional 的交互要点

  • Spring 的事务支持本质上就是通过 AOP 拦截实现的(事务是个切面)。因此 @Transactional 也受代理限制(同样会有自调用问题)。
  • 顺序问题:若自定义切面与事务切面都存在,要注意 @Order 排序(例如日志切面一般希望在事务外层或内层,依据需求):
    • 若日志切面在事务外层(order 小),日志能记录事务开始前后的情况;若在事务内层(order 大),日志能看到事务已开启的上下文(例如 DB 连接已绑定)。
  • 如果你需要确保事务在某些切面之前开启,设置切面的 @Order 值较大以保证事务先被套上或反之,视具体逻辑。

常见的切面模式(实战模板)

  1. 基于注解的切面(最推荐):定义 @TrackTime / @Loggable 自定义注解,然后切 @annotation(…)。
  2. 基于包/类模式的切面:execution(* com.example.service..*(..))。
  3. Controller 层切面:聚焦处理请求日志、参数脱敏、接口耗时、异常映射(注意不要在 Controller 切面做阻塞或长耗时逻辑)。
  4. 异常统一处理 + 报警:@AfterThrowing 捕捉异常并发出告警(但应确保不要在生产切面里做阻塞行为)。
  5. 指标/埋点切面:在切点中记录 Micrometer 指标,如计时器 Timer、计数器 Counter。

调试技巧(快速排查 AOP 问题)

  • 检查 bean 是否被代理:
import org.springframework.aop.support.AopUtils;
AopUtils.isAopProxy(bean); // true/false
AopUtils.isCglibProxy(bean);
AopUtils.isJdkDynamicProxy(bean);
  • 打开 Spring AOP debug 日志:logging.level.org.springframework.aop=DEBUG。
  • 在切面里打印 pjp.getSignature()、pjp.getTarget().getClass()、Proxy 类名等。
  • 若切面没被触发,确认调用路径是否走了代理(外部调用 vs 自调用)以及 pointcut 是否准确匹配。
  • 使用单元测试 @SpringBootTest + @SpyBean 监视切面的执行。

最小可运行示例(Spring Boot)

下面给出一个超级实用的最小 demo:包含 pom.xml、启动类、一个 Service、一个控制器、两个切面(日志 + 注解式耗时统计)、以及如何解决自调用问题的演示。

1) pom.xml(核心依赖)

<!-- 省略 parent/版本管理,只列关键依赖 -->
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
  </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

2) 启动类(开启 exposeProxy 以便示例里演示)

@SpringBootApplication
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
public class DemoApplication {
    public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }
}

3) 自定义注解(用于标记方法埋点)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TrackTime {}

4) 日志切面(通用)

@Aspect
@Component
@Order(20)
public class LoggingAspect {
    private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);

    @Pointcut("execution(public * com.example.demo.service..*(..))")
    public void serviceMethods() {}

    @Around("serviceMethods()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        String name = pjp.getSignature().toShortString();
        Object[] args = pjp.getArgs();
        log.info("Enter {} args={}", name, Arrays.toString(args));
        long t0 = System.currentTimeMillis();
        try {
            Object r = pjp.proceed();
            log.info("Exit {} return={} time={}ms", name, r, System.currentTimeMillis() - t0);
            return r;
        } catch (Throwable ex) {
            log.error("Exception in {} : {}", name, ex.toString());
            throw ex;
        }
    }
}

5) 注解式耗时切面(只对带 @TrackTime 的方法)

@Aspect
@Component
@Order(10)
public class TrackTimeAspect {
    private static final Logger log = LoggerFactory.getLogger(TrackTimeAspect.class);

    @Pointcut("@annotation(com.example.demo.annotation.TrackTime)")
    public void annotatedMethods() {}

    @Around("annotatedMethods()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long t0 = System.nanoTime();
        try {
            return pjp.proceed();
        } finally {
            long t1 = System.nanoTime();
            log.info("{} took {} ms", pjp.getSignature().toShortString(), (t1 - t0) / 1_000_000);
        }
    }
}

6) Service 示例(演示自调用问题与解决)

public interface MyService {
    String hello(String name);
    String compCall(String name);
}

@Service
public class MyServiceImpl implements MyService {

    @Override
    @TrackTime
    public String hello(String name) {
        return "Hello " + name;
    }

    @Override
    public String compCall(String name) {
        // 直接自调用(下面的 hello 方法带 @TrackTime)
        // this.hello(name); // <- 这样不会走切面(自调用)
        // 正确做法:通过代理调用
        MyService proxy = (MyService) AopContext.currentProxy();
        return proxy.hello(name); // 需要在 @EnableAspectJAutoProxy 设置 exposeProxy=true
    }
}

7) Controller

@RestController
@RequestMapping("/api")
public class DemoController {
    private final MyService myService;
    public DemoController(MyService myService) { this.myService = myService; }

    @GetMapping("/hello")
    public String hello(@RequestParam(defaultValue = "world") String name) {
        return myService.hello(name);
    }

    @GetMapping("/comp")
    public String comp(@RequestParam(defaultValue = "world") String name) {
        return myService.compCall(name);
    }
}

启动并访问 /api/hello?name=Ray 将会触发 TrackTimeAspect;访问 /api/comp 展示自调用通过 AopContext 触发切面(注意必须启用 exposeProxy=true)。


单元测试切面(思路)

  • 用 @SpringBootTest 启动容器,@SpyBean 注入切面实例,然后 verify() 切面方法被调用。
  • 示例:
@SpringBootTest
public class AspectTest {
    @Autowired MyService myService;
    @SpyBean TrackTimeAspect trackTimeAspect;

    @Test
    void aspectInvoked() {
        myService.hello("t");
        verify(trackTimeAspect, times(1)).around(any(ProceedingJoinPoint.class));
    }
}

注意:around 方法是一个普通方法(非 final)且切面是 Spring 管理的 bean,因此 @SpyBean 能 spy。


生产环境最佳实践(要点速查)

  1. 尽量使用基于注解的切点(显式可读,不会误伤)
  2. 避免在切面里做长耗时或阻塞操作(影响主业务线程),改为异步/消息
  3. 不要用过宽的 pointcut(如 execution(* *(..))),会带来性能和维护问题
  4. 切面中尽量不要抛出未被处理的异常(这会影响业务异常流)
  5. 注意切面调用顺序(@Order),特别是与事务、鉴权相关的切面
  6. 测试覆盖切面场景(外部调用、自调用、异常路径)
  7. 监控切面开销(用 metrics 记录切面耗时,避免在高 QPS 热点方法上做重操作)
  8. 代码可见性:把切点逻辑写清楚(注释、单元测试文档)以便别人理解切面会在哪些地方生效

常见问题快速答案

  • 为什么我的 @Transactional 不生效? 多数缘由是自调用导致没有走代理;解决见上文“自调用”部分。
  • 为什么切面没有被触发? 检查 pointcut 是否匹配、调用是否通过代理、方法是否为 private/final、切面是否在 Spring 容器中。
  • 如何拦截 private 方法? Spring AOP 拦截不了 private;除非改用 AspectJ(CTW/LTW)。
  • 如何确认 bean 是否被代理? 使用 AopUtils.isAopProxy(bean) 或打印 bean.getClass() 看是否为 CGLIB 生成的类(含 $$EnhancerByCGLIB$$)。

总结(要点回顾)

  • AOP 是解耦横切逻辑的利器,Spring AOP 用代理实现、简单高效;AspectJ 提供更强劲的织入能力。
  • 重点掌握:pointcut 写法、各类 advice 行为、代理实现(JDK/CGLIB)与自调用限制、与事务的交互,以及切面顺序。
  • 生产上提议:使用注解驱动的切点、保持切面轻量、严格测试与监控切面性能。
© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...