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

基于自定义注解+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. 执行流程
- 客户端调用接口 → 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:复制代码文件
将@Debounce、BladeDebounceUtil、BladeDebounceAspect三个类复制到项目对应包下;
步骤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秒之后在请求,又可以正常响应数据给前端,接口防抖成功。