SpringBoot接口防抖(防重復(fù)提交)的實(shí)現(xiàn)方案
前言
作為一名老碼農(nóng),在開發(fā)后端Java業(yè)務(wù)系統(tǒng),包括各種管理后臺(tái)和小程序等。在這些項(xiàng)目中,我設(shè)計(jì)過單/多租戶體系系統(tǒng),對(duì)接過許多開放平臺(tái),也搞過消息中心這類較為復(fù)雜的應(yīng)用,但幸運(yùn)的是,我至今還沒有遇到過線上系統(tǒng)由于代碼崩潰導(dǎo)致資損的情況。這其中的原因有三點(diǎn):一是業(yè)務(wù)系統(tǒng)本身并不復(fù)雜;二是我一直遵循某大廠代碼規(guī)約,在開發(fā)過程中盡可能按規(guī)約編寫代碼;三是經(jīng)過多年的開發(fā)經(jīng)驗(yàn)積累,我成為了一名熟練工,掌握了一些實(shí)用的技巧。
什么是防抖
所謂防抖,一是防用戶手抖,二是防網(wǎng)絡(luò)抖動(dòng)。在Web系統(tǒng)中,表單提交是一個(gè)非常常見的功能,如果不加控制,容易因?yàn)橛脩舻恼`操作或網(wǎng)絡(luò)延遲導(dǎo)致同一請(qǐng)求被發(fā)送多次,進(jìn)而生成重復(fù)的數(shù)據(jù)記錄。要針對(duì)用戶的誤操作,前端通常會(huì)實(shí)現(xiàn)按鈕的loading狀態(tài),阻止用戶進(jìn)行多次點(diǎn)擊。而對(duì)于網(wǎng)絡(luò)波動(dòng)造成的請(qǐng)求重發(fā)問題,僅靠前端是不行的。為此,后端也應(yīng)實(shí)施相應(yīng)的防抖邏輯,確保在網(wǎng)絡(luò)波動(dòng)的情況下不會(huì)接收并處理同一請(qǐng)求多次。
一個(gè)理想的防抖組件或機(jī)制,我覺得應(yīng)該具備以下特點(diǎn):
邏輯正確,也就是不能誤判;
響應(yīng)迅速,不能太慢;
易于集成,邏輯與業(yè)務(wù)解耦;
良好的用戶反饋機(jī)制,比如提示“您點(diǎn)擊的太快了”
思路解析
前面講了那么多,我們已經(jīng)知道接口的防抖是很有必要的了,但是在開發(fā)之前,我們需要捋清楚幾個(gè)問題。
哪一類接口需要防抖?
接口防抖也不是每個(gè)接口都需要加,一般需要加防抖的接口有這幾類:
用戶輸入類接口:比如搜索框輸入、表單輸入等,用戶輸入往往會(huì)頻繁觸發(fā)接口請(qǐng)求,但是每次觸發(fā)并不一定需要立即發(fā)送請(qǐng)求,可以等待用戶完成輸入一段時(shí)間后再發(fā)送請(qǐng)求。
按鈕點(diǎn)擊類接口:比如提交表單、保存設(shè)置等,用戶可能會(huì)頻繁點(diǎn)擊按鈕,但是每次點(diǎn)擊并不一定需要立即發(fā)送請(qǐng)求,可以等待用戶停止點(diǎn)擊一段時(shí)間后再發(fā)送請(qǐng)求。
滾動(dòng)加載類接口:比如下拉刷新、上拉加載更多等,用戶可能在滾動(dòng)過程中頻繁觸發(fā)接口請(qǐng)求,但是每次觸發(fā)并不一定需要立即發(fā)送請(qǐng)求,可以等待用戶停止?jié)L動(dòng)一段時(shí)間后再發(fā)送請(qǐng)求。
如何確定接口是重復(fù)的?
防抖也即防重復(fù)提交,那么如何確定兩次接口就是重復(fù)的呢?首先,我們需要給這兩次接口的調(diào)用加一個(gè)時(shí)間間隔,大于這個(gè)時(shí)間間隔的一定不是重復(fù)提交;其次,兩次請(qǐng)求提交的參數(shù)比對(duì),不一定要全部參數(shù),選擇標(biāo)識(shí)性強(qiáng)的參數(shù)即可;最后,如果想做的更好一點(diǎn),還可以加一個(gè)請(qǐng)求地址的對(duì)比。
分布式部署下如何做接口防抖?
有兩個(gè)方案:
使用共享緩存
流程圖如下:
使用分布式鎖
流程圖如下:
常見的分布式組件有Redis、Zookeeper等,但結(jié)合實(shí)際業(yè)務(wù)來看,一般都會(huì)選擇Redis,因?yàn)镽edis一般都是Web系統(tǒng)必備的組件,不需要額外搭建
具體實(shí)現(xiàn)
現(xiàn)在有一個(gè)保存用戶的接口
@PostMapping("/add") @RequiresPermissions(value = "add") @Log(methodDesc = "添加用戶") public ResponseEntity<String> add(@RequestBody AddReq addReq) { return userService.add(addReq); }
package com.summo.demo.model.request; import java.util.List; import lombok.Data; @Datapublic class AddReq { /** * 用戶名稱 */ private String userName; /** * 用戶手機(jī)號(hào) */ private String userPhone; /** * 角色I(xiàn)D列表 */ private List<Long> roleIdList;}
目前數(shù)據(jù)庫(kù)表中沒有對(duì)userPhone字段做UK索引,這就會(huì)導(dǎo)致每調(diào)用一次add就會(huì)創(chuàng)建一個(gè)用戶,即使userPhone相同。
請(qǐng)求鎖
根據(jù)上面的要求,我定了一個(gè)注解@RequestLock,使用方式很簡(jiǎn)單,把這個(gè)注解打在接口方法上即可。
RequestLock.java
package com.summo.demo.model.request; import java.util.List; import lombok.Data; @Data public class AddReq { /** * 用戶名稱 */ private String userName; /** * 用戶手機(jī)號(hào) */ private String userPhone; /** * 角色I(xiàn)D列表 */ private List<Long> roleIdList; }
@RequestLock注解定義了幾個(gè)基礎(chǔ)的屬性,redis鎖前綴、redis鎖時(shí)間、redis鎖時(shí)間單位、key分隔符。其中前面三個(gè)參數(shù)比較好理解,都是一個(gè)鎖的基本信息。key分隔符是用來將多個(gè)參數(shù)合并在一起的,比如userName是張三,userPhone是123456,那么完整的key就是"張三&123456",最后再加上redis鎖前綴,就組成了一個(gè)唯一key。
唯一key生成
這里有些人能就要說了,直接拿參數(shù)來生成key不就行了嗎?額,不是不行,但我想問一個(gè)問題:如果這個(gè)接口是文章發(fā)布的接口,你也打算把內(nèi)容當(dāng)做key嗎?要知道,Redis的效率跟key的大小息息相關(guān)。所以,我的建議是選取合適的字段作為key就行了,沒必要全都加上。
要做到參數(shù)可選,那么用注解的方式最好了,注解如下
RequestKeyParam.java
package com.example.requestlock.lock.annotation; import java.lang.annotation.*; /** * @description 加上這個(gè)注解可以將參數(shù)設(shè)置為key */ @Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface RequestKeyParam { }
這個(gè)注解加到參數(shù)上就行,沒有多余的屬性。
接下來就是lockKey的生成了,代碼如下RequestKeyGenerator.java
import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; public class RequestKeyGenerator { /** * 獲取LockKey * * @param joinPoint 切入點(diǎn) * @return */ public static String getLockKey(ProceedingJoinPoint joinPoint) { //獲取連接點(diǎn)的方法簽名對(duì)象 MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature(); //Method對(duì)象 Method method = methodSignature.getMethod(); //獲取Method對(duì)象上的注解對(duì)象 RequestLock requestLock = method.getAnnotation(RequestLock.class); //獲取方法參數(shù) final Object[] args = joinPoint.getArgs(); //獲取Method對(duì)象上所有的注解 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); //如果屬性不是RequestKeyParam注解,則不處理 if (keyParam == null) { continue; } //如果屬性是RequestKeyParam注解,則拼接 連接符 "& + RequestKeyParam" sb.append(requestLock.delimiter()).append(args[i]); } //如果方法上沒有加RequestKeyParam注解 if (StringUtils.isEmpty(sb.toString())) { //獲取方法上的多個(gè)注解(為什么是兩層數(shù)組:因?yàn)榈诙訑?shù)組是只有一個(gè)元素的數(shù)組) final Annotation[][] parameterAnnotations = method.getParameterAnnotations(); //循環(huán)注解 for (int i = 0; i < parameterAnnotations.length; i++) { final Object object = args[i]; //獲取注解類中所有的屬性字段 final Field[] fields = object.getClass().getDeclaredFields(); for (Field field : fields) { //判斷字段上是否有RequestKeyParam注解 final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class); //如果沒有,跳過 if (annotation == null) { continue; } //如果有,設(shè)置Accessible為true(為true時(shí)可以使用反射訪問私有變量,否則不能訪問私有變量) field.setAccessible(true); //如果屬性是RequestKeyParam注解,則拼接 連接符" & + RequestKeyParam" sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object)); } } } //返回指定前綴的key return requestLock.prefix() + sb; } } > 由于``@RequestKeyParam``可以放在方法的參數(shù)上,也可以放在對(duì)象的屬性上,所以這里需要進(jìn)行兩次判斷,一次是獲取方法上的注解,一次是獲取對(duì)象里面屬性上的注解。
重復(fù)提交判斷
Redis緩存方式
RedisRequestLockAspect.java
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.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 org.springframework.util.StringUtils; /** * @description 緩存實(shí)現(xiàn) */ @Aspect @Configuration @Order(2) public class RedisRequestLockAspect { private final StringRedisTemplate stringRedisTemplate; @Autowired public RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @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, "重復(fù)提交前綴不能為空"); } //獲取自定義key final String lockKey = RequestKeyGenerator.getLockKey(joinPoint); // 使用RedisCallback接口執(zhí)行set命令,設(shè)置鎖鍵;設(shè)置額外選項(xiàng):過期時(shí)間和SET_IF_ABSENT選項(xiàng) 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 (!success) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請(qǐng)稍后重試"); } try { return joinPoint.proceed(); } catch (Throwable throwable) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系統(tǒng)異常"); } } }
這里的核心代碼是stringRedisTemplate.execute里面的內(nèi)容,正如注釋里面說的“使用RedisCallback接口執(zhí)行set命令,設(shè)置鎖鍵;設(shè)置額外選項(xiàng):過期時(shí)間和SET_IF_ABSENT選項(xiàng)”,有些同學(xué)可能不太清楚SET_IF_ABSENT是個(gè)啥,這里我解釋一下:SET_IF_ABSENT是 RedisStringCommands.SetOption 枚舉類中的一個(gè)選項(xiàng),用于在執(zhí)行 SET 命令時(shí)設(shè)置鍵值對(duì)的時(shí)候,如果鍵不存在則進(jìn)行設(shè)置,如果鍵已經(jīng)存在,則不進(jìn)行設(shè)置。
Redisson分布式方式
Redisson分布式需要一個(gè)額外依賴,引入方式
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.10.6</version> </dependency>
由于我之前的代碼有一個(gè)RedisConfig,引入Redisson之后也需要單獨(dú)配置一下,不然會(huì)和RedisConfig沖突
RedissonConfig.java
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(); // 這里假設(shè)你使用單節(jié)點(diǎn)的Redis服務(wù)器 config.useSingleServer() // 使用與Spring Data Redis相同的地址 .setAddress("redis://127.0.0.1:6379"); // 如果有密碼 //.setPassword("xxxx"); // 其他配置參數(shù) //.setDatabase(0) //.setConnectionPoolSize(10) //.setConnectionMinimumIdleSize(2); // 創(chuàng)建RedissonClient實(shí)例 return Redisson.create(config); } }
配好之后,核心代碼如下RedissonRequestLockAspect.java
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; /** * @description 分布式鎖實(shí)現(xiàn) */ @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, "重復(fù)提交前綴不能為空"); } //獲取自定義key final String lockKey = RequestKeyGenerator.getLockKey(joinPoint); // 使用Redisson分布式鎖的方式判斷是否重復(fù)提交 RLock lock = redissonClient.getLock(lockKey); boolean isLocked = false; try { //嘗試搶占鎖 isLocked = lock.tryLock(); //沒有拿到鎖說明已經(jīng)有了請(qǐng)求了 if (!isLocked) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請(qǐng)稍后重試"); } //拿到鎖后設(shè)置過期時(shí)間 lock.lock(requestLock.expire(), requestLock.timeUnit()); try { return joinPoint.proceed(); } catch (Throwable throwable) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系統(tǒng)異常"); } } catch (Exception e) { throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請(qǐng)稍后重試"); } finally { //釋放鎖 if (isLocked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }
Redisson的核心思路就是搶鎖,當(dāng)一次請(qǐng)求搶到鎖之后,對(duì)鎖加一個(gè)過期時(shí)間,在這個(gè)時(shí)間段內(nèi)重復(fù)的請(qǐng)求是無法獲得這個(gè)鎖,也不難理解。
測(cè)試一下。
第一次提交,“添加用戶成功”
短時(shí)間內(nèi)重復(fù)提交,“BIZ-0001:您的操作太快了,請(qǐng)稍后重試”
過幾秒后再次提交,“添加用戶成功”
從測(cè)試的結(jié)果上看,防抖是做到了,但是隨著緩存消失、鎖失效,還是可以發(fā)起同樣的請(qǐng)求,所以要真正做到接口冪等性,還需要業(yè)務(wù)代碼的判斷、設(shè)置數(shù)據(jù)庫(kù)表的UK索引等操作。我在文章里面說到生成唯一key的時(shí)候沒有加用戶相關(guān)的信息,比如用戶ID、IP屬地等,真實(shí)生產(chǎn)環(huán)境建議加上這些,可以更好地減少誤判。
以上就是SpringBoot接口防抖(防重復(fù)提交)的實(shí)現(xiàn)方案的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot接口防抖的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
springboot的切面應(yīng)用方式(注解Aspect)
文章總結(jié):Spring?Boot提供了三種攔截器:Filter、Interceptor和Aspect,Filter主要用于內(nèi)容過濾和非登錄狀態(tài)的非法請(qǐng)求過濾,無法獲取Spring框架相關(guān)的信息,Interceptor可以在獲取請(qǐng)求類名、方法名的同時(shí),獲取請(qǐng)求參數(shù),但無法獲取參數(shù)值2024-11-11Java動(dòng)態(tài)驗(yàn)證碼單線設(shè)計(jì)的兩種方法
這篇文章主要介紹了Java動(dòng)態(tài)驗(yàn)證碼單線設(shè)計(jì)的兩種方法,需要的朋友可以參考下2018-07-07Java的可變參數(shù)與Collections類的功能示例解析
這篇文章主要為大家介紹了Java的可變參數(shù)與Collections類的功能示例解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-05-05Spring?boot?運(yùn)用策略模式實(shí)現(xiàn)避免多次使用if的操作代碼
這篇文章主要介紹了Spring?boot?運(yùn)用策略模式實(shí)現(xiàn),避免多次使用if,使用策略模式后,新加一種支付策略時(shí),只需要在策略枚舉中添加新加的策略信息,外加一個(gè)策略類即可,而不再需要添加新的if判斷,需要的朋友可以參考下2022-08-08詳解如何利用jasypt實(shí)現(xiàn)配置文件加密
Jasypt?(Java?Simplified?Encryption)?是一個(gè)?java?庫(kù),它允許開發(fā)人員以最小的成本將基本的加密功能添加到項(xiàng)目中,而無需深入了解密碼學(xué)的工作原理。本文將利用jasypt實(shí)現(xiàn)配置文件加密,感興趣的可以學(xué)習(xí)一下2022-07-07