Java面向切面编程实践
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());
}
}
问题很明显:日志、耗时、异常处理这些与业务无关的代码混在一起。如果每个接口都这样写,会出现:
- 代码重复:每个 Controller 方法都要复制一遍日志和异常处理
- 侵入性强:改日志格式要改所有方法
- 难以维护:加一个新功能(如链路追踪)要改所有方法
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,原因:
- 精确控制:想拦谁就给谁加注解
- 可读性好:看注解就知道这个方法有特殊处理
- 可传参:注解能携带配置(如
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 方法不生效
动态代理无法重写 final 和 static 方法,切面对它们无效。
坑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 只适合横切关注点)
- 需要与业务强耦合的逻辑(如根据不同业务参数走不同分支)
- 性能极致敏感的热点路径(反射开销虽小但不为零)
最佳实践
- 切面职责单一:一个切面只做一件事(日志切面只记日志,不要混入限流)
- 用注解驱动:
@annotation比全包扫描更精确、更可控 - 异常要抛出:切面不要吞异常,否则破坏原有异常处理链
- 加开关:生产可能需要临时关闭,用配置开关比删代码安全
- 异步化重操作:切面里的网络请求、IO 操作一定要异步
本文所有代码均来自真实生产项目,GitHub 可查。 AOP 是 Spring 生态的基石之一,掌握它能让你的代码更优雅、更易维护。下次遇到"每个方法都要写一遍"的重复代码时,第一反应应该是:这能不能用 AOP 解决?
更多推荐



所有评论(0)