最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

SpringBoot接口防抖(防重復(fù)提交)的實現(xiàn)方案

 更新時間:2024年04月17日 11:43:41   作者:會java的怪蜀黍  
所謂防抖,一是防用戶手抖,二是防網(wǎng)絡(luò)抖動,在Web系統(tǒng)中,表單提交是一個非常常見的功能,如果不加控制,容易因為用戶的誤操作或網(wǎng)絡(luò)延遲導(dǎo)致同一請求被發(fā)送多次,所以本文給大家介紹了SpringBoot接口防抖(防重復(fù)提交)的實現(xiàn)方案,需要的朋友可以參考下

前言

作為一名老碼農(nóng),在開發(fā)后端Java業(yè)務(wù)系統(tǒng),包括各種管理后臺和小程序等。在這些項目中,我設(shè)計過單/多租戶體系系統(tǒng),對接過許多開放平臺,也搞過消息中心這類較為復(fù)雜的應(yīng)用,但幸運的是,我至今還沒有遇到過線上系統(tǒng)由于代碼崩潰導(dǎo)致資損的情況。這其中的原因有三點:一是業(yè)務(wù)系統(tǒng)本身并不復(fù)雜;二是我一直遵循某大廠代碼規(guī)約,在開發(fā)過程中盡可能按規(guī)約編寫代碼;三是經(jīng)過多年的開發(fā)經(jīng)驗積累,我成為了一名熟練工,掌握了一些實用的技巧。

什么是防抖

在這里插入圖片描述

所謂防抖,一是防用戶手抖,二是防網(wǎng)絡(luò)抖動。在Web系統(tǒng)中,表單提交是一個非常常見的功能,如果不加控制,容易因為用戶的誤操作或網(wǎng)絡(luò)延遲導(dǎo)致同一請求被發(fā)送多次,進而生成重復(fù)的數(shù)據(jù)記錄。要針對用戶的誤操作,前端通常會實現(xiàn)按鈕的loading狀態(tài),阻止用戶進行多次點擊。而對于網(wǎng)絡(luò)波動造成的請求重發(fā)問題,僅靠前端是不行的。為此,后端也應(yīng)實施相應(yīng)的防抖邏輯,確保在網(wǎng)絡(luò)波動的情況下不會接收并處理同一請求多次。

一個理想的防抖組件或機制,我覺得應(yīng)該具備以下特點:

邏輯正確,也就是不能誤判;
響應(yīng)迅速,不能太慢;
易于集成,邏輯與業(yè)務(wù)解耦;
良好的用戶反饋機制,比如提示“您點擊的太快了”

思路解析

前面講了那么多,我們已經(jīng)知道接口的防抖是很有必要的了,但是在開發(fā)之前,我們需要捋清楚幾個問題。

哪一類接口需要防抖?

接口防抖也不是每個接口都需要加,一般需要加防抖的接口有這幾類:

用戶輸入類接口:比如搜索框輸入、表單輸入等,用戶輸入往往會頻繁觸發(fā)接口請求,但是每次觸發(fā)并不一定需要立即發(fā)送請求,可以等待用戶完成輸入一段時間后再發(fā)送請求。

按鈕點擊類接口:比如提交表單、保存設(shè)置等,用戶可能會頻繁點擊按鈕,但是每次點擊并不一定需要立即發(fā)送請求,可以等待用戶停止點擊一段時間后再發(fā)送請求。

滾動加載類接口:比如下拉刷新、上拉加載更多等,用戶可能在滾動過程中頻繁觸發(fā)接口請求,但是每次觸發(fā)并不一定需要立即發(fā)送請求,可以等待用戶停止?jié)L動一段時間后再發(fā)送請求。

如何確定接口是重復(fù)的?

防抖也即防重復(fù)提交,那么如何確定兩次接口就是重復(fù)的呢?首先,我們需要給這兩次接口的調(diào)用加一個時間間隔,大于這個時間間隔的一定不是重復(fù)提交;其次,兩次請求提交的參數(shù)比對,不一定要全部參數(shù),選擇標識性強的參數(shù)即可;最后,如果想做的更好一點,還可以加一個請求地址的對比。

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

有兩個方案:

使用共享緩存

流程圖如下:

在這里插入圖片描述

使用分布式鎖

流程圖如下:

在這里插入圖片描述

常見的分布式組件有Redis、Zookeeper等,但結(jié)合實際業(yè)務(wù)來看,一般都會選擇Redis,因為Redis一般都是Web系統(tǒng)必備的組件,不需要額外搭建

具體實現(xiàn)

現(xiàn)在有一個保存用戶的接口

