一、啥是防抖
所谓防抖,一是防用户手抖,二是防网络抖动。
在Web系统中,表单提交是一个非常常见的功能,如果不加控制,容易因为用户的误操作或网络延迟导致同一请求被发送多次,进而生成重复的数据记录
。要针对用户的误操作,前端通常会实现按钮的loading
状态,阻止用户进行多次点击。而对于网络波动造成的请求重发问题,仅靠前端是不行的。为此,后端也应实施相应的防抖逻辑,确保在网络波动的情况下不会接收并处理同一请求多次。
一个理想的防抖组件或机制,应该具备以下特点:
- 逻辑正确,也就是不能误判;
- 响应迅速,不能太慢;
- 易于集成,逻辑与业务解耦;
- 良好的用户反馈机制,比如提示“您点击的太快了”
二、思路解析
哪一类接口需要防抖?
接口防抖也不是每个接口都需要加,一般需要加防抖的接口有这几类:
- 用户输入类接口:比如搜索框输入、表单输入等,用户输入往往会频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户完成输入一段时间后再发送请求。
- 按钮点击类接口:比如提交表单、保存设置等,用户可能会频繁点击按钮,但是每次点击并不一定需要立即发送请求,可以等待用户停止点击一段时间后再发送请求。
- 滚动加载类接口:比如下拉刷新、上拉加载更多等,用户可能在滚动过程中频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户停止滚动一段时间后再发送请求。
如何确定接口是重复的?
防抖也即防重复提交,那么如何确定两次接口就是重复的呢?首先,我们需要给这两次接口的调用加一个时间间隔,大于这个时间间隔的一定不是重复提交;其次,两次请求提交的参数比对,不一定要全部参数,选择标识性强的参数即可;最后,如果想做的更好一点,还可以加一个请求地址的对比。
三、如何做接口防抖
适用于分布式部署服务的场景。
1. 使用共享缓存
2. 使用分布式锁
常见的分布式组件有Redis、Zookeeper等,但结合实际业务来看,一般都会选择Redis,因为Redis一般都是Web系统必备的组件,不需要额外搭建。
四、具体实现 - 基于springboot
4.1 请求锁
通过自定义注解@RequestLock
,使用方式很简单,把这个注解加在接口方法上即可。
@RequestLock
注解定义了几个基础的属性,redis锁前缀、redis锁时间、redis锁时间单位、key分隔符。
其中前面三个参数比较好理解,都是一个锁的基本信息。
key分隔符是用来将多个参数合并在一起的,比如userName是张三,userPhone是123456,那么完整的key就是”张三&123456”,最后再加上redis锁前缀,就组成了一个唯一key。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface RequestLock {
String prefix() default "";
int expire() default 2;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String delimiter() default "&"; }
|
eg:
1 2 3 4 5 6
| @PostMapping @RequestLock(prefix = "agreement", expire = 5) public R<Boolean> add(@RequestBody Agreement agreement) { return R.success(agreementService.save(agreement)); }
|
4.2 唯一key生成
那直接拿参数来生成key不就行了吗?不是不行,但:如果这个接口是文章发布的接口,你也打算把内容当做key吗?要知道,Redis的效率跟key的大小息息相关。所以,最好是选取合适的字段作为key就行了,没必要全都加上
。
要做到参数可选,就使用该 @RequestKeyParam 注解加在入参参数前 或 实体类中的字段上:
1 2 3 4 5 6 7 8 9 10 11 12
| import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface RequestKeyParam {
}
|
这个注解加到参数上就行,没有多余的属性。
eg:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Data @NoArgsConstructor @AllArgsConstructor @ApiModel("协议") public class Agreement {
@ApiModelProperty("协议名称") @RequestKeyParam private String name;
@ApiModelProperty("所属分类") @RequestKeyParam private Long type;
}
|
接下来就是lockKey的生成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| import com.ruoyi.common.annotation.RequestKeyParam; import com.ruoyi.common.annotation.RequestLock; import com.ruoyi.common.utils.StringUtils; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.util.ReflectionUtils;
import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Parameter;
public class RequestKeyGenerator {
public static String getLockKey(ProceedingJoinPoint joinPoint) { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method method = methodSignature.getMethod(); RequestLock requestLock = method.getAnnotation(RequestLock.class); final Object[] args = joinPoint.getArgs(); final Parameter[] parameters = method.getParameters(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < parameters.length; i++) { final RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class); if (keyParam == null) { continue; } sb.append(requestLock.delimiter()).append(args[i]); } if (StringUtils.isEmpty(sb.toString())) { final Annotation[][] parameterAnnotations = method.getParameterAnnotations(); for (int i = 0; i < parameterAnnotations.length; i++) { final Object object = args[i]; final Field[] fields = object.getClass().getDeclaredFields(); for (Field field : fields) { final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class); if (annotation == null) { continue; } field.setAccessible(true); sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object)); } } } return requestLock.prefix() + sb; } }
|
4.3 重复提交判断
1) redis 缓存方式
- AOP 切面:RedisRequestLockAspect
核心代码是stringRedisTemplate.execute里面的内容,正如注释里面说的“使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项
”。
SET_IF_ABSENT
是 RedisStringCommands.SetOption 枚举类中的一个选项,用于在执行 SET 命令时设置键值对的时候,如果键不存在则进行设置,如果键已经存在,则不进行设置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| import com.ruoyi.common.annotation.RequestLock; import com.ruoyi.common.exception.base.BaseException; import com.ruoyi.common.utils.StringUtils; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.data.redis.connection.RedisStringCommands; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.types.Expiration;
import java.lang.reflect.Method;
@Aspect @Configuration @Order(2) public class RedisRequestLockAspect {
private final StringRedisTemplate stringRedisTemplate;
@Autowired public RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; }
@Around("execution(public * * (..)) && @annotation(com.ruoyi.common.annotation.RequestLock)") public Object interceptor(ProceedingJoinPoint joinPoint) throws Exception { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method method = methodSignature.getMethod(); RequestLock requestLock = method.getAnnotation(RequestLock.class); if (StringUtils.isEmpty(requestLock.prefix())) { throw new BaseException("重复提交前缀不能为空"); } final String lockKey = RequestKeyGenerator.getLockKey(joinPoint); final Boolean success = stringRedisTemplate.execute( (RedisCallback<Boolean>) connection -> connection.set(lockKey.getBytes(), new byte[0], Expiration.from(requestLock.expire(), requestLock.timeUnit()), RedisStringCommands.SetOption.SET_IF_ABSENT)); if (Boolean.FALSE.equals(success)) { throw new BaseException("您的操作太快了,请稍后重试"); } try { return joinPoint.proceed(); } catch (Throwable throwable) { throw new BaseException("系统异常"); } } }
|
- 统一异常处理:BaseException(…) → @RestControllerAdvice
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
@RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BaseException.class) public R<String> handleBaseException(BaseException e, HttpServletRequest request) { log.error(e.getMessage(), e); return R.error(e.getMessage()); } }
|
第一次请求:
5s内第二次请求:
2) redisson 分布式方式
Redisson的核心思路就是抢锁,当一次请求抢到锁之后,对锁加一个过期时间,在这个时间段内重复的请求是无法获得这个锁,也不难理解。
Redisson分布式需要一个额外依赖,引入方式
1 2 3 4 5
| <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.10.6</version> </dependency>
|
如果之前的代码有一个RedisConfig,引入Redisson之后也需要单独配置一下,不然会冲突:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;
@Configuration public class RedissonConfig {
@Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379"); return Redisson.create(config); } }
|
配好之后,核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| import java.lang.reflect.Method;
import com.summo.demo.exception.biz.BizException; import com.summo.demo.model.response.ResponseCodeEnum; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.util.StringUtils;
@Aspect @Configuration @Order(2) public class RedissonRequestLockAspect { private RedissonClient redissonClient;
@Autowired public RedissonRequestLockAspect(RedissonClient redissonClient) { this.redissonClient = redissonClient; }
@Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)") public Object interceptor(ProceedingJoinPoint joinPoint) { MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature(); Method method = methodSignature.getMethod(); RequestLock requestLock = method.getAnnotation(RequestLock.class); if (StringUtils.isEmpty(requestLock.prefix())) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空"); } final String lockKey = RequestKeyGenerator.getLockKey(joinPoint); RLock lock = redissonClient.getLock(lockKey); boolean isLocked = false; try { isLocked = lock.tryLock(); if (!isLocked) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试"); } lock.lock(requestLock.expire(), requestLock.timeUnit()); try { return joinPoint.proceed(); } catch (Throwable throwable) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常"); } } catch (Exception e) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试"); } finally { if (isLocked && lock.isHeldByCurrentThread()) { lock.unlock(); } }
} }
|