《优化接口设计的思路》系列:第六篇—接口防抖(防重复提交)的一些方式

一、前言

大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

作为一名从业已达六年的老码农,我的工作主要是开发后端Java业务系统,包括各种管理后台和小程序等。在这些项目中,我设计过单/多租户体系系统,对接过许多开放平台,也搞过消息中心这类较为复杂的应用,但幸运的是,我至今还没有遇到过线上系统由于代码崩溃导致资损的情况。这其中的原因有三点:一是业务系统本身并不复杂;二是我一直遵循某大厂代码规约,在开发过程中尽可能按规约编写代码;三是经过多年的开发经验积累,我成为了一名熟练工,掌握了一些实用的技巧。

本文参考项目源码地址:summo-springboot-interface-demo

由于文章经常被抄袭,开源的代码甚至被当成收费项,所以源码里面不是全部代码,有需要的同学可以留个邮箱,我给你单独发!

二、啥是防抖

所谓防抖,一是防用户手抖,二是防网络抖动。在Web系统中,表单提交是一个非常常见的功能,如果不加控制,容易因为用户的误操作或网络延迟导致同一请求被发送多次,进而生成重复的数据记录。要针对用户的误操作,前端通常会实现按钮的loading状态,阻止用户进行多次点击。而对于网络波动造成的请求重发问题,仅靠前端是不行的。为此,后端也应实施相应的防抖逻辑,确保在网络波动的情况下不会接收并处理同一请求多次。

一个理想的防抖组件或机制,我觉得应该具备以下特点:

  1. 逻辑正确,也就是不能误判;
  2. 响应迅速,不能太慢;
  3. 易于集成,逻辑与业务解耦;
  4. 良好的用户反馈机制,比如提示“您点击的太快了”

三、思路解析

前面讲了那么多,我们已经知道接口的防抖是很有必要的了,但是在开发之前,我们需要捋清楚几个问题。

1. 哪一类接口需要防抖?

接口防抖也不是每个接口都需要加,一般需要加防抖的接口有这几类:

  • 用户输入类接口:比如搜索框输入、表单输入等,用户输入往往会频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户完成输入一段时间后再发送请求。

  • 按钮点击类接口:比如提交表单、保存设置等,用户可能会频繁点击按钮,但是每次点击并不一定需要立即发送请求,可以等待用户停止点击一段时间后再发送请求。

  • 滚动加载类接口:比如下拉刷新、上拉加载更多等,用户可能在滚动过程中频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户停止滚动一段时间后再发送请求。

2. 如何确定接口是重复的?

防抖也即防重复提交,那么如何确定两次接口就是重复的呢?首先,我们需要给这两次接口的调用加一个时间间隔,大于这个时间间隔的一定不是重复提交;其次,两次请求提交的参数比对,不一定要全部参数,选择标识性强的参数即可;最后,如果想做的更好一点,还可以加一个请求地址的对比。

3. 分布式部署下如何做接口防抖?

有两个方案:

(1)使用共享缓存

流程图如下:

(2)使用分布式锁

流程图如下:

常见的分布式组件有Redis、Zookeeper等,但结合实际业务来看,一般都会选择Redis,因为Redis一般都是Web系统必备的组件,不需要额外搭建。

四、具体实现

现在有一个保存用户的接口

1@PostMapping("/add") 2@RequiresPermissions(value = "add") 3@Log(methodDesc = "添加用户") 4public ResponseEntity<String> add(@RequestBody AddReq addReq) { 5 return userService.add(addReq); 6}

**AddReq.java **

1package com.summo.demo.model.request; 2 3import java.util.List; 4 5import lombok.Data; 6 7@Data 8public class AddReq { 9 10 /** 11 * 用户名称 12 */ 13 private String userName; 14 15 /** 16 * 用户手机号 17 */ 18 private String userPhone; 19 20 /** 21 * 角色ID列表 22 */ 23 private List<Long> roleIdList; 24}

目前数据库表中没有对userPhone字段做UK索引,这就会导致每调用一次add就会创建一个用户,即使userPhone相同。

1. 🔐请求锁

根据上面的要求,我定了一个注解@RequestLock,使用方式很简单,把这个注解打在接口方法上即可。 RequestLock.java

