我给订单服务定过一个小目标:每个接口统计耗时、关键操作留审计、下单支付防重复提交。这三件事都不难,难在它们跟业务一点关系都没有。没有切面的写法是把它们复制进每个方法:System.currentTimeMillis() 抬头低头各一次,审计字段在返回前手动塞库,幂等靠前端按钮置灰。我见过三个月后的下场:没人说得清哪些接口漏了计时,审计表里缺哪天的行,重复提交打穿库存的客诉截图还挂在群里。这三件事的共性是它们不属于任何业务方法,属于「所有方法都要过一遍」的横切关注点,切面正是为这类代码准备的。
这篇我把整套方案在 Spring Boot 4.1.1 上落了一遍:订单服务带四个切面,按 @Order 排成洋葱链,计时用 @within、链路跟踪用 execution、审计用 @annotation、幂等用注解加指纹。审计落的是真实 MySQL,幂等拒绝走 RFC 9457 返回 409,@Around 的两个经典坑我各准备了一个最小复现。文中的数字全部来自同一次运行,验证脚本的原始记录我都留了底,你可以放心对着抄。
编程模型与自动配置的事实
写代码之前我先把坐标和自动配置确认了一遍,做法笨但扎实:从 Central 把制品解包,再用字节码工具翻一遍。
第一件事就推翻了我的记忆。说实话,starter 改名这事我一开始是不信的,直到亲手解包才认:spring-boot-starter-aop 在 Central 的 4.1.1 目录里 404,现行坐标是 spring-boot-starter-aspectj。组成(POM 实测):
1
2
3
spring-boot-starter-aspectj = spring-boot-starter + org.springframework:spring-aop:7.0.9
+ org.aspectj:aspectjweaver:1.9.25.1
spring-boot-starter-aspectj-test = spring-boot-starter-aspectj + spring-boot-starter-test
两个细节值得记住。aspectjweaver 的版本由 BOM 属性 aspectj.version=1.9.25.1 管理,同一属性管 aspectjrt 与 aspectjtools,工程里不用写版本号。starter 只带 weaver 不带 aspectjrt:纯 Spring 代理模型在运行期用 weaver 里的 @Aspect 注解就够了,不需要 AspectJ 编译器参与编译。
spring-boot-aop 这样的独立模块不存在(Central 404 实证),自动配置类仍在 spring-boot-autoconfigure 里:org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,登记在该模块的 AutoConfiguration.imports。条件结构用 javap 解出来是三层:
1
2
3
4
5
6
7
8
9
10
AopAutoConfiguration
@ConditionalOnBooleanProperty(name="spring.aop.auto", matchIfMissing=true)
├─ AspectJAutoProxyingConfiguration @ConditionalOnClass(org.aspectj.weaver.Advice)
│ ├─ CglibAutoProxyConfiguration
│ │ @EnableAspectJAutoProxy(proxyTargetClass=true)
│ │ @ConditionalOnBooleanProperty(name="spring.aop.proxy-target-class", matchIfMissing=true)
│ └─ JdkDynamicAutoProxyConfiguration
│ @EnableAspectJAutoProxy(proxyTargetClass=false)
│ @ConditionalOnBooleanProperty(name="spring.aop.proxy-target-class", havingValue=false)
└─ ClassProxyingConfiguration @ConditionalOnMissingClass("org.aspectj.weaver.Advice")
类路径上有 aspectjweaver(引 starter 即有),走上半支:自动配置替工程贴上 @EnableAspectJAutoProxy,proxy-target-class 缺省按 true 算,代理落 CGLIB 分支。类路径没有 weaver 的工程(只用 @Async、@Cacheable 这类注解)走下半支,一个 BeanFactoryPostProcessor 把代理方式切成类代理,不贴 @EnableAspectJAutoProxy。
spring.aop.* 前缀下只有两条属性,并且没有对应的 @ConfigurationProperties 属性类,条件注解按属性名读 Environment:
1
2
spring.aop.auto 默认 true 设 false 则整个 AOP 自动配置关闭
spring.aop.proxy-target-class 默认 true true 为 CGLIB 类代理,false 为 JDK 接口代理
配置写在 aop.proxy-target-class 或 spring.aop.proxytargetclass 下都绑不上,这是排查代理不生效时我建议你先查的一处。@EnableAspectJAutoProxy 与自动配置的关系也清楚了:引了 starter 就不必手写,注解默认 proxyTargetClass=false,Boot 替工程贴的版本是 true,与「Boot 下代理实际是 CGLIB」的日常经验一致。唯一值得手贴的场景是 exposeProxy=true,配合 AopContext.currentProxy() 解决自调用。
依赖和配置
工程四块依赖:starter-webmvc 撑起订单 HTTP 面,starter-aspectj 是本篇主角,starter-jdbc 加 MySQL 驱动管审计落库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-aspectj</artifactId>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId> com.mysql</groupId>
<artifactId> mysql-connector-j</artifactId>
<scope> runtime</scope>
</dependency>
测试侧一个 starter 够用。我对比过 gRPC 那套进程内传输,aspectj-test 里没有专属测试设施,常规 webmvc-test 自带的 MockMvc 与 OutputCapture 就覆盖了切面测试的全部需求:
1
2
3
4
5
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc-test</artifactId>
<scope> test</scope>
</dependency>
配置只有一处与本篇强相关:spring.mvc.problemdetails.enabled=true 让错误响应统一走 RFC 9457 的 application/problem+json,切面里抛出的异常最后由它渲染:
1
2
3
4
5
6
7
8
9
10
11
12
spring :
datasource :
url : jdbc:mysql://192.168.1.97:3306/springboot4_aop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username : root
password : root123456
mvc :
problemdetails :
enabled : true
sql :
init :
mode : always
schema-locations : classpath:sql/schema.sql
审计表一张,八列:模块、操作、方法签名、参数摘要、成败、异常类名、耗时毫秒、时间戳。
1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE IF NOT EXISTS t_audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY ,
module VARCHAR ( 32 ) NOT NULL ,
action VARCHAR ( 64 ) NOT NULL ,
method VARCHAR ( 128 ) NOT NULL ,
args VARCHAR ( 255 ),
status VARCHAR ( 8 ) NOT NULL ,
error VARCHAR ( 64 ),
cost_ms INT ,
created_at DATETIME ( 3 ) NOT NULL
);
核心代码
四个切面按职责分工,注解只定义不实现。先看两个自定义注解,它们是切面的「配置面」:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Target ( ElementType . METHOD )
@Retention ( RetentionPolicy . RUNTIME )
public @interface Idempotent {
/** 防重窗口秒数:同一指纹在这个窗口内只放行一次 */
int windowSeconds () default 60 ;
}
@Target ( ElementType . METHOD )
@Retention ( RetentionPolicy . RUNTIME )
public @interface OperationLog {
/** 业务模块,例如「订单」 */
String module ();
/** 操作名,例如「创建订单」 */
String action ();
}
@Retention(RetentionPolicy.RUNTIME) 必须显式写,默认的 CLASS 保留策略在运行期读不到,@annotation 切点会静默失配。这是我踩过才敢这么写的:漏了它编译正常、启动正常,就是一条切点都匹配不上。业务代码这边零改动,观测逻辑全搬进切面,这笔买卖稳稳的赚。
计时切面:@within 圈住整个 web 层
@Pointcut 声明切点,@Around 包住目标方法,proceed() 前后就是计时窗口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Aspect
@Component
@Order ( 2 )
public class MetricsAspect {
private static final Logger log = LoggerFactory . getLogger ( MetricsAspect . class );
@Pointcut ( "@within(org.springframework.web.bind.annotation.RestController)" )
public void webLayer () {
}
@Around ( "webLayer()" )
public Object timeRequest ( ProceedingJoinPoint pjp ) throws Throwable {
long t0 = System . nanoTime ();
String target = pjp . getSignature (). toShortString ();
log . info ( "AOP_METRICS enter {}" , target );
try {
Object result = pjp . proceed ();
log . info ( "AOP_TIME {} costMs={} status=OK" , target , elapsedMs ( t0 ));
return result ;
} catch ( Throwable t ) {
log . info ( "AOP_TIME {} costMs={} status=ERROR({})" ,
target , elapsedMs ( t0 ), t . getClass (). getSimpleName ());
throw t ;
} finally {
log . info ( "AOP_METRICS exit {}" , target );
}
}
private long elapsedMs ( long t0 ) {
return ( System . nanoTime () - t0 ) / 1_000_000 ;
}
}
切点 @within(RestController) 命中所有标注 @RestController 的类里的全部方法,控制器不用贴任何额外注解。catch 分支记完状态原样 throw t,计时切面只观测不干预,异常留给全局处理器渲染。实测一轮:create 首单 51 ms,同单重复请求(幂等打回路径)之后第二单 14 ms,pay 11 ms,pack 142 ms,查不存在订单的 404 响应 16 ms。
跟踪切面:execution 免注解覆盖 service 层
四种 advice 各干一件事,@Before 进场记参数,@AfterReturning 记返回值,@AfterThrowing 记异常,@After 成败都走:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Aspect
@Component
@Order ( 3 )
public class TraceAspect {
private static final Logger log = LoggerFactory . getLogger ( TraceAspect . class );
@Pointcut ( "execution(* com.xncoding.aop.service..*.*(..))" )
public void serviceLayer () {
}
@Before ( "serviceLayer()" )
public void enter ( JoinPoint jp ) {
log . info ( "AOP_TRACE enter {} args={}" ,
jp . getSignature (). toShortString (), Arrays . toString ( jp . getArgs ()));
}
@AfterReturning ( pointcut = "serviceLayer()" , returning = "result" )
public void onReturn ( JoinPoint jp , Object result ) {
log . info ( "AOP_TRACE return {} result={}" , jp . getSignature (). toShortString (), result );
}
@AfterThrowing ( pointcut = "serviceLayer()" , throwing = "ex" )
public void onThrow ( JoinPoint jp , Throwable ex ) {
log . info ( "AOP_TRACE throws {} ex={}" ,
jp . getSignature (). toShortString (), ex . getClass (). getSimpleName ());
}
@After ( "serviceLayer()" )
public void exit ( JoinPoint jp ) {
log . info ( "AOP_TRACE exit {}" , jp . getSignature (). toShortString ());
}
}
execution(* com.xncoding.aop.service..*.*(..)) 按包名匹配,service 包下新增方法自动进入跟踪范围,这是它和注解切点最大的分工差异:按包圈范围用 execution,按标记圈范围用注解。
审计切面:@annotation 加 finally 落库
审计挂在 @OperationLog 上,成败两个分支都在 finally 里写库,失败行带异常类名:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Aspect
@Component
@Order ( 4 )
public class AuditAspect {
private static final Logger log = LoggerFactory . getLogger ( AuditAspect . class );
private final JdbcTemplate jdbc ;
public AuditAspect ( JdbcTemplate jdbc ) {
this . jdbc = jdbc ;
}
@Around ( "@annotation(operationLog)" )
public Object audit ( ProceedingJoinPoint pjp , OperationLog operationLog ) throws Throwable {
long t0 = System . nanoTime ();
String method = pjp . getSignature (). toShortString ();
String args = summarize ( pjp . getArgs ());
String status = "OK" ;
String error = null ;
log . info ( "AOP_AUDIT enter {} {}" , operationLog . module (), operationLog . action ());
try {
return pjp . proceed ();
} catch ( Throwable e ) {
status = "ERROR" ;
error = e . getClass (). getSimpleName ();
throw e ;
} finally {
long costMs = ( System . nanoTime () - t0 ) / 1_000_000 ;
jdbc . update ( """
INSERT INTO t_audit_log(module, action, method, args, status, error, cost_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3))
""" ,
operationLog . module (), operationLog . action (), method , args , status , error , ( int ) costMs );
log . info ( "AOP_AUDIT done {} {} status={} costMs={}" ,
operationLog . module (), operationLog . action (), status , costMs );
}
}
private String summarize ( Object [] args ) {
String joined = Arrays . stream ( args )
. map ( a -> a == null ? "null" : a . toString ())
. collect ( Collectors . joining ( ", " ));
return joined . length () > 200 ? joined . substring ( 0 , 200 ) : joined ;
}
}
切点写法 @annotation(operationLog) 把注解实例绑定进 advice 参数,module() 与 action() 从方法上的注解取值,业务代码除了贴注解零改动。我数过这轮落库:审计落了 5 行,创建与支付成功 3 行、打包 1 行、查不存在订单 1 行 ERROR,error 列记着 OrderNotFoundException。
有个对账细节挺有意思,我单独拎出来说:打包接口业务逻辑 sleep 120 ms,审计表 cost_ms 记 124,多出的 4 ms 是方法内 JSON 组装。这个对得上,恰恰说明审计切面的计时口径只含业务方法自身,中间各层切面的开销不在它里面。
幂等切面:指纹加 putIfAbsent
幂等放在切面链最外层,指纹取方法签名加入参,命中重复指纹抛自定义异常:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Aspect
@Component
@Order ( 1 )
public class IdempotentAspect {
private static final Logger log = LoggerFactory . getLogger ( IdempotentAspect . class );
/** 请求指纹 -> 过期时间戳。单机演示够用,分布式场景要换成 Redis 这类共享存储 */
private final ConcurrentHashMap < String , Long > tokens = new ConcurrentHashMap <> ();
@Around ( "@annotation(idempotent)" )
public Object guard ( ProceedingJoinPoint pjp , Idempotent idempotent ) throws Throwable {
String key = pjp . getSignature (). toShortString () + ":" + Arrays . deepToString ( pjp . getArgs ());
long now = System . currentTimeMillis ();
long deadline = now + idempotent . windowSeconds () * 1000L ;
tokens . entrySet (). removeIf ( e -> e . getValue () <= now );
if ( tokens . putIfAbsent ( key , deadline ) != null ) {
log . warn ( "AOP_IDEM reject key={} window={}s" , key , idempotent . windowSeconds ());
throw new IdempotentRejectedException ( idempotent . windowSeconds ());
}
log . info ( "AOP_IDEM enter key={}" , key );
try {
return pjp . proceed ();
} finally {
log . info ( "AOP_IDEM exit key={}" , key );
}
}
}
指纹可靠的前提是入参有稳定的 toString。控制器里的请求体用 record 定义,编译器生成的 toString 逐字段输出,同内容的两次请求指纹一致。putIfAbsent 的原子性保证并发到达的两个相同请求只有一个放行。被拒绝的异常沿调用链抛给全局处理器:
1
2
3
4
5
6
7
@ExceptionHandler ( IdempotentRejectedException . class )
public ProblemDetail handleIdempotentRejected ( IdempotentRejectedException ex , HttpServletRequest req ) {
ProblemDetail pd = problem ( HttpStatus . CONFLICT , "重复请求" , ex . getMessage (), req ,
"urn:problem-type:duplicate-request" );
pd . setProperty ( "windowSeconds" , ex . getWindowSeconds ());
return pd ;
}
顺序如何落到日志
四个 @Order 数字小在外层,链路按洋葱结构展开。一次创建订单的日志实测序列:
1
2
3
4
5
6
7
8
9
AOP_IDEM enter OrderController.create
AOP_METRICS enter OrderController.create
AOP_AUDIT enter 订单 创建订单
AOP_TRACE enter OrderService.createOrder
AOP_TRACE return OrderService.createOrder
AOP_TRACE exit OrderService.createOrder
AOP_AUDIT done 订单 创建订单 status=OK costMs=2
AOP_METRICS exit OrderController.create
AOP_IDEM exit OrderController.create
有个容易想错的地方,我第一次也想错了:Metrics 是 2,Trace 是 3,但 Trace 的日志夹在 Audit 中间。原因是 Trace 织入的是 service 层 bean 的代理,Audit 织入的是 controller 层 bean 的代理,两层代理嵌套,Trace 所在的内层链整体位于 Audit 的 proceed() 之内。@Order 只在同一层代理内部定序,跨层比较没有意义。这段日志序列就是洋葱结构的铁证,日志前缀 AOP_ 就是为这类断言准备的,测试一节直接按序列断言。
完整案例
业务三件套:OrderStore 用 AtomicInteger 管库存 50 件,CAS 扣减;OrderService 管下单、查询、支付、打包;OrderController 暴露四个接口,create 与 pay 标 @Idempotent,四个方法全标 @OperationLog。启动应用按场景打一轮:
1
2
3
4
5
6
7
8
POST /api/orders {"orderNo":"SK-AOP-1","amount":"129.00","itemCount":1} -> 201
POST /api/orders 同一请求体原样重发 -> 409
GET /api/orders/SK-NOPE -> 404
POST /api/orders {"orderNo":"SK-AOP-2","amount":"88.00","itemCount":4} -> 201
POST /api/orders/SK-AOP-1/pay -> 200
POST /api/orders/SK-AOP-1/pay 窗口内重发 -> 409
POST /api/orders/SK-AOP-2/pack -> 200
GET /api/ops/stats -> 对账
两次 409 的响应体都是 problem+json,type 字段区分问题类别,windowSeconds 带回防重窗口:
对账接口读库读内存,数字逐项对上:审计 5 行,其中 4 行 OK、1 行 ERROR;库存从 50 降到 45(两单合计 5 件);订单 2 单;幂等拒绝 2 次。被幂等打回的请求不产生审计行,因为拒绝发生在切面链最外层,内层审计切面没进到。
测试怎么写
四个测试类覆盖四种能力,9 个用例全绿。
切面顺序用 OutputCapture 断言日志序列。CapturedOutput 在 Boot 4 里是接口不是注解,按参数类型注入,OutputCaptureExtension 负责赋值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith ( OutputCaptureExtension . class )
class AspectOrderTest {
@Autowired
private MockMvc mockMvc ;
@Test
void fourAspects_wrap_like_onion ( CapturedOutput output ) throws Exception {
String orderNo = "SK-ORD-" + System . nanoTime ();
mockMvc . perform ( post ( "/api/orders" )
. contentType ( MediaType . APPLICATION_JSON )
. content ( """
{"orderNo":"%s","amount":"66.00","itemCount":1}
""" . formatted ( orderNo )))
. andExpect ( status (). isCreated ());
List < String > marks = Arrays . stream ( output . toString (). split ( "\\r?\\n" ))
. filter ( l -> l . contains ( "AOP_" ))
. map ( l -> l . replaceAll ( ".*(AOP_[A-Z]+ (?:enter|done|exit|return|reject)).*" , "$1" ))
. toList ();
assertThat ( marks ). containsSubsequence (
"AOP_IDEM enter" ,
"AOP_METRICS enter" ,
"AOP_AUDIT enter" ,
"AOP_TRACE enter" ,
"AOP_TRACE return" ,
"AOP_TRACE exit" ,
"AOP_AUDIT done" ,
"AOP_METRICS exit" ,
"AOP_IDEM exit" );
}
}
这里有两个我踩过的坑。其一,split("\\n") 在 Windows 上会留下行尾的 \r,正则里的点号不匹配 \r,replaceAll 的尾部 .* 吞不掉回车,抽出来的标记尾部挂着不可见字符,containsSubsequence 断言全挂,我盯着满屏红字愣了半天才发现是回车捣的乱。写成 split("\\r?\\n") 一劳永逸。其二,containsSubsequence 断言的是子序列不是连续子串,中间夹其他切面的日志不干扰判定。
幂等的 HTTP 行为用 MockMvc 直测:第一单 201,同请求体第二发 409,响应体标题与窗口字段都在断言里。审计落库用真实 MySQL,@BeforeEach 清表,请求后 JdbcTemplate 查行数与最后一行的字段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
void business_exception_writes_error_row_with_exception_name () throws Exception {
mockMvc . perform ( get ( "/api/orders/{orderNo}" , "SK-NOPE-" + System . nanoTime ()))
. andExpect ( status (). isNotFound ());
assertThat ( auditRows ()). isEqualTo ( 1 );
Map < String , Object > row = lastAuditRow ();
assertThat ( row . get ( "status" )). isEqualTo ( "ERROR" );
assertThat ( row . get ( "error" )). isEqualTo ( "OrderNotFoundException" );
}
@Test
void idempotent_rejected_request_writes_no_audit_row () throws Exception {
// 第一次 201 落 1 行;第二次 409 后行数不变:
// 幂等切面在最外层打回,审计切面没进到
}
@Around 的两个坑用最小上下文复现,AspectJProxyFactory 手动织入,不起 Spring 容器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Aspect
static class SwallowAspect {
@Around ( "execution(* com.xncoding.aop..*.doWork(..))" )
Object swallow ( ProceedingJoinPoint pjp ) {
try {
return pjp . proceed ();
} catch ( Throwable t ) {
return null ;
}
}
}
@Test
void swallow_aspect_hides_exception_and_returns_null () {
FragileService service = new FragileService ();
AspectJProxyFactory factory = new AspectJProxyFactory ( service );
factory . addAspect ( new SwallowAspect ());
FragileService proxy = factory . getProxy ();
assertThatCode ( proxy :: doWork ). doesNotThrowAnyException ();
assertThat ( service . executed ). isEqualTo ( 1 );
}
被代理的方法执行就抛异常,计数器 executed 证明方法体确实跑过:异常被切面吃掉,调用方拿到 null,排查时唯一的线索是少了一行 ERROR 日志。坑二更隐蔽,advice 里忘调 proceed() 而是返回兜底值,计数器保持 0,方法体压根没执行。这两个坑的解法一致:@Around 里要么 proceed() 要么抛异常,不做第三种收场。
避坑指南
坑一,starter 坐标已经改名。 spring-boot-starter-aop 在 4.1.1 的 Central 目录里 404,写老坐标构建直接失败。我第一反应是网络问题,重试了两次才去查目录,九成是这个原因。现行坐标 spring-boot-starter-aspectj,组成是 starter 加 spring-aop 7.0.9 加 aspectjweaver 1.9.25.1,版本全部由 BOM 管。
坑二,切点注解的保留策略必须是 RUNTIME。 自定义注解漏写 @Retention(RetentionPolicy.RUNTIME) 时编译正常、启动正常,@annotation 切点一条都匹配不上,所有 advice 静默失职。排查手段是把切点临时换成 execution 看是否恢复,我就是这么定位的。
坑三,@within 与 @annotation 圈的范围不同。 @within 匹配标注在类上的注解,覆盖类的全部方法;@annotation 只匹配方法上贴了注解的方法。想给 @RestController 全类生效用前者,想按方法粒度开关注解用后者,写反了要么范围过大要么一条不中。
坑四,自调用不经过代理。 同类里方法 A 调方法 B,B 上的切面不生效,因为这次调用没走代理对象。解法是注入自身代理(AopContext.currentProxy() 配 exposeProxy=true)或把 B 挪到另一个 bean。
坑五,@Around 吞异常与漏 proceed。 catch 之后返回 null 或兜底值不上抛,调用方无从知晓失败;不调 proceed() 则方法体根本不执行。两条都有最小复现测试,@Around 的出口只有 return pjp.proceed() 的结果或重抛的异常。
坑六,@Order 只管同层代理内部。 本例 Trace 是 @Order(3),日志却夹在 @Order(4) 的审计中间,因为它织在 service 层代理,不在 controller 层的链上。跨层排顺序看调用链不看注解数字,我在这上面闹过笑话。计时切面要放最外层,放内层统计不到外层切面的开销。
坑七,属性前缀只有 spring.aop.* 两条。 spring.aop.auto 关自动配置,spring.aop.proxy-target-class 切代理模式,没有属性类,写 aop.proxy-target-class 或驼峰变体都绑不上。排查代理模式问题时先确认属性名没写错。
坑八,切面抛异常要走全局处理器。 切面里的异常沿调用链穿透,最终由 @RestControllerAdvice 接住渲染成 problem+json,切面自身不需要感知 HTTP。前提是异常类型在处理器里有对应的 @ExceptionHandler,没有的话 409 会变成 500。
坑九,切面里的计时口径要分清。 外层切面统计的耗时包含内层全部切面与业务;审计切面在最里层,cost_ms 只含业务方法自身。本例 pack 接口 AOP_TIME 记 142 ms,审计表 cost_ms 记 124,差的 18 ms 是中间各层开销。对外报耗时用外层口径,性能归因用内层口径。
小结
选型上我的建议是三句话。要把计时、审计、幂等这类横切逻辑从业务代码里摘出来,starter-aspectj 一个依赖就够,业务代码只贴注解,接口加审计从改代码变成加一行注解。切点选择按范围挑:按包圈用 execution,按类圈用 @within,按方法粒度用 @annotation,三种都上手过才知道各自的顺手之处。幂等这条,单机起步用 ConcurrentHashMap 足够,上多实例时把存储换成 Redis,切面代码其余部分不用动。
没解决的事也交代两件。其一,指纹依赖入参的 deepToString,遇到没有稳定 toString 的参数类型会误判,除了约定 record 我还没找到更省心的办法。其二,切面内的异常目前只有「重抛给全局处理器」一条路,切面内部想做降级或重试还得自己再包一层。这两件我记下了,等有结论再展开。
参考链接