@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;
    /**     * 用戶手機號     */    private String userPhone;
    /**     * 角色ID列表     */    private List<Long> roleIdList;}

目前數(shù)據(jù)庫表中沒有對userPhone字段做UK索引,這就會導(dǎo)致每調(diào)用一次add就會創(chuàng)建一個用戶,即使userPhone相同。

請求鎖

根據(jù)上面的要求,我定了一個注解@RequestLock,使用方式很簡單,把這個注解打在接口方法上即可。

RequestLock.java

package com.summo.demo.model.request;

import java.util.List;

import lombok.Data;

@Data
public class AddReq {

    /**
     * 用戶名稱
     */
    private String userName;

    /**
     * 用戶手機號
     */
    private String userPhone;

    /**
     * 角色ID列表
     */
    private List<Long> roleIdList;
}

@RequestLock注解定義了幾個基礎(chǔ)的屬性,redis鎖前綴、redis鎖時間、redis鎖時間單位、key分隔符。其中前面三個參數(shù)比較好理解,都是一個鎖的基本信息。key分隔符是用來將多個參數(shù)合并在一起的,比如userName是張三,userPhone是123456,那么完整的key就是"張三&123456",最后再加上redis鎖前綴,就組成了一個唯一key。

唯一key生成

這里有些人能就要說了,直接拿參數(shù)來生成key不就行了嗎?額,不是不行,但我想問一個問題:如果這個接口是文章發(fā)布的接口,你也打算把內(nèi)容當做key嗎?要知道,Redis的效率跟key的大小息息相關(guān)。所以,我的建議是選取合適的字段作為key就行了,沒必要全都加上。

要做到參數(shù)可選,那么用注解的方式最好了,注解如下

RequestKeyParam.java

package com.example.requestlock.lock.annotation;

import java.lang.annotation.*;

/**
 * @description 加上這個注解可以將參數(shù)設(shè)置為key
 */
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {

}

這個注解加到參數(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 切入點
     * @return
     */
    public static String getLockKey(ProceedingJoinPoint joinPoint) {
        //獲取連接點的方法簽名對象
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        //Method對象
        Method method = methodSignature.getMethod();
        //獲取Method對象上的注解對象
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        //獲取方法參數(shù)
        final Object[] args = joinPoint.getArgs();
        //獲取Method對象上所有的注解
        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())) {
            //獲取方法上的多個注解(為什么是兩層數(shù)組:因為第二層數(shù)組是只有一個元素的數(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時可以使用反射訪問私有變量,否則不能訪問私有變量)
                    field.setAccessible(true);
                    //如果屬性是RequestKeyParam注解,則拼接 連接符" & + RequestKeyParam"
                    sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));
                }
            }
        }
        //返回指定前綴的key
        return requestLock.prefix() + sb;
    }
}
> 由于``@RequestKeyParam``可以放在方法的參數(shù)上,也可以放在對象的屬性上,所以這里需要進行兩次判斷,一次是獲取方法上的注解,一次是獲取對象里面屬性上的注解。

重復(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 緩存實現(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è)置額外選項:過期時間和SET_IF_ABSENT選項
        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, "您的操作太快了,請稍后重試");
        }
        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è)置額外選項:過期時間和SET_IF_ABSENT選項”,有些同學(xué)可能不太清楚SET_IF_ABSENT是個啥,這里我解釋一下:SET_IF_ABSENT是 RedisStringCommands.SetOption 枚舉類中的一個選項,用于在執(zhí)行 SET 命令時設(shè)置鍵值對的時候,如果鍵不存在則進行設(shè)置,如果鍵已經(jīng)存在,則不進行設(shè)置。

Redisson分布式方式

Redisson分布式需要一個額外依賴,引入方式

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.10.6</version>
</dependency>

由于我之前的代碼有一個RedisConfig,引入Redisson之后也需要單獨配置一下,不然會和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é)點的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實例
        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 分布式鎖實現(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)有了請求了
            if (!isLocked) {
                throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請稍后重試");
            }
            //拿到鎖后設(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, "您的操作太快了,請稍后重試");
        } finally {
            //釋放鎖
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }

    }
}

Redisson的核心思路就是搶鎖,當一次請求搶到鎖之后,對鎖加一個過期時間,在這個時間段內(nèi)重復(fù)的請求是無法獲得這個鎖,也不難理解。

測試一下。

第一次提交,“添加用戶成功”

在這里插入圖片描述

短時間內(nèi)重復(fù)提交,“BIZ-0001:您的操作太快了,請稍后重試”

在這里插入圖片描述

過幾秒后再次提交,“添加用戶成功”

在這里插入圖片描述

