Java 面向切面编程实践

本文以一个真实的广告服务项目(Spring Boot 3.2 + JDK 21)为背景,从零到一演示如何用 AOP 解决日志记录、异常处理、链路追踪三大横切关注点。所有代码均来自生产实践,可直接复用。

一、为什么需要 AOP

先看一段没有 AOP 的 Controller 代码:

@GetMapping("/vast")
public ResponseEntity<String> getVast(@ParameterObject CommonReq req) {
    // ① 记录请求日志
    log.info("收到请求, appId:{}, signId:{}", req.getAppId(), req.getSignId());
    long start = System.currentTimeMillis();
    try {
        // ② 业务逻辑
        AdResourceDTO resolved = vastService.resolveAdResource(req.getSignId(), req.getAppId());
        String vastXml = vastService.generateVastXml(resolved, req);
        // ③ 记录耗时
        log.info("处理完成, 耗时:{}ms", System.currentTimeMillis() - start);
        return ResponseEntity.ok().body(vastXml);
    } catch (Exception e) {
        // ④ 异常处理
        log.error("处理异常, error:{}", e.getMessage(), e);
        return ResponseEntity.ok().body(emptyVastXml());
    }
}

问题很明显:日志、耗时、异常处理这些与业务无关的代码混在一起。如果每个接口都这样写,会出现:

  1. 代码重复:每个 Controller 方法都要复制一遍日志和异常处理
  2. 侵入性强:改日志格式要改所有方法
  3. 难以维护:加一个新功能(如链路追踪)要改所有方法

AOP(Aspect-Oriented Programming,面向切面编程)就是用来解决这个问题的——把横切关注点(日志、事务、安全等)从业务逻辑中抽离出来,统一管理。

二、AOP 核心概念

概念 通俗解释 项目中的例子
Aspect(切面) 一个类,装着横切逻辑 KafkaTraceAspect
Join Point(连接点) 能被拦截的点(方法执行) Controller 的每个方法
Pointcut(切点) 拦截哪些方法(规则) @annotation(kafkaTrace)
Advice(通知) 什么时候做什么 @Around(环绕)、@Before@After
Target(目标对象) 被拦截的对象 VastController
Weaving(织入) 把切面套到目标上 Spring 运行时动态代理

五种通知类型

@Before("pointcut()")      // 方法执行前
@After("pointcut()")       // 方法执行后(无论成功失败)
@AfterReturning("pointcut()")  // 方法成功返回后
@AfterThrowing("pointcut()")   // 方法抛异常后
@Around("pointcut()")      // 环绕(最强大,能控制是否执行、修改参数和返回值)

实际开发中 @Around 用得最多,因为它能同时拿到方法执行前后的状态。

三、实战一:全局异常处理

3.1 问题

每个 Controller 方法都要 try-catch,返回结构不统一:

// 方法A:返回 null
// 方法B:返回空字符串
// 方法C:返回 error JSON
// 方法D:抛 500

3.2 方案

@RestControllerAdvice 统一捕获异常,转换为统一响应结构。

3.3 实现

自定义业务异常

@Getter
public class BusinessException extends RuntimeException {
    private final int code;
    private final transient Object data;

    public BusinessException(ResultCode resultCode) {
        super(resultCode.getMessage());
        this.code = resultCode.getCode();
        this.data = null;
    }
}

全局异常处理器

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e, HttpServletRequest request) {
        log.warn("业务异常, uri:{}, code:{}, message:{}",
                request.getRequestURI(), e.getCode(), e.getMessage());
        return Result.error(e.getCode(), e.getMessage());
    }

    // 参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e,
                                                      HttpServletRequest request) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ": " + f.getDefaultMessage())
                .collect(Collectors.joining("; "));
        log.warn("参数校验失败, uri:{}, message:{}", request.getRequestURI(), message);
        return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
    }

    // 兜底异常
    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e, HttpServletRequest request) {
        log.error("系统异常, uri:{}, message:{}", request.getRequestURI(), e.getMessage(), e);
        return Result.error(ResultCode.INTERNAL_SERVER_ERROR);
    }
}

3.4 效果

Controller 可以这样写,完全不用管异常:

@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
    User user = userService.getById(id);
    if (user == null) {
        throw new BusinessException(ResultCode.USER_NOT_EXISTS);
    }
    return Result.ok(user);
}

无论抛什么异常,前端收到的都是统一格式:

{ "code": 40005, "message": "用户不存在", "data": null }

四、实战二:自定义注解 + 环绕通知

4.1 场景

广告服务需要在每个接口执行后,把请求参数、处理结果、耗时发送到 Kafka,供下游统计分析。

要求:

  • 业务代码零侵入
  • 可通过开关控制开关
  • 只发一次(方法执行后),包含请求参数和结果

4.2 第一步:定义注解

注解是切点的"标记",打在哪个方法上,就拦截哪个方法。

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

    /**
     * 业务类型
     */
    String businessType();

    /**
     * 响应事件 Topic(默认广告响应 Topic)
     */
    String responseTopic() default Constant.KAFKA_TOPIC_AD_RESPONSE;
}

