在企业的复杂业务系统中,我们经常会遇到这样的困境:
业务代码里充斥着大量的**"非业务逻辑"** ------
"先记一下日志..."
"查一下 Redis 看有没有重复提交..."
"算一下这个 IP 这一秒请求了几次..."
这些代码像牛皮藓一样贴在核心业务逻辑上,让代码变得臃肿、难以维护。
这就好比: 你只是想请 VIP 客户进门喝茶(核心业务),结果让他自己在门口填表(日志)、自己掏钥匙开门(鉴权)、自己排队领号(限流)。太累了!
今天,我们要用 自定义注解 + AOP 的组合拳,打造一套**"实战档次的基础设施"** 。
我们将业务代码中的杂活剥离出来,封装成通用的组件。
从此以后,业务开发只需要关注核心逻辑,剩下的脏活累活,交给**"安检员"**(AOP 切面)去处理。
核心隐喻:特殊标记与安检员
-
自定义注解 = "特殊标记"
你在方法上贴个
@OperLog,就像在门口挂了个"VIP 通道"的牌子。你在方法上贴个
@Idempotent,就像挂了个"防弹玻璃"的牌子。 -
AOP 切面 = "全能安检员"
安检员站在门口(拦截器),盯着每一个进来的人(请求)。
他不需要认识具体的人,只需要看牌子:
- 看到"VIP 通道"?-> 自动记录是谁进来了(日志)。
- 看到"防弹玻璃"?-> 检查这人是不是刚才那个捣乱的(幂等性)。
- 看到"限流闸机"?-> 掐表看这人是不是来得太频繁(限流)。
核心价值 :解耦。业务代码只负责"喝茶",安检员负责"安保"。
实战案例 1:通用日志切面 ------ "全链路监控眼"
痛点:每个接口都要记录"谁、在什么时间、调用了什么方法、传了什么参数、花了多久、结果如何"。如果手写,代码量巨大。
1. 定义"标记":@OperLog
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperLog {
// 操作描述,例如:"新增用户"
String value() default "";
// 操作类型:0=其他 1=新增 2=修改 3=删除
int type() default 0;
}
2. 部署"安检员":LogAspect
这里我们利用 @Around 环绕通知,因为它能拿到执行前 (参数)和执行后(返回值、耗时)的所有信息
@Aspect
@Component
@Slf4j
public class LogAspect {
// 切入点:所有标了 @OperLog 的方法
@Around("@annotation(operLog)")
public Object doAround(ProceedingJoinPoint joinPoint, OperLog operLog) throws Throwable {
long startTime = System.currentTimeMillis();
// 1. 获取请求参数
Object[] args = joinPoint.getArgs();
// 这里可以序列化参数,注意:大文件参数要过滤,防止 OOM
String params = JSON.toJSONString(args);
Object result = null;
try {
// 2. 【核心】执行目标方法
result = joinPoint.proceed();
return result;
} catch (Exception e) {
log.error("业务异常: {}", e.getMessage());
throw e; // 必须抛出,否则业务层捕获不到异常
} finally {
// 3. 记录耗时和结果
long endTime = System.currentTimeMillis();
long cost = endTime - startTime;
// 4. 异步保存日志 (关键!不要阻塞主线程)
// 使用 Spring 的 @Async 或者线程池
saveLogAsync(operLog, params, result, cost);
}
}
private void saveLogAsync(OperLog operLog, String params, Object result, long cost) {
// 模拟异步入库
System.out.println(String.format("【日志入库】操作:%s, 参数:%s, 耗时:%dms, 结果:%s",
operLog.value(), params, cost, JSON.toJSONString(result)));
}
}
- 异步是关键:日志是辅助功能,绝对不能因为写日志慢而拖垮业务接口。
- 参数脱敏 :在序列化
args时,要注意过滤掉密码、文件流等敏感或超大字段。
实战案例 2:幂等性/防重提交切面 ------ "防弹护盾"
痛点:用户手抖点了两次提交按钮,或者网络卡顿导致重试,结果产生了两条一样的订单。
1. 定义"标记":@Idempotent
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// 唯一键前缀,例如 "order:submit"
String key();
// 锁定时间(秒),默认 5 秒
int expire() default 5;
}
2. 部署"安检员":IdempotentAspect
这里利用 Redis 的 setIfAbsent (SETNX) 来实现分布式锁。
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 生成 Redis Key
// 策略:Key = 前缀 + 用户ID (防止不同用户互斥) + 方法签名
String userId = UserContext.getCurrentUserId(); // 假设从上下文获取
String key = idempotent.key() + ":" + userId;
// 2. 尝试加锁 (SETNX)
// 如果返回 true,说明拿到了锁(第一次请求)
// 如果返回 false,说明锁已存在(重复请求)
Boolean isAbsent = redisTemplate.opsForValue()
.setIfAbsent(key, "LOCK", idempotent.expire(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(isAbsent)) {
// 3. 拦截:拒绝重复请求
throw new BusinessException("请勿重复提交,请稍后再试");
}
try {
// 4. 放行:执行业务
return joinPoint.proceed();
} finally {
// 5. 释放锁 (可选)
// 如果 expire 时间足够覆盖业务执行时间,也可以不手动删,让它自动过期
// 但为了严谨,通常建议在 finally 中删除
redisTemplate.delete(key);
}
}
}
- Key 的设计:只锁用户 ID 可能太宽泛,可以加上方法名或业务参数(如订单号)。
- Lua 脚本 :在高并发下,为了保证"判断+设置"的原子性,最好使用 Lua 脚本替代
setIfAbsent。
实战案例 3:动态限流切面 ------ "流量调节阀"
痛点:某个接口突然被刷,数据库 CPU 飙升。我们需要在代码层面加一道防线。
1. 定义"标记":@RateLimit
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
// 限流 Key(用于区分不同接口)
String key();
// 最大请求数
int maxRequests();
// 时间窗口(秒)
int timeout();
}
2. 部署"安检员":RateLimitAspect
这里演示两种流派:Guava 单机限流 和 Redis 分布式限流。
流派 A:单机限流 (Guava RateLimiter)
适合单体应用,利用令牌桶算法。
@Aspect
@Component
public class RateLimitAspect {
// 缓存限流器,Key -> RateLimiter
private final Map<String, RateLimiter> limiterMap = new ConcurrentHashMap<>();
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = rateLimit.key();
// 获取或创建限流器 (每秒 maxRequests 个令牌)
RateLimiter limiter = limiterMap.computeIfAbsent(key,
k -> RateLimiter.create(rateLimit.maxRequests()));
// 尝试获取令牌,超时 0.5 秒
if (!limiter.tryAcquire(500, TimeUnit.MILLISECONDS)) {
throw new TooManyRequestsException("系统繁忙,请稍后再试");
}
return joinPoint.proceed();
}
}
流派 B:分布式限流 (Redis + Lua)
适合集群部署,所有机器共享一个计数器。
// 伪代码逻辑
String script =
"local key = KEYS[1] " +
"local limit = tonumber(ARGV[1]) " +
"local current = tonumber(redis.call('get', key) or '0') " +
"if current + 1 > limit then " +
" return 0 " +
"else " +
" return redis.call('incr', key) " +
"end";
// 在切面中执行 script
Long count = redisTemplate.execute(script, Collections.singletonList(key), limit);
if (count == 0) {
throw new TooManyRequestsException("流量过大");
}
- Guava 简单高效,但集群环境下限流总数会翻倍(10 台机器 * 100 QPS = 1000 QPS)。
- Redis 精确,但增加了网络 IO 开销。
从"功能"到"基础设施"
通过这三个案例,我们可以看到自定义注解 + AOP 的强大之处:
- 零侵入 :业务代码里只有
@OperLog、@Idempotent,干干净净。 - 可复用:写一次切面,全公司 100 个微服务都能用。
- 易维护:修改限流算法?改切面代码就行,不用动业务逻辑。
最后,送上金句:
"优秀的架构师善于利用 AOP 将非业务逻辑封装成'基础设施'。业务开发人员只需要关注核心逻辑(怎么赚钱),剩下的脏活累活(怎么记账、怎么安保),交给注解和切面。"
现在,去给你的项目贴上"标记",部署"安检员"吧!️