1import java.lang.annotation.Documented; 2import java.lang.annotation.ElementType; 3import java.lang.annotation.Inherited; 4import java.lang.annotation.Retention; 5import java.lang.annotation.RetentionPolicy; 6import java.lang.annotation.Target; 7import java.util.concurrent.TimeUnit; 8 9/** 10 * @description 请求防抖锁,用于防止前端重复提交导致的错误 11 */ 12@Target(ElementType.METHOD) 13@Retention(RetentionPolicy.RUNTIME) 14@Documented 15@Inherited 16public @interface RequestLock { 17 /** 18 * redis锁前缀 19 * 20 * @return 默认为空,但不可为空 21 */ 22 String prefix() default ""; 23 24 /** 25 * redis锁过期时间 26 * 27 * @return 默认2秒 28 */ 29 int expire() default 2; 30 31 /** 32 * redis锁过期时间单位 33 * 34 * @return 默认单位为秒 35 */ 36 TimeUnit timeUnit() default TimeUnit.SECONDS; 37 38 /** 39 * redis key分隔符 40 * 41 * @return 分隔符 42 */ 43 String delimiter() default "&"; 44}

@RequestLock注解定义了几个基础的属性,redis锁前缀、redis锁时间、redis锁时间单位、key分隔符。其中前面三个参数比较好理解,都是一个锁的基本信息。key分隔符是用来将多个参数合并在一起的,比如userName是张三,userPhone是123456,那么完整的key就是"张三&123456",最后再加上redis锁前缀,就组成了一个唯一key。

2. 唯一key生成

这里有些同学可能就要说了,直接拿参数来生成key不就行了吗? 额,不是不行,但我想问一个问题:如果这个接口是文章发布的接口,你也打算把内容当做key吗?要知道,Redis的效率跟key的大小息息相关。所以,我的建议是选取合适的字段作为key就行了,没必要全都加上

要做到参数可选,那么用注解的方式最好了,注解如下 RequestKeyParam.java

1package com.example.requestlock.lock.annotation; 2 3import java.lang.annotation.*; 4 5/** 6 * @description 加上这个注解可以将参数设置为key 7 */ 8@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD}) 9@Retention(RetentionPolicy.RUNTIME) 10@Documented 11@Inherited 12public @interface RequestKeyParam { 13 14}

这个注解加到参数上就行,没有多余的属性。

接下来就是lockKey的生成了,代码如下 RequestKeyGenerator.java

1import java.lang.annotation.Annotation; 2import java.lang.reflect.Field; 3import java.lang.reflect.Method; 4import java.lang.reflect.Parameter; 5 6import org.aspectj.lang.ProceedingJoinPoint; 7import org.aspectj.lang.reflect.MethodSignature; 8import org.springframework.util.ReflectionUtils; 9import org.springframework.util.StringUtils; 10 11public class RequestKeyGenerator { 12 /** 13 * 获取LockKey 14 * 15 * @param joinPoint 切入点 16 * @return 17 */ 18 public static String getLockKey(ProceedingJoinPoint joinPoint) { 19 //获取连接点的方法签名对象 20 MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature(); 21 //Method对象 22 Method method = methodSignature.getMethod(); 23 //获取Method对象上的注解对象 24 RequestLock requestLock = method.getAnnotation(RequestLock.class); 25 //获取方法参数 26 final Object[] args = joinPoint.getArgs(); 27 //获取Method对象上所有的注解 28 final Parameter[] parameters = method.getParameters(); 29 StringBuilder sb = new StringBuilder(); 30 for (int i = 0; i < parameters.length; i++) { 31 final RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class); 32 //如果属性不是RequestKeyParam注解,则不处理 33 if (keyParam == null) { 34 continue; 35 } 36 //如果属性是RequestKeyParam注解,则拼接 连接符 "& + RequestKeyParam" 37 sb.append(requestLock.delimiter()).append(args[i]); 38 } 39 //如果方法上没有加RequestKeyParam注解 40 if (StringUtils.isEmpty(sb.toString())) { 41 //获取方法上的多个注解(为什么是两层数组:因为第二层数组是只有一个元素的数组) 42 final Annotation[][] parameterAnnotations = method.getParameterAnnotations(); 43 //循环注解 44 for (int i = 0; i < parameterAnnotations.length; i++) { 45 final Object object = args[i]; 46 //获取注解类中所有的属性字段 47 final Field[] fields = object.getClass().getDeclaredFields(); 48 for (Field field : fields) { 49 //判断字段上是否有RequestKeyParam注解 50 final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class); 51 //如果没有,跳过 52 if (annotation == null) { 53 continue; 54 } 55 //如果有,设置Accessible为true(为true时可以使用反射访问私有变量,否则不能访问私有变量) 56 field.setAccessible(true); 57 //如果属性是RequestKeyParam注解,则拼接 连接符" & + RequestKeyParam" 58 sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object)); 59 } 60 } 61 } 62 //返回指定前缀的key 63 return requestLock.prefix() + sb; 64 } 65} 66> 由于``@RequestKeyParam``可以放在方法的参数上,也可以放在对象的属性上,所以这里需要进行两次判断,一次是获取方法上的注解,一次是获取对象里面属性上的注解。

3. 重复提交判断

(1)Redis缓存方式

RedisRequestLockAspect.java

1import java.lang.reflect.Method; 2import com.summo.demo.exception.biz.BizException; 3import com.summo.demo.model.response.ResponseCodeEnum; 4import org.aspectj.lang.ProceedingJoinPoint; 5import org.aspectj.lang.annotation.Around; 6import org.aspectj.lang.annotation.Aspect; 7import org.aspectj.lang.reflect.MethodSignature; 8import org.springframework.beans.factory.annotation.Autowired; 9import org.springframework.context.annotation.Configuration; 10import org.springframework.core.annotation.Order; 11import org.springframework.data.redis.connection.RedisStringCommands; 12import org.springframework.data.redis.core.RedisCallback; 13import org.springframework.data.redis.core.StringRedisTemplate; 14import org.springframework.data.redis.core.types.Expiration; 15import org.springframework.util.StringUtils; 16 17/** 18 * @description 缓存实现 19 */ 20@Aspect 21@Configuration 22@Order(2) 23public class RedisRequestLockAspect { 24 25 private final StringRedisTemplate stringRedisTemplate; 26 27 @Autowired 28 public RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) { 29 this.stringRedisTemplate = stringRedisTemplate; 30 } 31 32 @Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)") 33 public Object interceptor(ProceedingJoinPoint joinPoint) { 34 MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature(); 35 Method method = methodSignature.getMethod(); 36 RequestLock requestLock = method.getAnnotation(RequestLock.class); 37 if (StringUtils.isEmpty(requestLock.prefix())) { 38 throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空"); 39 } 40 //获取自定义key 41 final String lockKey = RequestKeyGenerator.getLockKey(joinPoint); 42 // 使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项 43 final Boolean success = stringRedisTemplate.execute( 44 (RedisCallback<Boolean>)connection -> connection.set(lockKey.getBytes(), new byte[0], 45 Expiration.from(requestLock.expire(), requestLock.timeUnit()), 46 RedisStringCommands.SetOption.SET_IF_ABSENT)); 47 if (!success) { 48 throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试"); 49 } 50 try { 51 return joinPoint.proceed(); 52 } catch (Throwable throwable) { 53 throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常"); 54 } 55 } 56}