关键点

  • @Target(METHOD):只能打在方法上
  • @Retention(RUNTIME):运行时保留(否则切面拿不到)

4.3 第二步:定义切面

切面是核心,包含三部分:切点、通知、横切逻辑。

@Slf4j
@Aspect
@Component
public class KafkaTraceAspect {

    @Resource
    private KafkaProducerService kafkaProducerService;

    @Value("${spring.application.name}")
    private String applicationName;

    /**
     * 开关:本地测试设为 false 跳过发送
     */
    @Value("${kafka.trace.enabled:true}")
    private boolean traceEnabled;

    /**
     * 环绕通知
     */
    @Around("@annotation(kafkaTrace)")
    public Object around(ProceedingJoinPoint joinPoint, KafkaTrace kafkaTrace) throws Throwable {
        // 开关关闭时直接执行,不发送 Kafka 事件
        if (!traceEnabled) {
            return joinPoint.proceed();
        }

        // 1. 方法执行前:收集请求信息
        String traceId = UUID.randomUUID().toString().replace("-", "");
        long requestTime = System.currentTimeMillis();
        CommonReq commonReq = extractCommonReq(joinPoint.getArgs());
        HttpServletRequest httpReq = getCurrentRequest();

        // 2. 执行业务方法
        long start = System.currentTimeMillis();
        Object result = null;
        Throwable error = null;
        try {
            result = joinPoint.proceed();
            return result;
        } catch (Throwable t) {
            error = t;
            throw t;  // 异常继续抛出,不影响原有流程
        } finally {
            // 3. 方法执行后:发送响应事件(含请求参数&结果)
            long costMs = System.currentTimeMillis() - start;
            sendResponseEvent(kafkaTrace, traceId, requestTime, httpReq,
                    commonReq, result, error, costMs);
        }
    }
}

逐行解析

代码 作用
@Around("@annotation(kafkaTrace)") 环绕通知,拦截带 @KafkaTrace 注解的方法,注解值绑定到参数 kafkaTrace
joinPoint.proceed() 执行原方法,返回值是原方法的返回值
joinPoint.getArgs() 获取方法入参
throws Throwable 必须抛出,否则吞了异常会导致全局异常处理器失效
finally 保证无论成功失败都发送事件

4.4 第三步:使用注解

在 Controller 方法上打注解,业务代码完全不用改:

@Operation(summary = "获取VAST 3.0 XML")
@GetMapping(value = "/vast", produces = MediaType.APPLICATION_XML_VALUE)
@KafkaTrace(businessType = "VAST")  // ← 就这一行
public ResponseEntity<String> getVast(@ParameterObject CommonReq req) {
    // 纯业务逻辑,没有任何日志/追踪代码
    AdResourceDTO resolved = vastService.resolveAdResource(req.getSignId(), req.getAppId());
    String vastXml = vastService.generateVastXml(resolved, req);
    return ResponseEntity.ok().body(vastXml);
}

4.5 效果

方法执行后,Kafka 收到一条消息:

{
  "traceId": "a1b2c3d4e5f6",
  "source": "yt-iov-advert",
  "timestamp": 1719033600000,
  "requestUri": "/advert/ad/vast",
  "httpMethod": "GET",
  "clientIp": "1.2.3.4",
  "requestTime": 1719033599955,
  "appId": "app001",
  "signId": "user001",
  "success": true,
  "costMs": 45,
  "campaignId": "c123",
  "campaignName": "夏季促销"
}

五、切点表达式详解

切点表达式决定了"拦截哪些方法",这是 AOP 最容易出错的地方。

5.1 常用切点表达式

// 1. 拦截某个类的所有方法
@Around("execution(* cn.radio.iov.controller.VastController.*(..))")

// 2. 拦截某个包下所有类的所有方法
@Around("execution(* cn.radio.iov.controller..*.*(..))")

// 3. 拦截带特定注解的方法(最常用)
@Around("@annotation(kafkaTrace)")

// 4. 拦截带特定注解的类的所有方法
@Within(org.springframework.web.bind.annotation.RestController)

// 5. 组合切点(AND / OR / NOT)
@Around("@annotation(kafkaTrace) && within(cn.radio.iov.controller..*)")

5.2 execution 表达式语法

execution(修饰符? 返回类型 包名.类名.方法名(参数类型) 异常?)

示例:

表达式 含义
execution(* *(..)) 所有方法
execution(public * *(..)) 所有 public 方法
execution(* save*(..)) 所有以 save 开头的方法
execution(* *..Service.*(..)) 所有 Service 类的方法
execution(* *(String, ..)) 第一个参数是 String 的方法

5.3 推荐实践

