自定义注解 + AOP:打造企业级通用组件(日志、限流、幂等)

在企业的复杂业务系统中,我们经常会遇到这样的困境:

业务代码里充斥着大量的**"非业务逻辑"** ------

"先记一下日志..."

"查一下 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 的强大之处:

  1. 零侵入 :业务代码里只有 @OperLog@Idempotent,干干净净。
  2. 可复用:写一次切面,全公司 100 个微服务都能用。
  3. 易维护:修改限流算法?改切面代码就行,不用动业务逻辑。

最后,送上金句

"优秀的架构师善于利用 AOP 将非业务逻辑封装成'基础设施'。业务开发人员只需要关注核心逻辑(怎么赚钱),剩下的脏活累活(怎么记账、怎么安保),交给注解和切面。"

现在,去给你的项目贴上"标记",部署"安检员"吧!️

相关推荐
大阿明2 小时前
Go基础之环境搭建
开发语言·后端·golang
polaris06302 小时前
springboot接入deepseek深度求索 java
java·spring boot·后端
真实的菜2 小时前
Spring Boot 升级全攻略:从 2.2 到 2.7 再到 3.x
java·spring boot·后端
wangfpp2 小时前
产品:这个文字颜色能不能根据背景图自动换?
前端·面试·产品
freshman_y2 小时前
经典的C语言题型
c语言·开发语言·算法
small_wh1te_coder2 小时前
拷打字节技术总监: 详解c语言嵌入式多线程编程中的头文件 #总结 上下篇合 #
c语言·开发语言·算法·操作系统·嵌入式
凌盛羽2 小时前
使用python绘图分析电池充电曲线
开发语言·python·stm32·单片机·fpga开发·51单片机
wangjialelele2 小时前
现代C++:C++17新特性整理
c语言·开发语言·c++·visual studio code