news 2025/12/14 8:23:11

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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

@Target({ElementType.METHOD})// 仅作用于方法@Retention(RetentionPolicy.RUNTIME)// 运行时生效,支持AOP解析@Documented// 生成文档public@interfaceDebounce{// Redis Key前缀,用于区分不同接口Stringprefix()default"blade:debounce:";// SpEL表达式,用于动态拼接Key(如取方法参数、参数属性)Stringkey()default"";// 防抖过期时间,默认5秒longexpireTime()default5;// 时间单位,默认秒TimeUnittimeUnit()defaultTimeUnit.SECONDS;// 重复操作提示语,返回给前端Stringmessage()default"操作过于频繁,请稍后再试";// 是否使用分布式锁(集群环境默认开启)booleanuseDistributedLock()defaulttrue;// 分布式锁等待时间(默认0,立即返回)longlockWaitTime()default0;}

关键解析

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

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

@Slf4j@ComponentpublicclassBladeDebounceUtil{// 可配置常量,便于维护privatestaticfinalStringDEFAULT_DEBOUNCE_VALUE="1";privatestaticfinallongDEFAULT_EXPIRE_SECONDS=5;privatestaticfinalbooleanLOG_ENABLE=true;@ResourceprivateBladeRedisbladeRedis;privateRedisTemplate<String,Object>redisTemplate;privateValueOperations<String,Object>valueOps;// 初始化方法:序列化器全局配置(仅执行1次,提升性能)@PostConstructpublicvoidinit(){if(bladeRedis!=null){this.redisTemplate=bladeRedis.getRedisTemplate();if(this.redisTemplate!=null){// 强制String序列化,避免Redis参数解析异常StringRedisSerializerstringSerializer=newStringRedisSerializer();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注入失败,防抖功能降级为直接放行");}}// 重载方法:简化调用,支持默认过期时间publicbooleantryAcquire(Stringkey){returntryAcquire(key,DEFAULT_EXPIRE_SECONDS,TimeUnit.SECONDS);}// 重载方法:支持自定义value,便于区分不同业务锁publicbooleantryAcquire(Stringkey,Stringvalue,longexpireTime,TimeUnittimeUnit){returninnerTryAcquire(key,value,expireTime,timeUnit);}// 核心方法:对外暴露的标准调用入口publicbooleantryAcquire(Stringkey,longexpireTime,TimeUnittimeUnit){returninnerTryAcquire(key,DEFAULT_DEBOUNCE_VALUE,expireTime,timeUnit);}// 内部核心逻辑:抽离通用逻辑,便于维护privatebooleaninnerTryAcquire(Stringkey,Stringvalue,longexpireTime,TimeUnittimeUnit){// 1. 前置校验:参数合法性+Redis降级try{Assert.hasText(key,"防抖Key不能为空");Assert.hasText(value,"防抖Value不能为空");}catch(IllegalArgumentExceptione){log.error("【防抖工具类】参数非法:{}",e.getMessage());returnfalse;}// Redis未初始化时降级放行,避免影响主业务if(redisTemplate==null||valueOps==null){if(LOG_ENABLE){log.warn("【防抖工具类】Redis未初始化,防抖功能降级,直接放行Key:{}",key);}returntrue;}// 2. 过期时间合法性校验longexpireSeconds=timeUnit.toSeconds(expireTime);if(expireSeconds<=0){if(LOG_ENABLE){log.error("【防抖工具类】过期时间非法,Key:{},时间:{}秒",key,expireSeconds);}returnfalse;}booleanlockSuccess=false;Longttl=-1L;try{// 3. 原子操作:判断Key不存在则设置值+过期时间(核心防抖逻辑)BooleansetResult=valueOps.setIfAbsent(key,value,expireTime,timeUnit);lockSuccess=Boolean.TRUE.equals(setResult);// 4. 双重保障:强制设置过期时间,防止setIfAbsent参数解析失败if(lockSuccess){booleanexpireSuccess=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(Exceptione){// Redis异常时降级放行,核心业务优先log.error("【防抖工具类】获取锁异常,Key:{},异常信息:{}",key,e.getMessage(),e);returntrue;}// 6. 日志输出:仅在锁获取失败且日志开启时打印,减少日志量if(!lockSuccess&&LOG_ENABLE){StringttlDesc=switch(ttl.intValue()){case-1->"永久有效";case-2->"Key不存在";default->ttl+"秒";};log.warn("【防抖工具类】获取锁失败,Key:{},剩余过期时间:{}",key,ttlDesc);}returnlockSuccess;}// 手动释放锁:增加异常捕获,避免释放失败影响主业务publicvoidreleaseLock(Stringkey){if(bladeRedis==null||key==null||key.isEmpty()){return;}try{bladeRedis.del(key);if(LOG_ENABLE){log.info("【防抖工具类】释放锁成功,Key:{}",key);}}catch(Exceptione){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

@Slf4j@Aspect@ComponentpublicclassBladeDebounceAspect{@ResourceprivateBladeDebounceUtilbladeDebounceUtil;// SpEL解析器:BladeX内部同款,保证解析规则一致privatefinalExpressionParserspelParser=newSpelExpressionParser();// 参数名解析器:解析方法参数名,支持SpEL引用参数privatefinalParameterNameDiscovererparameterNameDiscoverer=newDefaultParameterNameDiscoverer();// 切点:匹配所有标注@Debounce的方法@Pointcut("@annotation(org.springblade.business.aspect.annotation.Debounce)")publicvoiddebouncePointcut(){}// 环绕通知:核心拦截逻辑@Around("debouncePointcut()")publicObjectaround(ProceedingJoinPointjoinPoint)throwsThrowable{// 目标方法标识:类名.方法名,便于日志定位StringtargetMethod=joinPoint.getTarget().getClass().getSimpleName()+"."+joinPoint.getSignature().getName();log.info("【自定义防抖切面】开始执行,目标方法:{}",targetMethod);try{// 1. 获取方法和注解信息MethodSignaturesignature=(MethodSignature)joinPoint.getSignature();Methodmethod=signature.getMethod();Debouncedebounce=method.getAnnotation(Debounce.class);if(debounce==null){log.warn("【自定义防抖切面】目标方法:{},未解析到@Debounce注解,直接放行",targetMethod);returnjoinPoint.proceed();}log.info("【自定义防抖切面】目标方法:{},解析防抖注解成功,配置:前缀={},过期时间={}{},提示语={}",targetMethod,debounce.prefix(),debounce.expireTime(),debounce.timeUnit().name(),debounce.message());// 2. 生成Redis Key:前缀+SpEL解析的动态KeyStringredisKey=generateRedisKey(joinPoint,debounce);log.info("【自定义防抖切面】目标方法:{},生成防抖RedisKey:{}",targetMethod,redisKey);if(redisKey.isEmpty()){log.error("【自定义防抖切面】目标方法:{},防抖Key生成失败(空值),拒绝执行",targetMethod);returnR.fail("防抖Key配置异常,请检查@Debounce注解");}// 3. 过期时间合法性校验longexpireSeconds=debounce.timeUnit().toSeconds(debounce.expireTime());if(expireSeconds<=0){log.error("【BladeX防抖切面】目标方法:{},过期时间配置错误({}秒),拒绝执行",targetMethod,expireSeconds);returnR.fail("限流配置异常,请联系管理员");}// 4. 调用工具类获取防抖锁booleanacquireSuccess=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());returnR.fail(debounce.message());}// 6. 执行原业务方法log.info("【自定义防抖切面】目标方法:{},防抖校验通过,开始执行原方法",targetMethod);Objectresult=joinPoint.proceed();log.info("【自定义防抖切面】目标方法:{},原方法执行完成,返回结果类型:{}",targetMethod,result==null?"null":result.getClass().getSimpleName());returnresult;}catch(Throwablee){// 异常抛出:交给BladeX全局异常处理器处理log.error("【自定义防抖切面】目标方法:{},执行过程中发生异常",targetMethod,e);throwe;}finally{log.info("【自定义防抖切面】目标方法:{},切面执行结束",targetMethod);// 可选:手动释放锁(根据业务需求,如操作成功后立即释放)// if (redisKey != null) {// bladeDebounceUtil.releaseLock(redisKey);// }}}// 生成Redis Key:解析SpEL表达式,支持动态参数privateStringgenerateRedisKey(ProceedingJoinPointjoinPoint,Debouncedebounce){MethodSignaturesignature=(MethodSignature)joinPoint.getSignature();Methodmethod=signature.getMethod();Object[]args=joinPoint.getArgs();StringtargetMethod=method.getDeclaringClass().getSimpleName()+"."+method.getName();StringspelKey=debounce.key();// 未配置SpEL时,使用默认Key:前缀+类名+方法名if(spelKey.isEmpty()){StringdefaultKey=debounce.prefix()+method.getDeclaringClass().getSimpleName()+"_"+method.getName();log.debug("【自定义防抖切面】目标方法:{},未配置自定义SpEL Key,使用默认Key:{}",targetMethod,defaultKey);returndefaultKey;}// 构建SpEL上下文:解析方法参数名+参数值StandardEvaluationContextcontext=newMethodBasedEvaluationContext(null,method,args,parameterNameDiscoverer);ObjectkeyObj=null;try{// 解析SpEL表达式,获取动态KeykeyObj=spelParser.parseExpression(spelKey).getValue(context);}catch(Exceptione){log.error("【自定义防抖切面】目标方法:{},解析SpEL表达式失败(表达式:{})",targetMethod,spelKey,e);}// 拼接最终Key:前缀+动态解析结果StringdynamicKey=debounce.prefix()+(keyObj==null?"":keyObj.toString());log.debug("【自定义防抖切面】目标方法:{},SpEL表达式解析完成,表达式:{},解析结果:{},最终Key:{}",targetMethod,spelKey,keyObj,dynamicKey);returndynamicKey;}}

关键解析

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

4. 业务接口接入示例

@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秒后再试"// 前端提示语)publicR<NameAndCardNumVO>getNameAndCardNum(@RequestParam@Parameter(description="小程序用户id")LongminiUserId){NameAndCardNumVOdetail=miniUserService.getNameAndCardNum(miniUserId);returnR.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默认已集成):

<!-- BladeX Redis依赖 --><dependency><groupId>org.springblade</groupId><artifactId>blade-core-redis</artifactId></dependency>

2. 配置Redis(application.yml)

spring:redis:host:127.0.0.1port:6379password:123456database:0timeout:5000ms

3. 快速接入步骤

步骤1:复制代码文件

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

步骤2:业务接口添加注解
@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、序列化优化、异常降级等设计思路,也是分布式系统开发中的高频考点,既满足业务需求,也适配面试场景的核心考点。

完整版源码

importjava.lang.annotation.*;importjava.util.concurrent.TimeUnit;/** * BladeX专属防抖注解 * 避免重复提交/重复操作 */@Target({ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic@interfaceDebounce{/** * 防抖Redis Key前缀(默认:blade:debounce:) */Stringprefix()default"blade:debounce:";/** * 防抖Key的拼接规则(支持SpEL表达式) * 示例: * - "#information.cardNo":取方法参数information的cardNo属性 * - "#userId":取方法参数userId * - "#root.args[0].id":取第一个参数的id属性 */Stringkey()default"";/** * 防抖过期时间(默认5秒) */longexpireTime()default5;/** * 时间单位(默认秒) */TimeUnittimeUnit()defaultTimeUnit.SECONDS;/** * 重复操作提示信息 */Stringmessage()default"操作过于频繁,请稍后再试";/** * 是否使用分布式锁(BladeX集群环境默认开启) */booleanuseDistributedLock()defaulttrue;/** * 分布式锁等待时间(默认0,立即返回) */longlockWaitTime()default0;}
/** * BladeX Redis防抖工具类 */@Slf4j@ComponentpublicclassBladeDebounceUtil{// ========== 配置常量 ==========/** * 默认防抖value(标识锁) */privatestaticfinalStringDEFAULT_DEBOUNCE_VALUE="1";/** * 默认过期时间(秒) */privatestaticfinallongDEFAULT_EXPIRE_SECONDS=5;/** * 日志开关(生产可关闭调试日志) */privatestaticfinalbooleanLOG_ENABLE=true;@ResourceprivateBladeRedisbladeRedis;/** * 全局RedisTemplate(只初始化1次) */privateRedisTemplate<String,Object>redisTemplate;/** * 全局ValueOperations(避免重复获取) */privateValueOperations<String,Object>valueOps;// ========== 初始化序列化器(只执行1次) ==========@PostConstructpublicvoidinit(){if(bladeRedis!=null){this.redisTemplate=bladeRedis.getRedisTemplate();// 序列化器全局配置(仅初始化1次,提升性能)if(this.redisTemplate!=null){StringRedisSerializerstringSerializer=newStringRedisSerializer();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秒) */publicbooleantryAcquire(Stringkey){returntryAcquire(key,DEFAULT_EXPIRE_SECONDS,TimeUnit.SECONDS);}/** * 重载:支持自定义value(便于区分不同业务的锁) */publicbooleantryAcquire(Stringkey,Stringvalue,longexpireTime,TimeUnittimeUnit){returninnerTryAcquire(key,value,expireTime,timeUnit);}// ========== 核心方法(优化逻辑) ==========/** * 尝试获取防抖锁(强制配置序列化器+双重保障过期时间) */publicbooleantryAcquire(Stringkey,longexpireTime,TimeUnittimeUnit){returninnerTryAcquire(key,DEFAULT_DEBOUNCE_VALUE,expireTime,timeUnit);}/** * 内部核心逻辑(抽离通用逻辑,便于维护) */privatebooleaninnerTryAcquire(Stringkey,Stringvalue,longexpireTime,TimeUnittimeUnit){// 1. 前置校验 + 降级逻辑(Redis异常时直接放行,避免影响主业务)try{Assert.hasText(key,"防抖Key不能为空");Assert.hasText(value,"防抖Value不能为空");}catch(IllegalArgumentExceptione){log.error("【防抖工具类】参数非法:{}",e.getMessage());returnfalse;}// Redis未初始化,降级为放行(不影响主业务)if(redisTemplate==null||valueOps==null){if(LOG_ENABLE){log.warn("【防抖工具类】Redis未初始化,防抖功能降级,直接放行Key:{}",key);}returntrue;}// 2. 校验过期时间longexpireSeconds=timeUnit.toSeconds(expireTime);if(expireSeconds<=0){if(LOG_ENABLE){log.error("【防抖工具类】过期时间非法,Key:{},时间:{}秒",key,expireSeconds);}returnfalse;}booleanlockSuccess=false;Longttl=-1L;try{// 第一步:原子判断并设置Key(带过期时间)BooleansetResult=valueOps.setIfAbsent(key,value,expireTime,timeUnit);lockSuccess=Boolean.TRUE.equals(setResult);// 第二步:双重保障(强制设置过期时间,防止第一步失效)if(lockSuccess){booleanexpireSuccess=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(Exceptione){// Redis异常时降级为放行,避免影响主业务log.error("【防抖工具类】获取锁异常,Key:{},异常信息:{}",key,e.getMessage(),e);returntrue;}// 优化日志:只在开启日志且获取锁失败时打印if(!lockSuccess&&LOG_ENABLE){StringttlDesc=switch(ttl.intValue()){case-1->"永久有效";case-2->"Key不存在";default->ttl+"秒";};log.warn("【防抖工具类】获取锁失败,Key:{},剩余过期时间:{}",key,ttlDesc);}returnlockSuccess;}/** * 手动释放防抖锁,增加异常捕获,避免释放失败影响主业务 */publicvoidreleaseLock(Stringkey){if(bladeRedis==null||key==null||key.isEmpty()){return;}try{bladeRedis.del(key);if(LOG_ENABLE){log.info("【防抖工具类】释放锁成功,Key:{}",key);}}catch(Exceptione){log.error("【防抖工具类】释放锁失败,Key:{},异常信息:{}",key,e.getMessage(),e);}}}
```javaimportjakarta.annotation.Resource;importlombok.extern.slf4j.Slf4j;importorg.aspectj.lang.ProceedingJoinPoint;importorg.aspectj.lang.annotation.Around;importorg.aspectj.lang.annotation.Aspect;importorg.aspectj.lang.annotation.Pointcut;importorg.aspectj.lang.reflect.MethodSignature;importorg.springblade.business.aspect.annotation.Debounce;importorg.springblade.business.util.BladeDebounceUtil;importorg.springblade.core.tool.api.R;importorg.springframework.context.expression.MethodBasedEvaluationContext;importorg.springframework.core.DefaultParameterNameDiscoverer;importorg.springframework.core.ParameterNameDiscoverer;importorg.springframework.core.annotation.Order;importorg.springframework.expression.ExpressionParser;importorg.springframework.expression.spel.standard.SpelExpressionParser;importorg.springframework.expression.spel.support.StandardEvaluationContext;importorg.springframework.stereotype.Component;importjava.lang.reflect.Method;/** * BladeX防抖注解切面 * 适配框架原生返回结果R */@Slf4j@Aspect@Component@Order(3)publicclassBladeDebounceAspect{@ResourceprivateBladeDebounceUtilbladeDebounceUtil;// SpEL表达式解析器privatefinalExpressionParserspelParser=newSpelExpressionParser();// 参数名解析器privatefinalParameterNameDiscovererparameterNameDiscoverer=newDefaultParameterNameDiscoverer();/** * 切入点:匹配所有标注@Debounce的方法 */@Pointcut("@annotation(org.springblade.business.aspect.annotation.Debounce)")publicvoiddebouncePointcut(){}/** * 环绕通知:实现防抖逻辑 */@Around("debouncePointcut()")publicObjectaround(ProceedingJoinPointjoinPoint)throwsThrowable{// 目标方法标识(类名.方法名)StringtargetMethod=joinPoint.getTarget().getClass().getSimpleName()+"."+joinPoint.getSignature().getName();log.info("【自定义防抖切面】开始执行,目标方法:{}",targetMethod);try{// 1. 获取方法和注解信息MethodSignaturesignature=(MethodSignature)joinPoint.getSignature();Methodmethod=signature.getMethod();Debouncedebounce=method.getAnnotation(Debounce.class);if(debounce==null){log.warn("【自定义防抖切面】目标方法:{},未解析到@Debounce注解,直接放行",targetMethod);returnjoinPoint.proceed();}log.info("【自定义防抖切面】目标方法:{},解析防抖注解成功,配置:前缀={},过期时间={}{},提示语={}",targetMethod,debounce.prefix(),debounce.expireTime(),debounce.timeUnit().name(),debounce.message());// 2. 生成防抖Key(前缀+动态Key)StringredisKey=generateRedisKey(joinPoint,debounce);log.info("【自定义防抖切面】目标方法:{},生成防抖RedisKey:{}",targetMethod,redisKey);if(redisKey.isEmpty()){log.error("【自定义防抖切面】目标方法:{},防抖Key生成失败(空值),拒绝执行",targetMethod);returnR.fail("防抖Key配置异常,请检查@Debounce注解");}// 环绕通知中,生成redisKey后新增longexpireSeconds=debounce.timeUnit().toSeconds(debounce.expireTime());if(expireSeconds<=0){log.error("【BladeX防抖切面】目标方法:{},过期时间配置错误({}秒),拒绝执行",targetMethod,expireSeconds);returnR.fail("限流配置异常,请联系管理员");}// 3. 使用BladeRedis校验防抖(原子操作)booleanacquireSuccess=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());returnR.fail(debounce.message());}// 5. 执行原方法log.info("【自定义防抖切面】目标方法:{},防抖校验通过,开始执行原方法",targetMethod);Objectresult=joinPoint.proceed();log.info("【自定义防抖切面】目标方法:{},原方法执行完成,返回结果类型:{}",targetMethod,result==null?"null":result.getClass().getSimpleName());returnresult;}catch(Throwablee){log.error("【自定义防抖切面】目标方法:{},执行过程中发生异常",targetMethod,e);throwe;// 抛出异常,交给BladeX全局异常处理器处理}finally{log.info("【自定义防抖切面】目标方法:{},切面执行结束",targetMethod);//可选:操作成功后手动释放锁(根据业务需求)//if (redisKey != null) {// bladeDebounceUtil.releaseLock(redisKey);// log.info("【BladeX防抖切面】目标方法:{},手动释放防抖锁,RedisKey:{}", targetMethod, redisKey);//}}}/** * 生成Redis Key */privateStringgenerateRedisKey(ProceedingJoinPointjoinPoint,Debouncedebounce){MethodSignaturesignature=(MethodSignature)joinPoint.getSignature();Methodmethod=signature.getMethod();Object[]args=joinPoint.getArgs();StringtargetMethod=method.getDeclaringClass().getSimpleName()+"."+method.getName();StringspelKey=debounce.key();if(spelKey.isEmpty()){StringdefaultKey=debounce.prefix()+method.getDeclaringClass().getSimpleName()+"_"+method.getName();log.debug("【自定义防抖切面】目标方法:{},未配置自定义SpEL Key,使用默认Key:{}",targetMethod,defaultKey);returndefaultKey;}// 创建SpEL上下文StandardEvaluationContextcontext=newMethodBasedEvaluationContext(null,method,args,parameterNameDiscoverer);ObjectkeyObj=null;try{keyObj=spelParser.parseExpression(spelKey).getValue(context);}catch(Exceptione){log.error("【自定义防抖切面】目标方法:{},解析SpEL表达式失败(表达式:{})",targetMethod,spelKey,e);}StringdynamicKey=debounce.prefix()+(keyObj==null?"":keyObj.toString());log.debug("【自定义防抖切面】目标方法:{},SpEL表达式解析完成,表达式:{},解析结果:{},最终Key:{}",targetMethod,spelKey,keyObj,dynamicKey);returndynamicKey;}}

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

直接在我们的接口上添加注解,可以自己指定参数,如果不知道,就用自定义注解的默认值,测试如下:
第一次请求接口的时候,接口可以正常响应,返回数据给前端:

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


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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2025/12/12 15:09:58

黑客技术水深!普通人不要随便碰

黑客技术的水到底有多深&#xff1f;普通人千万不要随便碰&#xff01; 如果你只是想做个脚本小子&#xff0c;学点WinNuke和NetBus这种黑客工具去装逼&#xff0c;那不会有什么事&#xff0c;顶多就是遇到懂行的人一眼看穿&#xff0c;然后被无尽嘲讽。 众所周知黑客是高收入群…

作者头像 李华
网站建设 2025/12/12 15:07:15

AI建议的C++基础入门顺序

以下是一个系统性的C基础学习目录顺序&#xff0c;适合从零开始逐步掌握&#xff1a;第一阶段&#xff1a;C基础入门&#xff08;1-2周&#xff09; 第1章 程序结构与编译环境C程序基本结构&#xff08;main()函数&#xff09;注释与编码规范编译器与IDE使用&#xff08;…

作者头像 李华
网站建设 2025/12/12 15:06:43

35、使用 Ansible 部署脚本化服务器环境

使用 Ansible 部署脚本化服务器环境 1. 运行 Playbook 若 /etc/ansible/ansible.cfg 文件已正确配置,用于处理主机认证,就可以使用 ansible-playbook 命令来运行 Playbook。默认情况下,该命令会使用 /etc/ansible/hosts 中列出的主机,但也可以使用 -i 参数指定其…

作者头像 李华
网站建设 2025/12/12 15:04:27

13、文件操作与命令全解析

文件操作与命令全解析 在日常的文件操作中,我们会用到各种各样的命令和工具,它们各有特点和用途。下面将详细介绍一些常见的文件操作命令及其使用方法。 1. 基本文件读取与操作 head 命令 :默认情况下, head 命令会打印命令行中每个文件的前 10 行,如果没有指定文件…

作者头像 李华
网站建设 2025/12/12 14:46:27

终极修复指南:彻底解决Atmosphere固件2168-0002启动错误

终极修复指南&#xff1a;彻底解决Atmosphere固件2168-0002启动错误 【免费下载链接】Atmosphere Atmosphre is a work-in-progress customized firmware for the Nintendo Switch. 项目地址: https://gitcode.com/GitHub_Trending/at/Atmosphere 如果你在使用Nintendo …

作者头像 李华