优先用 @annotation,原因:

  1. 精确控制:想拦谁就给谁加注解
  2. 可读性好:看注解就知道这个方法有特殊处理
  3. 可传参:注解能携带配置(如 businessType

六、ProceedingJoinPoint 常用 API

ProceedingJoinPoint@Around 通知的核心参数,提供了操控原方法的能力:

@Around("@annotation(kafkaTrace)")
public Object around(ProceedingJoinPoint joinPoint, KafkaTrace kafkaTrace) throws Throwable {
    // 获取目标方法信息
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    String methodName = method.getName();
    Class<?>[] paramTypes = signature.getParameterTypes();

    // 获取方法参数
    Object[] args = joinPoint.getArgs();
    String[] paramNames = signature.getParameterNames();

    // 获取目标对象
    Object target = joinPoint.getTarget();
    Class<?> targetClass = target.getClass();

    // 执行原方法(可以修改 args 后再执行)
    Object result = joinPoint.proceed();
    // 或者:joinPoint.proceed(newArgs);  // 用新参数执行

    // 修改返回值
    return result;
}

七、常见踩坑记录

坑1:异常被吞,全局异常处理器失效

错误写法

@Around("@annotation(kafkaTrace)")
public Object around(ProceedingJoinPoint joinPoint, KafkaTrace kafkaTrace) {
    try {
        return joinPoint.proceed();
    } catch (Throwable t) {
        log.error("异常", t);
        return null;  // ← 异常被吞了!GlobalExceptionHandler 收不到
    }
}

正确写法

@Around("@annotation(kafkaTrace)")
public Object around(ProceedingJoinPoint joinPoint, KafkaTrace kafkaTrace) throws Throwable {
    try {
        return joinPoint.proceed();
    } catch (Throwable t) {
        // 记录日志后继续抛出
        log.error("异常", t);
        throw t;
    }
}

坑2:内部方法调用不生效

Spring AOP 基于动态代理,只有外部调用经过代理对象才会触发切面

@Service
public class UserService {

    public void methodA() {
        methodB();  // ← 内部调用,不经过代理,切面不生效!
    }

    @KafkaTrace(businessType = "USER")
    public void methodB() { ... }
}

解决方案

// 方案1:注入自己(推荐)
@Service
public class UserService {
    @Resource
    private UserService self;  // Spring 支持注入自己

    public void methodA() {
        self.methodB();  // 经过代理,切面生效
    }
}

// 方案2:从 ApplicationContext 获取
applicationContext.getBean(UserService.class).methodB();

坑3:final / static 方法不生效

动态代理无法重写 finalstatic 方法,切面对它们无效。

坑4:循环依赖

切面类如果被目标对象依赖,可能触发循环依赖。切面类不要注入业务 Service,保持单向依赖。

八、切面执行顺序

当多个切面同时作用一个方法时,执行顺序遵循"洋葱模型":

切面A @Before
  → 切面B @Before
    → 切面C @Before
      → 目标方法执行
    → 切面C @After
  → 切面B @After
→ 切面A @After

可以用 @Order 控制顺序:

@Order(1)   // 数字越小越先执行(外层)
@Aspect
public class LogAspect { ... }

@Order(2)
@Aspect
public class KafkaTraceAspect { ... }

九、性能考量

AOP 的性能开销

环节 开销
代理对象创建 启动时一次性,可忽略
方法调用经代理 纳秒级,可忽略
反射获取参数 微秒级,高频调用需注意
切面逻辑本身 取决于你的代码

结论:AOP 本身性能开销极小,瓶颈通常在切面里的业务逻辑(如发 HTTP 请求、写数据库)。

优化建议

// 1. 异步化耗时操作
kafkaProducerService.sendAsync(topic, event);  // 异步发送,不阻塞接口

// 2. 开关控制
if (!traceEnabled) {
    return joinPoint.proceed();  // 开关关闭时直接返回,零开销
}

// 3. 避免在切面中做重计算
// 错误:在切面里序列化大对象
// 正确:只收集必要信息,异步处理

十、总结

AOP 适用场景

场景 推荐通知 示例
日志记录 @Around 记录入参、出参、耗时
异常处理 @AfterThrowing / @RestControllerAdvice 统一异常转换
权限校验 @Before 检查登录状态
链路追踪 @Around 生成 traceId,发送追踪事件
缓存 @Around 命中缓存直接返回
限流 @Around 超过阈值拒绝请求

不适用 AOP 的场景

  • 核心业务逻辑(AOP 只适合横切关注点)
  • 需要与业务强耦合的逻辑(如根据不同业务参数走不同分支)
  • 性能极致敏感的热点路径(反射开销虽小但不为零)

最佳实践

  1. 切面职责单一:一个切面只做一件事(日志切面只记日志,不要混入限流)
  2. 用注解驱动@annotation 比全包扫描更精确、更可控
  3. 异常要抛出:切面不要吞异常,否则破坏原有异常处理链
  4. 加开关:生产可能需要临时关闭,用配置开关比删代码安全
  5. 异步化重操作:切面里的网络请求、IO 操作一定要异步

本文所有代码均来自真实生产项目,GitHub 可查。 AOP 是 Spring 生态的基石之一,掌握它能让你的代码更优雅、更易维护。下次遇到"每个方法都要写一遍"的重复代码时,第一反应应该是:这能不能用 AOP 解决?

更多推荐