當前位置: 妍妍網 > 碼農

SpringBoot 介面防抖(防重復送出)的一些實作方案

2024-04-26碼農

點選關註公眾號,Java幹貨 及時送達 👇

作為一名老碼農,在開發後端Java業務系統,包括各種管理後台和小程式等。在這些計畫中,我設計過單/多租戶體系系統,對接過許多開放平台,也搞過訊息中心這類較為復雜的套用,但幸運的是,我至今還沒有遇到過線上系統由於程式碼崩潰導致資損的情況。這其中的原因有三點:

  • 一是業務系統本身並不復雜;

  • 二是我一直遵循某大廠程式碼規約,在開發過程中盡可能按規約編寫程式碼;

  • 三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。

  • 啥是防抖

    所謂防抖,一是防使用者手抖,二是防網路抖動。在Web系統中,表單送出是一個非常常見的功能,如果不加控制,容易因為使用者的誤操作或網路延遲導致同一請求被發送多次,進而生成重復的數據記錄。要針對使用者的誤操作,前端通常會實作按鈕的loading狀態,阻止使用者進行多次點選。而對於網路波動造成的請求重發問題,僅靠前端是不行的。為此,後端也應實施相應的防抖邏輯,確保在網路波動的情況下不會接收並處理同一請求多次。

    一個理想的防抖元件或機制,我覺得應該具備以下特點:

  • 邏輯正確,也就是不能誤判;

  • 響應迅速,不能太慢;

  • 易於整合,邏輯與業務解耦;

  • 良好的使用者反饋機制,比如提示「您點選的太快了」

  • 思路解析

    前面講了那麽多,我們已經知道介面的防抖是很有必要的了,但是在開發之前,我們需要捋清楚幾個問題。

    哪一類介面需要防抖?

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

  • 使用者輸入類介面:比如搜尋框輸入、表單輸入等,使用者輸入往往會頻繁觸發介面請求,但是每次觸發並不一定需要立即發送請求,可以等待使用者完成輸入一段時間後再發送請求。

  • 按鈕點選類介面:比如送出表單、保存設定等,使用者可能會頻繁點選按鈕,但是每次點選並不一定需要立即發送請求,可以等待使用者停止點選一段時間後再發送請求。

  • 捲動載入類介面:比如下拉重新整理、上拉載入更多等,使用者可能在捲動過程中頻繁觸發介面請求,但是每次觸發並不一定需要立即發送請求,可以等待使用者停止捲動一段時間後再發送請求。

  • 如何確定介面是重復的?

    防抖也即防重復送出,那麽如何確定兩次介面就是重復的呢?首先,我們需要給這兩次介面的呼叫加一個時間間隔,大於這個時間間隔的一定不是重復送出;其次,兩次請求送出的參數比對,不一定要全部參數,選擇標識性強的參數即可;最後,如果想做的更好一點,還可以加一個請求地址的對比。

    分布式部署下如何做介面防抖?

    有兩個方案:

    使用共享緩存

    流程圖如下:

    使用分布式鎖

    流程圖如下:

    常見的分布式元件有Redis、Zookeeper等,但結合實際業務來看,一般都會選擇Redis,因為Redis一般都是Web系統必備的元件,不需要額外搭建。


    具體實作

    現在有一個保存使用者的介面

    @PostMapping("/add")
    @RequiresPermissions(value = "add")
    @Log(methodDesc = "添加使用者")
    public ResponseEntity<String> add(@RequestBody AddReq addReq){
    return userService.add(addReq);
    }

    AddReq.java

    package com.summo.demo.model.request;
    import java.util.List;
    import lombok.Data;
    @Datapublic classAddReq{
    /** * 使用者名稱稱 */private String userName;
    /** * 使用者手機號 */private String userPhone;
    /** * 角色ID列表 */private List<Long> roleIdList;}

    目前資料庫表中沒有對userPhone欄位做UK索引,這就會導致每呼叫一次add就會建立一個使用者,即使userPhone相同。

    請求鎖

    根據上面的要求,我定了一個註解@RequestLock,使用方式很簡單,把這個註解打在介面方法上即可。

    RequestLock.java

    package com.summo.demo.model.request;
    import java.util.List;
    import lombok.Data;
    @Data
    public classAddReq{
    /**
    * 使用者名稱稱
    */

    private String userName;
    /**
    * 使用者手機號
    */

    private String userPhone;
    /**
    * 角色ID列表
    */

    private List<Long> roleIdList;
    }




    @RequestLock註解定義了幾個基礎的內容,redis鎖字首、redis鎖時間、redis鎖時間單位、key分隔符。其中前面三個參數比較好理解,都是一個鎖的基本資訊。key分隔符是用來將多個參數合並在一起的,比如userName是張三,userPhone是123456,那麽完整的key就是"張三&123456",最後再加上redis鎖字首,就組成了一個唯一key。

    唯一key生成

    這裏有些同學可能就要說了,直接拿參數來生成key不就行了嗎?額,不是不行,但我想問一個問題:如果這個介面是文章釋出的介面,你也打算把內容當做key嗎?要知道,Redis的效率跟key的大小息息相關。所以,我的建議是選取合適的欄位作為key就行了,沒必要全都加上。

    要做到參數可選,那麽用註解的方式最好了,註解如下RequestKeyParam.java

    package com.example.requestlock.lock.annotation;
    import java.lang.annotation.*;
    /**
     * @description 加上這個註解可以將參數設定為key
     */

    @Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Inherited
    public@interface RequestKeyParam {
    }

    這個註解加到參數上就行,沒有多余的內容。

    接下來就是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 classRequestKeyGenerator{
    /**
    * 獲取LockKey
    *
    @param joinPoint 切入點
    @return
    */

    publicstatic String getLockKey(ProceedingJoinPoint joinPoint){
    //獲取連線點的方法簽名物件
    MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
    //Method物件
    Method method = methodSignature.getMethod();
    //獲取Method物件上的註解物件
    RequestLock requestLock = method.getAnnotation(RequestLock. class);
    //獲取方法參數
    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())) {
    //獲取方法上的多個註解(為什麽是兩層陣列:因為第二層陣列是只有一個元素的陣列)
    final Annotation[][] parameterAnnotations = method.getParameterAnnotations();
    //迴圈註解
    for (int i = 0; i < parameterAnnotations.length; i++) {
    final Object object = args[i];
    //獲取註解類中所有的內容欄位
    final Field[] fields = object.get class().getDeclaredFields();
    for (Field field : fields) {
    //判斷欄位上是否有RequestKeyParam註解
    final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam. class);
    //如果沒有,跳過
    if (annotation == null) {
    continue;
    }
    //如果有,設定Accessible為true(為true時可以使用反射存取私有變量,否則不能存取私有變量)
    field.setAccessible(true);
    //如果內容是RequestKeyParam註解,則拼接 連線符" & + RequestKeyParam"
    sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));
    }
    }
    }
    //返回指定字首的key
    return requestLock.prefix() + sb;
    }
    }

    由於 @RequestKeyParam 可以放在方法的參數上,也可以放在物件的內容上,所以這裏需要進行兩次判斷,一次是獲取方法上的註解,一次是獲取物件裏面內容上的註解。

    重復送出判斷

    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 緩存實作
     */

    @Aspect
    @Configuration
    @Order(2)
    public classRedisRequestLockAspect{
    privatefinal StringRedisTemplate stringRedisTemplate;
    @Autowired
    publicRedisRequestLockAspect(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())) {
    thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重復送出字首不能為空");
    }
    //獲取自訂key
    final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
    // 使用RedisCallback介面執行set命令,設定鎖鍵;設定額外選項:過期時間和SET_IF_ABSENT選項
    final Boolean success = stringRedisTemplate.execute(
    (RedisCallback<Boolean>)connection -> connection.set(lockKey.getBytes(), newbyte[0],
    Expiration.from(requestLock.expire(), requestLock.timeUnit()),
    RedisStringCommands.SetOption.SET_IF_ABSENT));
    if (!success) {
    thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請稍後重試");
    }
    try {
    return joinPoint.proceed();
    catch (Throwable throwable) {
    thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系統異常");
    }
    }
    }



    這裏的核心程式碼是stringRedisTemplate.execute裏面的內容,正如註釋裏面說的「使用RedisCallback介面執行set命令,設定鎖鍵;設定額外選項:過期時間和SET_IF_ABSENT選項」,有些同學可能不太清楚SET_IF_ABSENT是個啥,這裏我解釋一下:SET_IF_ABSENT是 RedisStringCommands.SetOption 列舉類中的一個選項,用於在執行 SET 命令時設定鍵值對的時候,如果鍵不存在則進行設定,如果鍵已經存在,則不進行設定。

    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 classRedissonConfig{
    @Bean
    public RedissonClient redissonClient(){
    Config config = new Config();
    // 這裏假設你使用單節點的Redis伺服器
    config.useSingleServer()
    // 使用與Spring Data Redis相同的地址
    .setAddress("redis://127.0.0.1:6379");
    // 如果有密碼
    //.setPassword("xxxx");
    // 其他配置參數
    //.setDatabase(0)
    //.setConnectionPoolSize(10)
    //.setConnectionMinimumIdleSize(2);
    // 建立RedissonClient例項
    return Redisson.create(config);
    }
    }

    配好之後,核心程式碼如下RedissonRequestLockAspect.java

    mport 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 分布式鎖實作
     */

    @Aspect
    @Configuration
    @Order(2)
    public classRedissonRequestLockAspect{
    private RedissonClient redissonClient;
    @Autowired
    publicRedissonRequestLockAspect(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())) {
    thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重復送出字首不能為空");
    }
    //獲取自訂key
    final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
    // 使用Redisson分布式鎖的方式判斷是否重復送出
    RLock lock = redissonClient.getLock(lockKey);
    boolean isLocked = false;
    try {
    //嘗試搶占鎖
    isLocked = lock.tryLock();
    //沒有拿到鎖說明已經有了請求了
    if (!isLocked) {
    thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請稍後重試");
    }
    //拿到鎖後設定過期時間
    lock.lock(requestLock.expire(), requestLock.timeUnit());
    try {
    return joinPoint.proceed();
    catch (Throwable throwable) {
    thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系統異常");
    }
    catch (Exception e) {
    thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,請稍後重試");
    finally {
    //釋放鎖
    if (isLocked && lock.isHeldByCurrentThread()) {
    lock.unlock();
    }
    }
    }
    }



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

    測試一下。

    第一次送出,"添加使用者成功"

    短時間內重復送出,"BIZ-0001:您的操作太快了,請稍後重試"

    過幾秒後再次送出,"添加使用者成功"

    從測試的結果上看,防抖是做到了,但是隨著緩存消失、鎖失效,還是可以發起同樣的請求,所以要真正做到介面冪等性,還需要業務程式碼的判斷、設定資料庫表的UK索引等操作。我在文章裏面說到生成唯一key的時候沒有加使用者相關的資訊,比如使用者ID、IP屬地等,真實生產環境建議加上這些,可以更好地減少誤判。

    來源:juejin.cn/post/7308992638468227109


    END


    看完本文有收獲?請轉發分享給更多人

    關註「Java編程鴨」,提升Java技能

    關註Java編程鴨微信公眾號,後台回復:碼農大禮包可以獲取最新整理的技術資料一份。涵蓋Java 框架學習、架構師學習等!

    文章有幫助的話,在看,轉發吧。

    謝謝支持喲 (*^__^*)