自定义注解+AOP实现统一切面:日志、限流、幂等一个注解搞定
自定义注解+AOP实现统一切面:日志、限流、幂等一个注解搞定
适读人群:想减少重复切面代码、提升接口治理能力的Spring Boot开发者 | 阅读时长:约18分钟
开篇故事
我接手过一个项目,每个 Controller 方法里的开头都有这么几行:
log.info("请求开始,参数:{}", JSON.toJSONString(request));
// ... 业务逻辑 ...
log.info("请求结束,耗时:{}ms", System.currentTimeMillis() - start);几十个接口,几十份重复代码。更夸张的是,限流逻辑也是手写的:
String key = "rate:" + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
if (count > 100) {
throw new TooManyRequestsException("请求过于频繁");
}同样的逻辑,复制了三四十遍。
我当时做了一件事:把所有这些横切关注点都收拢到注解 + AOP 里,业务代码只需要加一个注解,剩下的切面自动处理。改完之后,每个 Controller 方法平均缩短了 20 行代码。今天把这套方案完整分享出来。
一、方案设计
1.1 目标
设计一套注解体系,支持:
@ApiLog:记录请求/响应日志,支持脱敏@RateLimit:基于 Redis 的接口限流@Idempotent:基于 Token 的接口幂等
1.2 整体架构
二、完整代码实现
2.1 Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
</dependencies>2.2 @ApiLog 注解与切面
注解定义:
package com.laozhang.aop.annotation;
import java.lang.annotation.*;
/**
* 接口日志记录注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiLog {
/**
* 接口描述,用于日志标记
*/
String value() default "";
/**
* 是否记录请求参数,默认true
*/
boolean logRequest() default true;
/**
* 是否记录响应结果,默认true
*/
boolean logResponse() default true;
/**
* 需要脱敏的字段名(如 "password", "phone", "idCard")
*/
String[] sensitiveFields() default {};
}切面实现:
package com.laozhang.aop.aspect;
import com.alibaba.fastjson2.JSON;
import com.laozhang.aop.annotation.ApiLog;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
@Slf4j
@Aspect
@Component
public class ApiLogAspect {
@Around("@annotation(apiLog)")
public Object around(ProceedingJoinPoint joinPoint, ApiLog apiLog) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 获取请求信息
HttpServletRequest request = null;
try {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
request = attributes.getRequest();
}
} catch (Exception ignored) {}
String interfaceDesc = apiLog.value().isEmpty()
? method.getDeclaringClass().getSimpleName() + "." + method.getName()
: apiLog.value();
// 记录请求日志
if (apiLog.logRequest()) {
Object[] args = joinPoint.getArgs();
String requestStr = buildSafeJson(args, apiLog.sensitiveFields());
log.info("[ApiLog] 接口开始 | {} | url={} | params={}",
interfaceDesc,
request != null ? request.getRequestURI() : "N/A",
requestStr);
}
long start = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - start;
// 记录响应日志
if (apiLog.logResponse()) {
log.info("[ApiLog] 接口成功 | {} | 耗时={}ms | result={}",
interfaceDesc, elapsed, buildSafeJson(new Object[]{result}, new String[0]));
} else {
log.info("[ApiLog] 接口成功 | {} | 耗时={}ms", interfaceDesc, elapsed);
}
return result;
} catch (Throwable e) {
long elapsed = System.currentTimeMillis() - start;
log.error("[ApiLog] 接口异常 | {} | 耗时={}ms | error={}",
interfaceDesc, elapsed, e.getMessage(), e);
throw e;
}
}
/**
* 构建安全的JSON字符串(对敏感字段脱敏)
*/
private String buildSafeJson(Object[] args, String[] sensitiveFields) {
if (args == null || args.length == 0) {
return "[]";
}
try {
String json = JSON.toJSONString(args);
// 简单的脱敏处理:将敏感字段值替换为 "***"
for (String field : sensitiveFields) {
json = json.replaceAll(
"\"" + field + "\":\"[^\"]*\"",
"\"" + field + "\":\"***\""
);
}
return json;
} catch (Exception e) {
return "[序列化失败]";
}
}
}2.3 @RateLimit 注解与切面
package com.laozhang.aop.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 接口限流注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流key前缀,默认使用方法全路径
*/
String key() default "";
/**
* 时间窗口内允许的最大请求数
*/
int maxRequests() default 100;
/**
* 时间窗口大小
*/
long window() default 1;
/**
* 时间窗口单位
*/
TimeUnit timeUnit() default TimeUnit.MINUTES;
/**
* 限流维度:GLOBAL(全局)、IP(按IP)、USER(按用户)
*/
LimitType limitType() default LimitType.GLOBAL;
/**
* 触发限流时的提示信息
*/
String message() default "请求过于频繁,请稍后重试";
enum LimitType {
GLOBAL, IP, USER
}
}package com.laozhang.aop.aspect;
import com.laozhang.aop.annotation.RateLimit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Collections;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final StringRedisTemplate redisTemplate;
/**
* Lua脚本实现原子性的计数器限流
* 避免 incr + expire 的非原子问题
*/
private static final String RATE_LIMIT_LUA_SCRIPT =
"local key = KEYS[1]\n" +
"local maxRequests = tonumber(ARGV[1])\n" +
"local window = tonumber(ARGV[2])\n" +
"local current = redis.call('incr', key)\n" +
"if current == 1 then\n" +
" redis.call('expire', key, window)\n" +
"end\n" +
"if current > maxRequests then\n" +
" return 0\n" +
"else\n" +
" return 1\n" +
"end";
private final DefaultRedisScript<Long> rateLimitScript =
new DefaultRedisScript<>(RATE_LIMIT_LUA_SCRIPT, Long.class);
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = buildKey(joinPoint, rateLimit);
long windowInSeconds = rateLimit.timeUnit().toSeconds(rateLimit.window());
Long result = redisTemplate.execute(
rateLimitScript,
Collections.singletonList(key),
String.valueOf(rateLimit.maxRequests()),
String.valueOf(windowInSeconds)
);
if (result == null || result == 0L) {
log.warn("[RateLimit] 限流触发 key={} maxRequests={} window={}s",
key, rateLimit.maxRequests(), windowInSeconds);
throw new RuntimeException(rateLimit.message());
}
return joinPoint.proceed();
}
private String buildKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String methodKey = method.getDeclaringClass().getName() + "." + method.getName();
String prefix = rateLimit.key().isEmpty() ? methodKey : rateLimit.key();
return switch (rateLimit.limitType()) {
case IP -> "rate_limit:ip:" + getClientIp() + ":" + prefix;
case USER -> "rate_limit:user:" + getCurrentUserId() + ":" + prefix;
default -> "rate_limit:global:" + prefix;
};
}
private String getClientIp() {
try {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) return "unknown";
HttpServletRequest request = attrs.getRequest();
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip.split(",")[0].trim();
} catch (Exception e) {
return "unknown";
}
}
private String getCurrentUserId() {
// 从 SecurityContext 或 ThreadLocal 获取当前用户
// 这里简化处理
return "anonymous";
}
}2.4 @Idempotent 注解与切面
package com.laozhang.aop.annotation;
import java.lang.annotation.*;
/**
* 接口幂等注解
* 使用 Token 机制:前端请求前先获取 Token,提交时携带,后端消费 Token
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* Token 在请求头中的 key 名称
*/
String tokenHeader() default "Idempotency-Key";
/**
* Token 有效期(秒),默认5分钟
*/
long expireSeconds() default 300;
/**
* 重复提交时的提示信息
*/
String message() default "请勿重复提交";
}package com.laozhang.aop.aspect;
import com.laozhang.aop.annotation.Idempotent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private static final String TOKEN_PREFIX = "idempotent:token:";
private final StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
HttpServletRequest request = getRequest();
if (request == null) {
return joinPoint.proceed();
}
String token = request.getHeader(idempotent.tokenHeader());
if (token == null || token.trim().isEmpty()) {
throw new IllegalArgumentException("幂等Token不能为空,请先获取Token");
}
String redisKey = TOKEN_PREFIX + token;
// 使用 setIfAbsent 原子操作:存在则返回false,不存在则设置并返回true
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "processing", idempotent.expireSeconds(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(success)) {
log.warn("[Idempotent] 重复请求被拦截 token={}", token);
throw new RuntimeException(idempotent.message());
}
try {
Object result = joinPoint.proceed();
// 成功后将状态标记为"已完成"(而不是删除,避免网络抖动下的重复提交)
redisTemplate.opsForValue().set(
redisKey, "done", idempotent.expireSeconds(), TimeUnit.SECONDS
);
return result;
} catch (Throwable e) {
// 业务异常时删除token,允许重试
redisTemplate.delete(redisKey);
throw e;
}
}
private HttpServletRequest getRequest() {
try {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs != null ? attrs.getRequest() : null;
} catch (Exception e) {
return null;
}
}
}2.5 在 Controller 里使用
@RestController
@RequestMapping("/api/order")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
/**
* 创建订单:日志 + 幂等 + 限流三重保障
*/
@PostMapping("/create")
@ApiLog(value = "创建订单", sensitiveFields = {"phone", "idCard"})
@Idempotent(tokenHeader = "Idempotency-Key", expireSeconds = 300)
@RateLimit(maxRequests = 10, window = 1, timeUnit = TimeUnit.MINUTES,
limitType = RateLimit.LimitType.USER, message = "下单太频繁,请稍候")
public Result<OrderVO> createOrder(@RequestBody CreateOrderRequest request) {
return Result.success(orderService.createOrder(request));
}
/**
* 查询列表:只记日志,按IP限流
*/
@GetMapping("/list")
@ApiLog(value = "查询订单列表", logResponse = false)
@RateLimit(maxRequests = 200, window = 1, timeUnit = TimeUnit.MINUTES,
limitType = RateLimit.LimitType.IP)
public Result<PageVO<OrderVO>> listOrders(OrderQueryRequest request) {
return Result.success(orderService.listOrders(request));
}
}四、踩坑实录
坑1:切面不生效,原因是自调用
症状:在 ServiceA.methodA() 里调用 this.methodB(),methodB 上的 @RateLimit 不生效。
根因:Spring AOP 是基于代理的,this.methodB() 绕过了代理,直接调用了原始对象的方法。
解决方案:
// 方式1:注入自身(推荐)
@Service
public class OrderService {
@Autowired
private OrderService self; // 注入的是代理对象
public void methodA() {
self.methodB(); // 通过代理调用,切面生效
}
}
// 方式2:通过AopContext获取代理(需要 @EnableAspectJAutoProxy(exposeProxy=true))
public void methodA() {
((OrderService) AopContext.currentProxy()).methodB();
}坑2:@Around 里 proceed() 没有调用
症状:加了切面后,接口一直没有响应,等了很久超时了。
根因:@Around 切面里忘记调用 joinPoint.proceed(),或者在某个条件分支里没有调用。
正确写法:
@Around("@annotation(myAnnotation)")
public Object around(ProceedingJoinPoint joinPoint, MyAnnotation myAnnotation) throws Throwable {
// 前置处理
try {
Object result = joinPoint.proceed(); // 必须调用!
// 后置处理
return result; // 必须返回!
} catch (Throwable t) {
// 异常处理
throw t; // 要抛出,否则异常被吞掉
}
}坑3:切面执行顺序不对
症状:@Idempotent 应该先于 @RateLimit 执行,但实际上顺序反了。
解决:用 @Order 控制切面优先级,数字越小越先执行(最外层):
@Aspect
@Component
@Order(1) // 最先执行(最外层)
public class IdempotentAspect { ... }
@Aspect
@Component
@Order(2)
public class RateLimitAspect { ... }
@Aspect
@Component
@Order(3) // 最后执行(最里层,最靠近业务方法)
public class ApiLogAspect { ... }注意:@Order 数字小的切面,@Before 先执行,@After 后执行——就像洋葱模型。
坑4:在切面里拿不到 HttpServletRequest
症状:RequestContextHolder.getRequestAttributes() 返回 null。
根因:AOP 切面在异步线程里执行时(例如 @Async 方法),RequestContextHolder 是线程绑定的,子线程拿不到父线程的 Request 对象。
解决:
// 在主线程里提前获取好,传给切面或异步方法
String clientIp = getClientIp();
CompletableFuture.runAsync(() -> {
// 在这里不能再调 RequestContextHolder
doAsyncWork(clientIp);
});五、总结与延伸
这套方案的核心价值:
- 业务代码零侵入:所有横切关注点收在注解里,Controller 只关心业务逻辑
- 配置即策略:注解参数就是配置,改注解参数就能改策略,不用改业务代码
- 可组合:多个注解叠加,顺序用
@Order控制
几个可以继续完善的方向:
@ApiLog支持异步写日志到数据库(ELK 链路)@RateLimit支持令牌桶、漏桶等更复杂的算法@Idempotent支持基于业务字段的幂等(不依赖前端传 Token)
下一篇(459)深入 Spring AOP 的源码,看 ProxyFactory 是如何决定用 JDK 代理还是 CGLIB 的。