这里的核心代码是stringRedisTemplate.execute里面的内容,正如注释里面说的“使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项”,有些同学可能不太清楚SET_IF_ABSENT是个啥,这里我解释一下:SET_IF_ABSENT 是 RedisStringCommands.SetOption 枚举类中的一个选项,用于在执行 SET 命令时设置键值对的时候,如果键不存在则进行设置,如果键已经存在,则不进行设置。

(2)Redisson分布式方式

Redisson分布式需要一个额外依赖,引入方式

1<dependency> 2 <groupId>org.redisson</groupId> 3 <artifactId>redisson-spring-boot-starter</artifactId> 4 <version>3.10.6</version> 5</dependency>

由于我之前的代码有一个RedisConfig,引入Redisson之后也需要单独配置一下,不然会和RedisConfig冲突 RedissonConfig.java

1 2import org.redisson.Redisson; 3import org.redisson.api.RedissonClient; 4import org.redisson.config.Config; 5import org.springframework.context.annotation.Bean; 6import org.springframework.context.annotation.Configuration; 7 8@Configuration 9public class RedissonConfig { 10 11 @Bean 12 public RedissonClient redissonClient() { 13 Config config = new Config(); 14 // 这里假设你使用单节点的Redis服务器 15 config.useSingleServer() 16 // 使用与Spring Data Redis相同的地址 17 .setAddress("redis://127.0.0.1:6379"); 18 // 如果有密码 19 //.setPassword("xxxx"); 20 // 其他配置参数 21 //.setDatabase(0) 22 //.setConnectionPoolSize(10) 23 //.setConnectionMinimumIdleSize(2); 24 // 创建RedissonClient实例 25 return Redisson.create(config); 26 } 27} 28

配好之后,核心代码如下 RedissonRequestLockAspect.java

1mport java.lang.reflect.Method; 2 3import com.summo.demo.exception.biz.BizException; 4import com.summo.demo.model.response.ResponseCodeEnum; 5import org.aspectj.lang.ProceedingJoinPoint; 6import org.aspectj.lang.annotation.Around; 7import org.aspectj.lang.annotation.Aspect; 8import org.aspectj.lang.reflect.MethodSignature; 9import org.redisson.api.RLock; 10import org.redisson.api.RedissonClient; 11import org.springframework.beans.factory.annotation.Autowired; 12import org.springframework.context.annotation.Configuration; 13import org.springframework.core.annotation.Order; 14import org.springframework.util.StringUtils; 15 16/** 17 * @description 分布式锁实现 18 */ 19@Aspect 20@Configuration 21@Order(2) 22public class RedissonRequestLockAspect { 23 24 private RedissonClient redissonClient; 25 26 @Autowired 27 public RedissonRequestLockAspect(RedissonClient redissonClient) { 28 this.redissonClient = redissonClient; 29 } 30 31 @Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)") 32 public Object interceptor(ProceedingJoinPoint joinPoint) { 33 MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature(); 34 Method method = methodSignature.getMethod(); 35 RequestLock requestLock = method.getAnnotation(RequestLock.class); 36 if (StringUtils.isEmpty(requestLock.prefix())) { 37 throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空"); 38 } 39 //获取自定义key 40 final String lockKey = RequestKeyGenerator.getLockKey(joinPoint); 41 // 使用Redisson分布式锁的方式判断是否重复提交 42 RLock lock = redissonClient.getLock(lockKey); 43 boolean isLocked = false; 44 try { 45 //尝试抢占锁 46 isLocked = lock.tryLock(); 47 //没有拿到锁说明已经有了请求了 48 if (!isLocked) { 49 throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试"); 50 } 51 //拿到锁后设置过期时间 52 lock.lock(requestLock.expire(), requestLock.timeUnit()); 53 try { 54 return joinPoint.proceed(); 55 } catch (Throwable throwable) { 56 throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常"); 57 } 58 } catch (Exception e) { 59 throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试"); 60 } finally { 61 //释放锁 62 if (isLocked && lock.isHeldByCurrentThread()) { 63 lock.unlock(); 64 } 65 } 66 67 } 68}

