Spring AOP场景3——接口防抖(附带源码)

在白嫖之前,希望你会内疚,最起码点个赞收藏再自取吧,源码在最后,自取;

在白嫖之前,希望你会内疚,最起码点个赞收藏再自取吧,源码在最后,自取;

在白嫖之前,希望你会内疚,最起码点个赞收藏再自取吧,源码在最后,自取;

基于自定义注解+AOP实现BladeX接口防抖功能详解

一、功能概述

本方案基于Spring AOP + Redis原子操作实现BladeX框架下的接口防抖(限流)功能,核心解决接口重复调用/重复提交问题,支持分布式环境、SpEL动态生成Key、自定义过期时间/提示语,适配BladeX原生返回结果R,开箱即用且具备高鲁棒性。

二、整体架构与设计思路

1. 架构分层

层级 组件 职责
注解层 @Debounce 定义防抖规则(Key前缀、SpEL表达式、过期时间、提示语等)
切面层 BladeDebounceAspect 拦截标注@Debounce的方法,解析注解参数、生成Redis Key、调用防抖工具类
工具层 BladeDebounceUtil 封装Redis原子操作,实现防抖锁的获取/释放,处理序列化、异常降级等核心逻辑
业务层 业务接口(如getNameAndCardNum 标注@Debounce注解,无感接入防抖功能

2. 核心设计思路

  • 无侵入式接入:通过自定义注解+AOP实现,业务代码仅需添加注解,无需修改核心逻辑;
  • 分布式兼容 :基于Redis原子操作(setIfAbsent)实现分布式锁,适配BladeX集群部署;
  • 动态Key生成:支持SpEL表达式解析方法参数,实现"接口+用户/参数"级别的精准防抖;
  • 鲁棒性保障:Redis异常时降级放行、永久Key自动清理、序列化器全局配置,避免影响主业务;
  • 原生适配 :返回BladeX框架标准R对象,兼容全局异常处理、统一返回格式。

3. 执行流程

  1. 客户端调用接口 → 2. AOP切面拦截方法 → 3. 解析@Debounce注解参数 → 4. SpEL解析生成Redis Key → 5. 调用工具类尝试获取防抖锁 → 6. 锁获取成功则执行原业务方法并返回结果;锁获取失败则返回限流提示(R.fail

三、代码模块逐行解析

1. 自定义注解@Debounce

java 复制代码
@Target({ElementType.METHOD})  // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME)  // 运行时生效,支持AOP解析
@Documented  // 生成文档
public @interface Debounce {
    // Redis Key前缀,用于区分不同接口
    String prefix() default "blade:debounce:";

    // SpEL表达式,用于动态拼接Key(如取方法参数、参数属性)
    String key() default "";

    // 防抖过期时间,默认5秒
    long expireTime() default 5;

    // 时间单位,默认秒
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    // 重复操作提示语,返回给前端
    String message() default "操作过于频繁,请稍后再试";

    // 是否使用分布式锁(集群环境默认开启)
    boolean useDistributedLock() default true;

    // 分布式锁等待时间(默认0,立即返回)
    long lockWaitTime() default 0;
}

关键解析

  • @Target(ElementType.METHOD):限定注解仅作用于方法,符合接口防抖的场景;
  • key()支持SpEL:核心灵活点,可通过#miniUserId#root.args[0].id等表达式精准定位到"用户+接口"维度;
  • 预留useDistributedLock:为后续"本地锁/分布式锁"切换预留扩展点。

2. 防抖工具类BladeDebounceUtil(核心)

java 复制代码
@Slf4j
@Component
public class BladeDebounceUtil {
    // 可配置常量,便于维护
    private static final String DEFAULT_DEBOUNCE_VALUE = "1";
    private static final long DEFAULT_EXPIRE_SECONDS = 5;
    private static final boolean LOG_ENABLE = true;

    @Resource
    private BladeRedis bladeRedis;
    private RedisTemplate<String, Object> redisTemplate;
    private ValueOperations<String, Object> valueOps;

    // 初始化方法:序列化器全局配置(仅执行1次,提升性能)
    @PostConstruct
    public void init() {
        if (bladeRedis != null) {
            this.redisTemplate = bladeRedis.getRedisTemplate();
            if (this.redisTemplate != null) {
                // 强制String序列化,避免Redis参数解析异常
                StringRedisSerializer stringSerializer = new StringRedisSerializer();
                this.redisTemplate.setKeySerializer(stringSerializer);
                this.redisTemplate.setValueSerializer(stringSerializer);
                this.redisTemplate.setHashKeySerializer(stringSerializer);
                this.redisTemplate.setHashValueSerializer(stringSerializer);
                this.valueOps = this.redisTemplate.opsForValue();
                log.info("【防抖工具类】初始化完成,Redis序列化器配置成功");
            }
        } else {
            log.warn("【防抖工具类】BladeRedis注入失败,防抖功能降级为直接放行");
        }
    }

    // 重载方法:简化调用,支持默认过期时间
    public boolean tryAcquire(String key) {
        return tryAcquire(key, DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS);
    }

    // 重载方法:支持自定义value,便于区分不同业务锁
    public boolean tryAcquire(String key, String value, long expireTime, TimeUnit timeUnit) {
        return innerTryAcquire(key, value, expireTime, timeUnit);
    }

    // 核心方法:对外暴露的标准调用入口
    public boolean tryAcquire(String key, long expireTime, TimeUnit timeUnit) {
        return innerTryAcquire(key, DEFAULT_DEBOUNCE_VALUE, expireTime, timeUnit);
    }

    // 内部核心逻辑:抽离通用逻辑,便于维护
    private boolean innerTryAcquire(String key, String value, long expireTime, TimeUnit timeUnit) {
        // 1. 前置校验:参数合法性+Redis降级
        try {
            Assert.hasText(key, "防抖Key不能为空");
            Assert.hasText(value, "防抖Value不能为空");
        } catch (IllegalArgumentException e) {
            log.error("【防抖工具类】参数非法:{}", e.getMessage());
            return false;
        }

        // Redis未初始化时降级放行,避免影响主业务
        if (redisTemplate == null || valueOps == null) {
            if (LOG_ENABLE) {
                log.warn("【防抖工具类】Redis未初始化,防抖功能降级,直接放行Key:{}", key);
            }
            return true;
        }

        // 2. 过期时间合法性校验
        long expireSeconds = timeUnit.toSeconds(expireTime);
        if (expireSeconds <= 0) {
            if (LOG_ENABLE) {
                log.error("【防抖工具类】过期时间非法,Key:{},时间:{}秒", key, expireSeconds);
            }
            return false;
        }

        boolean lockSuccess = false;
        Long ttl = -1L;

        try {
            // 3. 原子操作:判断Key不存在则设置值+过期时间(核心防抖逻辑)
            Boolean setResult = valueOps.setIfAbsent(key, value, expireTime, timeUnit);
            lockSuccess = Boolean.TRUE.equals(setResult);

            // 4. 双重保障:强制设置过期时间,防止setIfAbsent参数解析失败
            if (lockSuccess) {
                boolean expireSuccess = Boolean.TRUE.equals(redisTemplate.expire(key, expireTime, timeUnit));
                if (LOG_ENABLE) {
                    log.info("【防抖工具类】获取锁成功,Key:{},过期时间:{}秒,过期设置{}",
                        key, expireSeconds, expireSuccess ? "成功" : "失败");
                }
            } else {
                // 5. 异常处理:检测到永久Key(ttl=-1)自动清理,并重试获取锁
                ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
                if (ttl == -1) {
                    if (LOG_ENABLE) {
                        log.error("【防抖工具类】检测到永久有效Key,强制删除:{}", key);
                    }
                    redisTemplate.delete(key);
                    // 重新尝试获取锁
                    setResult = valueOps.setIfAbsent(key, value, expireTime, timeUnit);
                    lockSuccess = Boolean.TRUE.equals(setResult);
                    // 重新获取ttl,保证日志准确性
                    ttl = lockSuccess ? Long.valueOf(expireSeconds) : redisTemplate.getExpire(key, TimeUnit.SECONDS);
                    if (lockSuccess && LOG_ENABLE) {
                        log.info("【防抖工具类】清理永久Key后获取锁成功,Key:{}", key);
                    }
                }
            }
        } catch (Exception e) {
            // Redis异常时降级放行,核心业务优先
            log.error("【防抖工具类】获取锁异常,Key:{},异常信息:{}", key, e.getMessage(), e);
            return true;
        }

        // 6. 日志输出:仅在锁获取失败且日志开启时打印,减少日志量
        if (!lockSuccess && LOG_ENABLE) {
            String ttlDesc = switch (ttl.intValue()) {
                case -1 -> "永久有效";
                case -2 -> "Key不存在";
                default -> ttl + "秒";
            };
            log.warn("【防抖工具类】获取锁失败,Key:{},剩余过期时间:{}", key, ttlDesc);
        }

        return lockSuccess;
    }

    // 手动释放锁:增加异常捕获,避免释放失败影响主业务
    public void releaseLock(String key) {
        if (bladeRedis == null || key == null || key.isEmpty()) {
            return;
        }
        try {
            bladeRedis.del(key);
            if (LOG_ENABLE) {
                log.info("【防抖工具类】释放锁成功,Key:{}", key);
            }
        } catch (Exception e) {
            log.error("【防抖工具类】释放锁失败,Key:{},异常信息:{}", key, e.getMessage(), e);
        }
    }
}

关键解析

  • @PostConstruct初始化:序列化器仅配置1次,避免每次调用重复创建对象,提升性能;
  • setIfAbsent原子操作:等价于Redis命令SET key value NX EX expire,保证"判断-设置-过期"原子性,避免并发问题;
  • 降级逻辑:Redis未初始化/异常时直接放行,核心业务不受影响;
  • 永久Key清理:检测到ttl=-1(永久Key)时自动删除并重试,解决历史残留Key导致的误限流;
  • 重载方法:适配不同调用场景,提升易用性。

3. AOP切面BladeDebounceAspect

java 复制代码
@Slf4j
@Aspect
@Component
public class BladeDebounceAspect {
    @Resource
    private BladeDebounceUtil bladeDebounceUtil;

    // SpEL解析器:BladeX内部同款,保证解析规则一致
    private final ExpressionParser spelParser = new SpelExpressionParser();
    // 参数名解析器:解析方法参数名,支持SpEL引用参数
    private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

    // 切点:匹配所有标注@Debounce的方法
    @Pointcut("@annotation(org.springblade.business.aspect.annotation.Debounce)")
    public void debouncePointcut() {}

    // 环绕通知:核心拦截逻辑
    @Around("debouncePointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 目标方法标识:类名.方法名,便于日志定位
        String targetMethod = joinPoint.getTarget().getClass().getSimpleName() + "." + joinPoint.getSignature().getName();
        log.info("【自定义防抖切面】开始执行,目标方法:{}", targetMethod);

        try {
            // 1. 获取方法和注解信息
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            Debounce debounce = method.getAnnotation(Debounce.class);
            if (debounce == null) {
                log.warn("【自定义防抖切面】目标方法:{},未解析到@Debounce注解,直接放行", targetMethod);
                return joinPoint.proceed();
            }
            log.info("【自定义防抖切面】目标方法:{},解析防抖注解成功,配置:前缀={},过期时间={}{},提示语={}",
                targetMethod, debounce.prefix(), debounce.expireTime(), debounce.timeUnit().name(), debounce.message());

            // 2. 生成Redis Key:前缀+SpEL解析的动态Key
            String redisKey = generateRedisKey(joinPoint, debounce);
            log.info("【自定义防抖切面】目标方法:{},生成防抖RedisKey:{}", targetMethod, redisKey);
            if (redisKey.isEmpty()) {
                log.error("【自定义防抖切面】目标方法:{},防抖Key生成失败(空值),拒绝执行", targetMethod);
                return R.fail("防抖Key配置异常,请检查@Debounce注解");
            }

            // 3. 过期时间合法性校验
            long expireSeconds = debounce.timeUnit().toSeconds(debounce.expireTime());
            if (expireSeconds <= 0) {
                log.error("【BladeX防抖切面】目标方法:{},过期时间配置错误({}秒),拒绝执行",
                    targetMethod, expireSeconds);
                return R.fail("限流配置异常,请联系管理员");
            }

            // 4. 调用工具类获取防抖锁
            boolean acquireSuccess = bladeDebounceUtil.tryAcquire(
                redisKey,
                debounce.expireTime(),
                debounce.timeUnit()
            );
            log.info("【自定义防抖切面】目标方法:{},Redis防抖校验结果:{}(true=未重复,false=重复)", targetMethod, acquireSuccess);

            // 5. 重复操作:返回BladeX标准失败结果
            if (!acquireSuccess) {
                log.warn("【自定义防抖切面】目标方法:{},检测到重复操作(RedisKey:{}),返回提示:{}",
                    targetMethod, redisKey, debounce.message());
                return R.fail(debounce.message());
            }

            // 6. 执行原业务方法
            log.info("【自定义防抖切面】目标方法:{},防抖校验通过,开始执行原方法", targetMethod);
            Object result = joinPoint.proceed();
            log.info("【自定义防抖切面】目标方法:{},原方法执行完成,返回结果类型:{}",
                targetMethod, result == null ? "null" : result.getClass().getSimpleName());
            return result;

        } catch (Throwable e) {
            // 异常抛出:交给BladeX全局异常处理器处理
            log.error("【自定义防抖切面】目标方法:{},执行过程中发生异常", targetMethod, e);
            throw e;
        } finally {
            log.info("【自定义防抖切面】目标方法:{},切面执行结束", targetMethod);
            // 可选:手动释放锁(根据业务需求,如操作成功后立即释放)
            // if (redisKey != null) {
            //     bladeDebounceUtil.releaseLock(redisKey);
            // }
        }
    }

    // 生成Redis Key:解析SpEL表达式,支持动态参数
    private String generateRedisKey(ProceedingJoinPoint joinPoint, Debounce debounce) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Object[] args = joinPoint.getArgs();
        String targetMethod = method.getDeclaringClass().getSimpleName() + "." + method.getName();

        String spelKey = debounce.key();
        // 未配置SpEL时,使用默认Key:前缀+类名+方法名
        if (spelKey.isEmpty()) {
            String defaultKey = debounce.prefix() + method.getDeclaringClass().getSimpleName() + "_" + method.getName();
            log.debug("【自定义防抖切面】目标方法:{},未配置自定义SpEL Key,使用默认Key:{}", targetMethod, defaultKey);
            return defaultKey;
        }

        // 构建SpEL上下文:解析方法参数名+参数值
        StandardEvaluationContext context = new MethodBasedEvaluationContext(
            null, method, args, parameterNameDiscoverer
        );

        Object keyObj = null;
        try {
            // 解析SpEL表达式,获取动态Key
            keyObj = spelParser.parseExpression(spelKey).getValue(context);
        } catch (Exception e) {
            log.error("【自定义防抖切面】目标方法:{},解析SpEL表达式失败(表达式:{})", targetMethod, spelKey, e);
        }
        // 拼接最终Key:前缀+动态解析结果
        String dynamicKey = debounce.prefix() + (keyObj == null ? "" : keyObj.toString());
        log.debug("【自定义防抖切面】目标方法:{},SpEL表达式解析完成,表达式:{},解析结果:{},最终Key:{}",
            targetMethod, spelKey, keyObj, dynamicKey);
        return dynamicKey;
    }
}

关键解析

  • @Pointcut:精准匹配标注@Debounce的方法,无冗余拦截;
  • MethodBasedEvaluationContext:BladeX兼容的SpEL上下文,支持解析方法参数名(如#miniUserId);
  • ProceedingJoinPoint.proceed():执行原业务方法,保证AOP无侵入;
  • 异常处理:切面异常直接抛出,交给BladeX全局异常处理器,保证异常处理逻辑统一。

4. 业务接口接入示例

java 复制代码
@GetMapping("/getNameAndCardNum")
@ApiOperationSupport(order = 2)
@Operation(summary = "小程序-获取用户真实姓名和身份证号", description = "传入miniUserId")
@SwaggerVersion(version = SwaggerVersionEnum.V1_0)
@Debounce(
    prefix = "mini_user:getNameAndCardNum:", // 接口专属前缀,便于区分
    key = "#miniUserId", // SpEL解析方法参数miniUserId,实现"用户级"防抖
    expireTime = 20, // 防抖时间窗口20秒
    timeUnit = TimeUnit.SECONDS,
    message = "查询过于频繁,请5秒后再试" // 前端提示语
)
public R<NameAndCardNumVO> getNameAndCardNum(@RequestParam @Parameter(description = "小程序用户id") Long miniUserId) {
    NameAndCardNumVO detail = miniUserService.getNameAndCardNum(miniUserId);
    return R.data(detail);
}

关键解析

  • prefix:接口专属前缀,避免不同接口Key冲突;
  • key = "#miniUserId":SpEL表达式解析方法参数miniUserId,实现"同一个用户20秒内只能调用1次",而非"所有用户共享20秒窗口";
  • 注解参数完全自定义,适配不同业务的防抖需求。

四、重点难点总结(面试高频)

1. 核心难点:Redis原子操作与序列化问题

  • 问题 :RedisTemplate默认序列化器(JdkSerializationRedisSerializer)会将Long/String参数序列化为字节数组,导致setIfAbsent的过期时间参数解析失败,Key被设置为永久有效;
  • 解决方案 :强制配置StringRedisSerializer,保证参数以纯字符串传递给Redis;
  • 面试回答思路 : 实现分布式防抖的核心是Redis原子操作,需注意两点:① 使用setIfAbsent(NX+EX)保证"判断-设置-过期"原子性,避免并发问题;② 必须统一Redis序列化器为String,否则参数解析异常会导致Key永久有效,反而引发更严重的限流问题。

2. 重点:SpEL表达式解析动态Key

  • 问题:如何实现"接口+参数"级别的精准防抖,而非全局接口防抖;
  • 解决方案 :通过MethodBasedEvaluationContext解析方法参数名,结合SpelExpressionParser解析表达式,动态拼接Key;
  • 面试回答思路 : 为了实现精细化防抖,我们基于Spring SpEL表达式解析方法参数,比如通过#miniUserId获取用户ID,将Redis Key设置为前缀+用户ID,保证防抖粒度精准到"用户+接口",既避免全局限流的粗粒度问题,又能防止恶意用户高频调用。

3. 鲁棒性难点:Redis异常降级

  • 问题:Redis宕机/网络异常时,防抖功能不能影响主业务;
  • 解决方案:Redis未初始化/执行异常时,直接放行请求,核心业务优先;
  • 面试回答思路 : 分布式组件的降级策略是高可用设计的核心,我们在防抖工具类中增加了Redis异常捕获和降级逻辑:当Redis连接失败或执行异常时,防抖功能自动降级为放行,保证核心业务接口的可用性,同时通过日志记录异常,便于后续排查。

4. 易错点:永久Key清理

  • 问题:序列化异常/参数解析失败会导致Key无过期时间,第一次调用后永久限流;
  • 解决方案 :检测到ttl=-1(永久Key)时自动删除,并重试获取锁;
  • 面试回答思路 : 实际生产中,Redis Key可能因序列化、参数错误等原因被设置为永久有效,我们通过getExpire方法检测Key的过期时间,若发现永久Key则立即删除并重试,避免因历史残留Key导致的误限流,保证防抖功能的稳定性。

5. 性能优化:序列化器全局配置

  • 问题:每次调用防抖工具类都重新设置序列化器,增加性能开销;
  • 解决方案 :通过@PostConstruct在Bean初始化时仅配置1次序列化器;
  • 面试回答思路 : 性能优化的核心是减少重复操作,我们将Redis序列化器的配置放在@PostConstruct初始化方法中,仅执行1次,避免每次调用防抖方法都重复创建序列化器对象,同时缓存ValueOperations,减少RedisTemplate的重复调用,提升接口响应速度。

五、开箱即用使用指南

1. 环境依赖

确保BladeX项目中已引入Redis相关依赖(BladeX默认已集成):

xml 复制代码
<!-- BladeX Redis依赖 -->
<dependency>
    <groupId>org.springblade</groupId>
    <artifactId>blade-core-redis</artifactId>
</dependency>

2. 配置Redis(application.yml)

yaml 复制代码
spring:
  redis:
    host: 127.0.0.1
    port: 6379
    password: 123456
    database: 0
    timeout: 5000ms

3. 快速接入步骤

步骤1:复制代码文件

@DebounceBladeDebounceUtilBladeDebounceAspect三个类复制到项目对应包下;

步骤2:业务接口添加注解
java 复制代码
@Debounce(
    prefix = "业务前缀:", // 如"order:submit:"
    key = "#参数名", // 如"#orderId"、"#user.id"
    expireTime = 10, // 防抖时间(秒)
    message = "操作过于频繁,请10秒后再试"
)
步骤3:测试验证
  • 第一次调用接口:正常返回业务结果,Redis中生成Key(ttl=配置的过期时间);
  • 过期时间内重复调用:返回R.fail(提示语);
  • 过期时间后调用:正常返回业务结果。

4. 常见问题排查

问题现象 排查方案
第一次调用就限流 1. 执行redis-cli DEL Key清理残留Key;2. 检查序列化器是否配置为String;
SpEL解析失败 1. 检查表达式是否正确(如#miniUserId是否与方法参数名一致);2. 查看日志中的解析异常信息;
Redis Key永久有效 1. 检查序列化器配置;2. 工具类已自动清理永久Key,重启应用后重试;
防抖功能不生效 1. 检查注解是否标注在方法上;2. 检查AOP切面是否被Spring扫描(@Component);

六、扩展建议

1. 功能扩展

  • 本地锁/分布式锁切换 :基于注解useDistributedLock参数,实现本地锁(ReentrantLock)和分布式锁的切换,适配单机/集群环境;
  • 防抖时间动态配置:整合Nacos/Apollo配置中心,支持防抖时间、提示语动态修改,无需重启应用;
  • 批量防抖:支持注解配置多个Key,实现"多参数组合"防抖;
  • 限流次数统计:增加计数器,统计接口被限流的次数,对接监控平台(如Prometheus/Grafana);
  • 自定义返回结果:支持注解配置返回码/返回体,适配不同业务的返回格式。

2. 性能扩展

  • Redis连接池优化:配置RedisTemplate的连接池参数,提升高并发下的性能;
  • 本地缓存预热:热点Key的防抖结果本地缓存,减少Redis调用;
  • 异步释放锁:操作成功后异步释放锁,提升接口响应速度。

3. 安全扩展

  • Key前缀白名单:限制可使用的Key前缀,避免恶意拼接Key占用Redis空间;
  • 防抖时间上限 :限制注解expireTime的最大值,避免设置过长的防抖时间;
  • IP限流扩展:结合用户IP生成Key,防止单IP高频调用。

七、总结

本方案基于自定义注解+AOP实现了BladeX框架下的高性能、高可用接口防抖功能,核心解决了分布式环境下的重复调用问题,同时兼顾了易用性、扩展性和鲁棒性。方案中的Redis原子操作、SpEL动态Key、序列化优化、异常降级等设计思路,也是分布式系统开发中的高频考点,既满足业务需求,也适配面试场景的核心考点。

完整版源码

java 复制代码
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * BladeX专属防抖注解
 * 避免重复提交/重复操作
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Debounce {

	/**
	 * 防抖Redis Key前缀(默认:blade:debounce:)
	 */
	String prefix() default "blade:debounce:";

	/**
	 * 防抖Key的拼接规则(支持SpEL表达式)
	 * 示例:
	 * - "#information.cardNo":取方法参数information的cardNo属性
	 * - "#userId":取方法参数userId
	 * - "#root.args[0].id":取第一个参数的id属性
	 */
	String key() default "";

	/**
	 * 防抖过期时间(默认5秒)
	 */
	long expireTime() default 5;

	/**
	 * 时间单位(默认秒)
	 */
	TimeUnit timeUnit() default TimeUnit.SECONDS;

	/**
	 * 重复操作提示信息
	 */
	String message() default "操作过于频繁,请稍后再试";

	/**
	 * 是否使用分布式锁(BladeX集群环境默认开启)
	 */
	boolean useDistributedLock() default true;

	/**
	 * 分布式锁等待时间(默认0,立即返回)
	 */
	long lockWaitTime() default 0;
}
java 复制代码
/**
 * BladeX Redis防抖工具类
 */
@Slf4j
@Component
public class BladeDebounceUtil {
	// ========== 配置常量 ==========
	/**
	 * 默认防抖value(标识锁)
	 */
	private static final String DEFAULT_DEBOUNCE_VALUE = "1";
	/**
	 * 默认过期时间(秒)
	 */
	private static final long DEFAULT_EXPIRE_SECONDS = 5;
	/**
	 * 日志开关(生产可关闭调试日志)
	 */
	private static final boolean LOG_ENABLE = true;

	@Resource
	private BladeRedis bladeRedis;
	/**
	 * 全局RedisTemplate(只初始化1次)
	 */
	private RedisTemplate<String, Object> redisTemplate;
	/**
	 * 全局ValueOperations(避免重复获取)
	 */
	private ValueOperations<String, Object> valueOps;

	// ========== 初始化序列化器(只执行1次) ==========
	@PostConstruct
	public void init() {
		if (bladeRedis != null) {
			this.redisTemplate = bladeRedis.getRedisTemplate();
			// 序列化器全局配置(仅初始化1次,提升性能)
			if (this.redisTemplate != null) {
				StringRedisSerializer stringSerializer = new StringRedisSerializer();
				this.redisTemplate.setKeySerializer(stringSerializer);
				this.redisTemplate.setValueSerializer(stringSerializer);
				this.redisTemplate.setHashKeySerializer(stringSerializer);
				this.redisTemplate.setHashValueSerializer(stringSerializer);
				this.valueOps = this.redisTemplate.opsForValue();
				log.info("【防抖工具类】初始化完成,Redis序列化器配置成功");
			}
		} else {
			log.warn("【防抖工具类】BladeRedis注入失败,防抖功能降级为直接放行");
		}
	}

	// ========== 重载方法(简化调用,提升易用性) ==========

	/**
	 * 重载:使用默认过期时间(5秒)
	 */
	public boolean tryAcquire(String key) {
		return tryAcquire(key, DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS);
	}

	/**
	 * 重载:支持自定义value(便于区分不同业务的锁)
	 */
	public boolean tryAcquire(String key, String value, long expireTime, TimeUnit timeUnit) {
		return innerTryAcquire(key, value, expireTime, timeUnit);
	}

	// ========== 核心方法(优化逻辑) ==========

	/**
	 * 尝试获取防抖锁(强制配置序列化器+双重保障过期时间)
	 */
	public boolean tryAcquire(String key, long expireTime, TimeUnit timeUnit) {
		return innerTryAcquire(key, DEFAULT_DEBOUNCE_VALUE, expireTime, timeUnit);
	}

	/**
	 * 内部核心逻辑(抽离通用逻辑,便于维护)
	 */
	private boolean innerTryAcquire(String key, String value, long expireTime, TimeUnit timeUnit) {
		// 1. 前置校验 + 降级逻辑(Redis异常时直接放行,避免影响主业务)
		try {
			Assert.hasText(key, "防抖Key不能为空");
			Assert.hasText(value, "防抖Value不能为空");
		} catch (IllegalArgumentException e) {
			log.error("【防抖工具类】参数非法:{}", e.getMessage());
			return false;
		}

		// Redis未初始化,降级为放行(不影响主业务)
		if (redisTemplate == null || valueOps == null) {
			if (LOG_ENABLE) {
				log.warn("【防抖工具类】Redis未初始化,防抖功能降级,直接放行Key:{}", key);
			}
			return true;
		}

		// 2. 校验过期时间
		long expireSeconds = timeUnit.toSeconds(expireTime);
		if (expireSeconds <= 0) {
			if (LOG_ENABLE) {
				log.error("【防抖工具类】过期时间非法,Key:{},时间:{}秒", key, expireSeconds);
			}
			return false;
		}

		boolean lockSuccess = false;
		Long ttl = -1L;

		try {
			// 第一步:原子判断并设置Key(带过期时间)
			Boolean setResult = valueOps.setIfAbsent(key, value, expireTime, timeUnit);
			lockSuccess = Boolean.TRUE.equals(setResult);

			// 第二步:双重保障(强制设置过期时间,防止第一步失效)
			if (lockSuccess) {
				boolean expireSuccess = Boolean.TRUE.equals(redisTemplate.expire(key, expireTime, timeUnit));
				if (LOG_ENABLE) {
					log.info("【防抖工具类】获取锁成功,Key:{},过期时间:{}秒,过期设置{}",
						key, expireSeconds, expireSuccess ? "成功" : "失败");
				}
			} else {
				// 检查Key是否永久有效,若是则强制删除(清理残留)
				ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
				if (ttl == -1) {
					if (LOG_ENABLE) {
						log.error("【防抖工具类】检测到永久有效Key,强制删除:{}", key);
					}
					redisTemplate.delete(key);
					// 重新尝试获取锁
					setResult = valueOps.setIfAbsent(key, value, expireTime, timeUnit);
					lockSuccess = Boolean.TRUE.equals(setResult);
					// 重新获取ttl,日志更准确
					ttl = lockSuccess ? Long.valueOf(expireSeconds) : redisTemplate.getExpire(key, TimeUnit.SECONDS);
					if (lockSuccess && LOG_ENABLE) {
						log.info("【防抖工具类】清理永久Key后获取锁成功,Key:{}", key);
					}
				}
			}
		} catch (Exception e) {
			// Redis异常时降级为放行,避免影响主业务
			log.error("【防抖工具类】获取锁异常,Key:{},异常信息:{}", key, e.getMessage(), e);
			return true;
		}

		// 优化日志:只在开启日志且获取锁失败时打印
		if (!lockSuccess && LOG_ENABLE) {
			String ttlDesc = switch (ttl.intValue()) {
				case -1 -> "永久有效";
				case -2 -> "Key不存在";
				default -> ttl + "秒";
			};
			log.warn("【防抖工具类】获取锁失败,Key:{},剩余过期时间:{}", key, ttlDesc);
		}

		return lockSuccess;
	}

	/**
	 * 手动释放防抖锁,增加异常捕获,避免释放失败影响主业务
	 */
	public void releaseLock(String key) {
		if (bladeRedis == null || key == null || key.isEmpty()) {
			return;
		}
		try {
			bladeRedis.del(key);
			if (LOG_ENABLE) {
				log.info("【防抖工具类】释放锁成功,Key:{}", key);
			}
		} catch (Exception e) {
			log.error("【防抖工具类】释放锁失败,Key:{},异常信息:{}", key, e.getMessage(), e);
		}
	}
}
java 复制代码
```java
import jakarta.annotation.Resource;
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.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springblade.business.aspect.annotation.Debounce;
import org.springblade.business.util.BladeDebounceUtil;
import org.springblade.core.tool.api.R;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.core.annotation.Order;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

/**
 * BladeX防抖注解切面
 * 适配框架原生返回结果R
 */
@Slf4j
@Aspect
@Component
@Order(3)
public class BladeDebounceAspect {

	@Resource
	private BladeDebounceUtil bladeDebounceUtil;

	// SpEL表达式解析器
	private final ExpressionParser spelParser = new SpelExpressionParser();
	// 参数名解析器
	private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

	/**
	 * 切入点:匹配所有标注@Debounce的方法
	 */
	@Pointcut("@annotation(org.springblade.business.aspect.annotation.Debounce)")
	public void debouncePointcut() {
	}

	/**
	 * 环绕通知:实现防抖逻辑
	 */
	@Around("debouncePointcut()")
	public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
		// 目标方法标识(类名.方法名)
		String targetMethod = joinPoint.getTarget().getClass().getSimpleName() + "." + joinPoint.getSignature().getName();
		log.info("【自定义防抖切面】开始执行,目标方法:{}", targetMethod);

		try {
			// 1. 获取方法和注解信息
			MethodSignature signature = (MethodSignature) joinPoint.getSignature();
			Method method = signature.getMethod();
			Debounce debounce = method.getAnnotation(Debounce.class);
			if (debounce == null) {
				log.warn("【自定义防抖切面】目标方法:{},未解析到@Debounce注解,直接放行", targetMethod);
				return joinPoint.proceed();
			}
			log.info("【自定义防抖切面】目标方法:{},解析防抖注解成功,配置:前缀={},过期时间={}{},提示语={}", targetMethod, debounce.prefix(), debounce.expireTime(), debounce.timeUnit().name(), debounce.message());

			// 2. 生成防抖Key(前缀+动态Key)
			String redisKey = generateRedisKey(joinPoint, debounce);
			log.info("【自定义防抖切面】目标方法:{},生成防抖RedisKey:{}", targetMethod, redisKey);
			if (redisKey.isEmpty()) {
				log.error("【自定义防抖切面】目标方法:{},防抖Key生成失败(空值),拒绝执行", targetMethod);
				return R.fail("防抖Key配置异常,请检查@Debounce注解");
			}

			// 环绕通知中,生成redisKey后新增
			long expireSeconds = debounce.timeUnit().toSeconds(debounce.expireTime());
			if (expireSeconds <= 0) {
				log.error("【BladeX防抖切面】目标方法:{},过期时间配置错误({}秒),拒绝执行", targetMethod, expireSeconds);
				return R.fail("限流配置异常,请联系管理员");
			}

			// 3. 使用BladeRedis校验防抖(原子操作)
			boolean acquireSuccess = bladeDebounceUtil.tryAcquire(redisKey, debounce.expireTime(), debounce.timeUnit());
			log.info("【自定义防抖切面】目标方法:{},Redis防抖校验结果:{}(true=未重复,false=重复)", targetMethod, acquireSuccess);

			// 4. 重复操作:返回BladeX原生R.fail结果
			if (!acquireSuccess) {
				log.warn("【自定义防抖切面】目标方法:{},检测到重复操作(RedisKey:{}),返回提示:{}", targetMethod, redisKey, debounce.message());
				return R.fail(debounce.message());
			}

			// 5. 执行原方法
			log.info("【自定义防抖切面】目标方法:{},防抖校验通过,开始执行原方法", targetMethod);
			Object result = joinPoint.proceed();
			log.info("【自定义防抖切面】目标方法:{},原方法执行完成,返回结果类型:{}", targetMethod, result == null ? "null" : result.getClass().getSimpleName());
			return result;

		} catch (Throwable e) {
			log.error("【自定义防抖切面】目标方法:{},执行过程中发生异常", targetMethod, e);
			throw e; // 抛出异常,交给BladeX全局异常处理器处理
		} finally {
			log.info("【自定义防抖切面】目标方法:{},切面执行结束", targetMethod);
			//可选:操作成功后手动释放锁(根据业务需求)
			//if (redisKey != null) {
			//    bladeDebounceUtil.releaseLock(redisKey);
			//    log.info("【BladeX防抖切面】目标方法:{},手动释放防抖锁,RedisKey:{}", targetMethod, redisKey);
			//}
		}
	}

	/**
	 * 生成Redis Key
	 */
	private String generateRedisKey(ProceedingJoinPoint joinPoint, Debounce debounce) {
		MethodSignature signature = (MethodSignature) joinPoint.getSignature();
		Method method = signature.getMethod();
		Object[] args = joinPoint.getArgs();
		String targetMethod = method.getDeclaringClass().getSimpleName() + "." + method.getName();

		String spelKey = debounce.key();
		if (spelKey.isEmpty()) {
			String defaultKey = debounce.prefix() + method.getDeclaringClass().getSimpleName() + "_" + method.getName();
			log.debug("【自定义防抖切面】目标方法:{},未配置自定义SpEL Key,使用默认Key:{}", targetMethod, defaultKey);
			return defaultKey;
		}

		// 创建SpEL上下文
		StandardEvaluationContext context = new MethodBasedEvaluationContext(null, method, args, parameterNameDiscoverer);

		Object keyObj = null;
		try {
			keyObj = spelParser.parseExpression(spelKey).getValue(context);
		} catch (Exception e) {
			log.error("【自定义防抖切面】目标方法:{},解析SpEL表达式失败(表达式:{})", targetMethod, spelKey, e);
		}
		String dynamicKey = debounce.prefix() + (keyObj == null ? "" : keyObj.toString());
		log.debug("【自定义防抖切面】目标方法:{},SpEL表达式解析完成,表达式:{},解析结果:{},最终Key:{}", targetMethod, spelKey, keyObj, dynamicKey);
		return dynamicKey;
	}
}

关于这个自定义注解实现接口防抖的用法,如下:

直接在我们的接口上添加注解,可以自己指定参数,如果不知道,就用自定义注解的默认值,测试如下:

第一次请求接口的时候,接口可以正常响应,返回数据给前端:

在20秒内再请求的话,会显示如下响应:

而在20秒之后在请求,又可以正常响应数据给前端,接口防抖成功。

相关推荐
海上彼尚2 小时前
Go之路 - 7.go的函数
开发语言·后端·golang
计算机毕设指导62 小时前
基于微信小程序的积分制零食自选平台【源码文末联系】
java·spring boot·mysql·微信小程序·小程序·tomcat·maven
神仙别闹2 小时前
基于QT(C++)实现(图形界面)连连看
java·c++·qt
BioRunYiXue2 小时前
双荧光素酶报告基因实验
java·运维·服务器·数据库·人工智能·数据挖掘·eclipse
Geoking.2 小时前
深度理解 Java synchronized —— 从原理到实战
java·开发语言
martinzh2 小时前
NL2SQL解决了?别闹了!大模型让你和数据库聊天背后的真相
后端
未来影子2 小时前
Java领域构建Agent新杀入一匹黑马(agentscope-java)
java·开发语言·python
靓仔建2 小时前
在asp.net web应用程序,老是访问同一个Handler1.ashx
后端·asp.net·一般处理程序