跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

IoC 容器负责创建并连接 Bean,自动配置则按条件提供框架基础设施。有些能力还需要包围一次方法调用:调用前开启事务,正常返回后提交,失败时回滚;或者记录方法耗时,而不把计时代码复制到每个业务方法中。

Spring AOP 使用代理对象拦截这类 Bean 方法调用。本章通过心跳服务的计时案例理解切面、切点、通知、代理与目标对象,并提前说明代理边界为什么会影响后面的 @Transactional。

1. 横切关注点

业务方法应该首先表达业务流程:

HealthService.java
1
public HealthStatus check(String service) {
2
String normalizedService = normalizeService(service);
3
return new HealthStatus(properties.status(), normalizedService);
4
}

如果直接在每个方法内部添加耗时、事务或权限代码,业务流程会被重复基础设施逻辑包围:

duplicated-concerns.txt
1
Controller 方法:开始计时 -> 业务调用 -> 结束计时
2
Service 方法:开始计时 -> 业务处理 -> 结束计时
3
Repository 方法:开始计时 -> SQL 执行 -> 结束计时

这类会跨越多个类和方法的功能称为横切关注点。常见例子包括:

  • 事务边界;
  • 方法耗时与指标;
  • 权限检查;
  • 缓存;
  • 重试。

AOP 适合包装具有明确边界的通用行为,不适合隐藏正常业务步骤。HealthService 仍然应该显式调用 Repository,不能因为使用切面就让核心业务关系从代码中消失。

2. 切面术语

Spring AOP 中几个术语分别描述「在哪里」与「执行什么」:

术语含义当前案例
Aspect一组横切逻辑HealthTimingAspect
Join point可以被拦截的方法执行位置HealthService.check() 的执行
Pointcut选择哪些连接点匹配 HealthService 的 check 方法
Advice在匹配位置执行的逻辑调用前后记录耗时
Target真正包含业务实现的对象原始 HealthService
Proxy调用方实际拿到的包装对象容器生成的 HealthService 代理

一次代理调用可以表示为:

aop-invocation.txt
1
HealthController
2
-> HealthService 代理
3
-> 匹配切点
4
-> 执行环绕通知的前半段
5
-> 调用目标 HealthService.check()
6
-> 执行环绕通知的后半段
7
-> 返回 Controller

通知还可以只在调用前、正常返回后或抛出异常后运行。本章使用环绕通知,是因为计时需要同时覆盖方法开始与结束,并确保异常路径也能记录耗时。

3. 创建计时切面

项目需要加入 Spring Boot 4 提供的 AspectJ Starter。它包含 Spring AOP 使用 @Aspect 切点表达式所需的 AspectJ Weaver,并触发 AOP 自动配置:

pom.xml
1
<dependency>
2
<groupId>org.springframework.boot</groupId>
3
<artifactId>spring-boot-starter-aspectj</artifactId>
4
</dependency>

然后创建计时切面:

src/main/java/cn/usehook/spring/health/HealthTimingAspect.java
01
package cn.usehook.spring.health;
02
03
import org.aspectj.lang.ProceedingJoinPoint;
04
import org.aspectj.lang.annotation.Around;
05
import org.aspectj.lang.annotation.Aspect;
06
import org.slf4j.Logger;
07
import org.slf4j.LoggerFactory;
08
import org.springframework.stereotype.Component;
09
10
@Aspect
11
@Component
12
public class HealthTimingAspect {
13
14
private static final Logger log = LoggerFactory.getLogger(HealthTimingAspect.class);
15
16
@Around("execution(* cn.usehook.spring.health.HealthService.check(..))")
17
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
18
long startedAt = System.nanoTime();
19
20
try {
21
return joinPoint.proceed();
22
} finally {
23
long elapsed = System.nanoTime() - startedAt;
24
log.info("health_check_elapsed_ns={}", elapsed);
25
}
26
}
27
}