從測試的結(jié)果上看,防抖是做到了,但是隨著緩存消失、鎖失效,還是可以發(fā)起同樣的請求,所以要真正做到接口冪等性,還需要業(yè)務(wù)代碼的判斷、設(shè)置數(shù)據(jù)庫表的UK索引等操作。我在文章里面說到生成唯一key的時候沒有加用戶相關(guān)的信息,比如用戶ID、IP屬地等,真實生產(chǎn)環(huán)境建議加上這些,可以更好地減少誤判。

以上就是SpringBoot接口防抖(防重復(fù)提交)的實現(xiàn)方案的詳細內(nèi)容,更多關(guān)于SpringBoot接口防抖的資料請關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

  • Java利用Spire.XLS?for?Java實現(xiàn)查找并替換Excel中的數(shù)據(jù)

    Java利用Spire.XLS?for?Java實現(xiàn)查找并替換Excel中的數(shù)據(jù)

    在日常的數(shù)據(jù)處理工作中,Excel?文件無疑是最常見的載體之一,本文將深入探討如何借助?Java?語言和強大的?Spire.XLS?for?Java?自動化處理?Excel?文件的查找替換任務(wù),感興趣的小伙伴可以了解下
    2025-09-09
  • 從零搭建Spring Boot腳手架整合OSS作為文件服務(wù)器的詳細教程

    從零搭建Spring Boot腳手架整合OSS作為文件服務(wù)器的詳細教程

    這篇文章主要介紹了從零搭建Spring Boot腳手架整合OSS作為文件服務(wù)器的詳細教程,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下
    2020-08-08
  • Java中的異常處理機制介紹(非常全面!)

    Java中的異常處理機制介紹(非常全面!)

    異??赡苁窃诔绦驁?zhí)行過程中產(chǎn)生的,也可能是程序中throw主動拋出的,下面這篇文章主要給大家介紹了關(guān)于Java中異常處理機制的相關(guān)資料,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下
    2023-01-01
  • Sharding-Proxy基本功能用法介紹

    Sharding-Proxy基本功能用法介紹

    這篇文章介紹了Sharding-Proxy基本功能用法,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧
    2022-02-02
  • Java包裝類之自動裝箱與拆箱

    Java包裝類之自動裝箱與拆箱

    這篇文章主要介紹了Java包裝類之自動裝箱與拆箱,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下
    2021-09-09
  • Java中Map集合(接口)的基本方法程序演示

    Java中Map集合(接口)的基本方法程序演示

    這篇文章主要為大家詳細介紹了Java中Map集合的基本方法程序演示,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下
    2018-08-08
  • 實例詳解Java8函數(shù)式接口

    實例詳解Java8函數(shù)式接口

    本文給大家分析了Java8默認方法和函數(shù)式接口實例其它創(chuàng)建方式,需要的朋友跟著學(xué)習(xí)下吧。
    2017-11-11
  • JAVA學(xué)習(xí)筆記:注釋、變量的聲明和定義操作實例分析

    JAVA學(xué)習(xí)筆記:注釋、變量的聲明和定義操作實例分析

    這篇文章主要介紹了JAVA學(xué)習(xí)筆記:注釋、變量的聲明和定義操作,結(jié)合實例形式分析了Java注釋、變量的聲明和定義相關(guān)原理、實現(xiàn)方法及操作注意事項,需要的朋友可以參考下
    2020-04-04
  • idea數(shù)據(jù)庫驅(qū)動下載失敗的問題及解決

    idea數(shù)據(jù)庫驅(qū)動下載失敗的問題及解決

    這篇文章主要介紹了idea數(shù)據(jù)庫驅(qū)動下載失敗的問題及解決方案,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教
    2024-01-01
  • 在SSM框架中將圖片上傳到數(shù)據(jù)庫中的實現(xiàn)代碼

    在SSM框架中將圖片上傳到數(shù)據(jù)庫中的實現(xiàn)代碼

    這篇文章主要介紹了在SSM框架中將圖片上傳到數(shù)據(jù)庫中的實現(xiàn)代碼,本文通過實例代碼給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下
    2021-03-03

最新評論

凭祥市| 浦东新区| 浦江县| 繁昌县| 深州市| 荥经县| 清流县| 来宾市| 泌阳县| 开江县| 河西区| 广灵县| 保靖县| 阳春市| 镇巴县| 靖远县| 广水市| 滨州市| 襄垣县| 宁强县| 平原县| 专栏| 中牟县| 新密市| 同心县| 博湖县| 报价| 繁昌县| 澄迈县| 青海省| 钟山县| 沙坪坝区| 西昌市| 桐城市| 茌平县| 新密市| 兰溪市| 常德市| 诸暨市| 安塞县| 张家港市|