Redisson的核心思路就是抢锁,当一次请求抢到锁之后,对锁加一个过期时间,在这个时间段内重复的请求是无法获得这个锁,也不难理解。

4. 测试一下

  • 第一次提交,"添加用户成功"

  • 短时间内重复提交,"BIZ-0001:您的操作太快了,请稍后重试"

  • 过几秒后再次提交,"添加用户成功"

从测试的结果上看,防抖是做到了,但是随着缓存消失、锁失效,还是可以发起同样的请求,所以要真正做到接口幂等性,还需要业务代码的判断、设置数据库表的UK索引等操作。 我在文章里面说到生成唯一key的时候没有加用户相关的信息,比如用户ID、IP属地等,真实生产环境建议加上这些,可以更好地减少误判。

点赞
收藏

评论区

加载中...

相关推荐

浅聊函数防抖与节流

防抖(debounce)所谓防抖,就是指触发事件后n秒后才执行函数,如果在n秒内又触发了事件,则会重新计算函数执行时间。防抖类型分为1.非立即执行版2.立即执行版3.合成版本防抖防抖应用场景登录、发短信等按钮避免用户点击太快,以致于发送了多次请求调整浏览器窗口大小时,resize次数过于频繁,造成计算过多,此时需要一次到

记录 30 seconds of code 项目个人觉得中有价值的片段或者小技巧(二)

DF系列1、防抖函数,限制高频触发jsconstdebounce(fn,ms0)lettimeoutId;returnfunction(...args)clearTimeout(timeoutId);timeoutIdsetTimeout(()fn.apply(this,args),ms);

防抖和节流

防抖触发高频事件后n秒内函数只会执行一次,如果n秒内高频事件再次触发,则重新计算事件思路每次触发的时候取消之前的延时调用方法,以当下为准//防抖functiondebounce(fn){lettimeoutnull;returnfunction(){clearTimeout

js的防抖与节流

在进行窗口的resize、scroll,输入框内容校验等操作时,如果事件处理函数调用的频率无限制,会加重浏览器的负担,导致用户体验非常糟糕。此时我们可以采用debounce(防抖)和throttle(节流)的方式来减少调用频率,同时又不影响实际效果。函数防抖函数防抖(debounce):当持续触发事件时,一定时间段内没有再触发事件,事件处理函数才

2020年前端开发面试必考:什么是前端防抖?

防抖(去抖),以及节流(分流)在日常开发中可能用的不多,但在特定场景,却十分有用。!(https://oscimg.oschina.net/oscnet/dc9ed2e656be427d8f3324e57538aa13.png)最近有同学遇到了要做防抖的需求,那今天李老师就来讲解一下,什么是防抖。为了方便查阅和让不了解防抖

ASP.NET WebApi服务接口如何防止重复请求实现HTTP幂等性

一、背景描述与课程介绍明人不说暗话,跟着阿笨一起玩WebApi。在我们平时开发项目中可能会出现下面这些情况;1)、由于用户误操作,多次点击网页表单提交按钮。由于网速等原因造成页面卡顿,用户重复刷新提交页面。黑客或恶意用户使用postman等工具重复恶意提交表单(攻击网站)。这些情况都会导致表单重复提交,造成数据重复