@Aspect 声明这个类包含切面规则,@Component 让组件扫描把它注册为 Bean。@Around 中的表达式匹配 HealthService 上名称为 check 的任意重载方法,(..) 表示参数数量和类型不限。

joinPoint.proceed() 才会继续执行目标方法。忘记调用它,相当于切面直接截断业务调用;调用多次则会让业务方法执行多次。这里在 finally 中记录耗时,因此正常返回和抛出异常都会执行日志代码,原异常仍然继续传播。

这个切面适合观察机制。生产指标通常应交给 Micrometer 记录直方图和标签,而不是只把每次耗时写成普通日志;后面的可观测性章节会继续处理。

4. 代理类型

Spring AOP 主要使用两种运行时代理:

代理方式基础特点
JDK 动态代理Java 接口代理实现相同接口,调用方应通过接口类型使用
类代理目标类的子类可以代理没有接口的类,但受 final 等继承规则限制

当前 HealthService 没有单独接口,因此 Spring Boot 默认的类代理方式可以包装它。Controller 构造参数仍然写 HealthService,容器实际注入的是可以赋给该类型的代理对象。

HealthController.java
1
private final HealthService healthService;
2
3
public HealthController(HealthService healthService) {
4
this.healthService = healthService;
5
}

是否为了使用代理而创建接口,要回到业务抽象本身判断。只有一个实现且没有替换边界的 Service 不需要机械增加接口;类代理能够处理这种情况。

类代理不能覆盖 final 方法,也不能通过继承代理 private 方法。Spring AOP 关注的是经过代理对象的 Bean 方法调用,不是改写任意 Java 指令。

5. 调用边界

代理只能拦截经过代理对象的调用。对象在自身方法中使用 this 调用另一个方法时,调用不会先返回外层代理:

self-invocation.java
1
public void checkAll() {
2
check("spring");
3
}
4
5
public HealthStatus check(String service) {
6
// 业务逻辑
7
}

外部调用代理的 checkAll() 后,内部的 check() 是目标对象自身调用。如果切点只匹配 check(),这次内部调用不会触发对应代理通知。

当前事务文章会让 Controller 直接调用带 @Transactional 的公开 check() 方法,并把复用逻辑放入不要求独立事务的私有方法。这样外部调用先经过代理,事务边界才会建立。

还要注意以下边界:

  • 使用 new HealthService(...) 手动创建的对象不由容器代理;
  • private 方法不能成为普通代理的外部调用入口;
  • 同类自调用不会重新经过代理;
  • 在构造方法中调用业务方法时,代理和 Bean 初始化可能尚未完成;
  • 捕获并吞掉异常会让事务等外层通知误以为方法正常返回。

因此,注解只是提供元数据。真正执行横切逻辑的是容器创建的代理、拦截器以及背后的事务管理器或缓存管理器。

6. 顺序与验证

同一个方法可能同时匹配计时、事务和其他切面。可以使用 @Order 或 Ordered 明确切面优先级;数值越小,通常越靠近调用链外层,返回时则越晚退出。

advice-order.txt
1
低 order 值切面:进入
2
-> 高 order 值切面:进入
3
-> 目标方法
4
-> 高 order 值切面:退出
5
低 order 值切面:退出

只有当先后顺序会改变语义时才显式设置,不要依赖未声明的偶然顺序。例如计时是否包含获取数据库连接的等待时间,要由监控目标决定。

启动应用并请求心跳接口,可以观察计时日志:

terminal.bash
1
curl http://localhost:8080/api/v1/health
terminal.txt
1
health_check_elapsed_ns=483920

如果日志没有出现,应依次确认切面是否位于组件扫描范围、AOP Starter 是否存在、切点是否匹配公开方法,以及调用是否真的经过容器注入的 Bean。

Spring AOP 的核心不是背诵切点表达式,而是分清调用方拿到的代理与真正执行业务的目标对象。后面的事务、缓存和监控都会建立在这个模型上。下一篇进入 Spring MVC,沿一次 HTTP 请求观察 Web 层如何找到 Controller 并转换参数与响应。