springboot接口防抖【防重复提交】

news2024/11/19 19:19:32

什么是防抖

在这里插入图片描述

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

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

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

哪一类接口需要防抖?

一般需要加防抖的接口有这几类:

  • 用户输入类接口:比如搜索框输入、表单输入等,用户输入往往会频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户完成输入一段时间后再发送请求。
  • 按钮点击类接口:比如提交表单、保存设置等,用户可能会频繁点击按钮,但是每次点击并不一定需要立即发送请求,可以等待用户停止点击一段时间后再发送请求。
  • 滚动加载类接口:比如下拉刷新、上拉加载更多等,用户可能在滚动过程中频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户停止滚动一段时间后再发送请求。

如何确定接口是重复的?

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

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

方案一:使用共享缓存
流程图如下:
在这里插入图片描述
二、使用分布式锁
流程图:
在这里插入图片描述

具体实现

有一个保存用户的接口

@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 class AddReq {
    /**     * 用户名称     */    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 class AddReq {

    /**
     * 用户名称
     */
    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 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);
        //获取方法参数
        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.getClass().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 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, "重复提交前缀不能为空");
        }
        //获取自定义key
        final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        // 使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和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, "系统异常");
        }
    }
}

这里的核心代码是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 class RedissonConfig {

    @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

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 分布式锁实现
 */
@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, "重复提交前缀不能为空");
        }
        //获取自定义key
        final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        // 使用Redisson分布式锁的方式判断是否重复提交
        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();
            }
        }

    }
}

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

测试
第一次提交,“添加用户成功”
在这里插入图片描述
短时间内重复提交,“BIZ-0001:您的操作太快了,请稍后重试”
在这里插入图片描述
过几秒后再次提交,“添加用户成功”
在这里插入图片描述

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1882003.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Anaconda创建虚拟环境+Pycharm和Jupyter使用虚拟环境

文章目录 前言一、创建虚拟环境步骤1.查看已有虚拟环境2.创建虚拟环境3.激活虚拟环境4.查看已安装的库清单5.删除虚拟环境 二、pycharm导入虚拟环境三、jupyter导入虚拟环境1.激活虚拟环境2.导入内核3.打开jupyter4.选择虚拟环境5.查看内核列表6.删除内核 前言 提前下载好Anac…

Windows定时任务执行脚本

场景&#xff1a;由于网络波动原因导致云数据库没连接上&#xff0c;从而导致某个流程引擎链接不上数据库从而导致该流程引擎服务挂了&#xff0c;网络恢复后 数据库链接正常&#xff0c;但是该引擎服务还是中止状态。 解决方案&#xff1a;在Windows中新建一个定时任务&#…

服务器raid5坏盘-换盘-修复阵列过程

目录 背景原因分析解决步骤名词解释进入raid管理界面换回旧4号&#xff0c;进行import再次更换4号盘 总结 背景 服务器除尘之后文件服务器部分文件不能访问了,部分文件夹内容为空&#xff0c;起初以为是新配置的权限的问题&#xff0c;排查之后发现不仅仅是权限问题 jumpserv…

hello.go程序讲解、litelde的使用

package mainimport "fmt"func main() {fmt.Println("hello go!!!") }注意 go语言有且只有一个入口函数main()&#xff0c;一个文件夹也只能有一个main函数 两个文件夹里面都有main函数是不能编译通过的&#xff0c;需要分到不同的文件夹&#xff0c;只能…

C++视觉开发 二.OpenCV基础

目录 本章记录OpenCV开发中的基本操作语法 一.基础 1.读取图像 2.显示图像 3.保存图像 二.图像 1.像素处理 2.彩色图像 三.滤波 1.高斯滤波&#xff08;Gaussian Blur&#xff09; 功能&#xff1a; 高斯滤波是一种常用的线性平滑滤波器&#xff0c;用于降低图像噪声…

案例:MySQL主从复制与读写分离

一、案例分析 1.案例概述 在实际的生产环境中&#xff0c;如果对数据库的读和写都在同一个数据库服务器中操作,无论是在安全性、高可用性还是高并发等各个方面都是完全不能满足实际需求的。因此&#xff0c;一般来说都是通过主从复制(Master-Slave)来同步数据&#xff0c;再通…

【昇思25天学习打卡营打卡指南-第十三天】ShuffleNet图像分类

ShuffleNet图像分类 ShuffleNet网络介绍 ShuffleNetV1是旷视科技提出的一种计算高效的CNN模型&#xff0c;和MobileNet, SqueezeNet等一样主要应用在移动端&#xff0c;所以模型的设计目标就是利用有限的计算资源来达到最好的模型精度。ShuffleNetV1的设计核心是引入了两种操…

stable-diffusion-webui-colab搭建SadTalker由图生成视频人

在这里选择一个stable-diffusion-webui-colab ​​​​​​​​​GitHub - camenduru/stable-diffusion-webui-colab: stable diffusion webui colab 这里我选择是&#xff1a; https://colab.research.google.com/github/camenduru/stable-diffusion-webui-colab/blob/main…

《昇思25天学习打卡营第16天 | 昇思MindSpore基于MobileNetv2的垃圾分类》

16天 本节学习了垃圾分类代码开发的方法。通过读取本地图像数据作为输入&#xff0c;对图像中的垃圾物体进行检测&#xff0c;并且将检测结果图片保存到文件中。 MobileNet网络是由Google团队于2017年提出的专注于移动端、嵌入式或IoT设备的轻量级CNN网络&#xff0c;相比于传…

百元蓝牙耳机推荐2024,百元蓝牙耳机排行榜盘点

在2024年面对琳琅满目的蓝牙耳机选项&#xff0c;消费者往往难以抉择&#xff0c;特别是在预算有限的情况下&#xff0c;如何在众多产品中挑选出既满足质量又符合预算的耳机成为了一个不小的挑战。 为了帮助大家在繁多的选择中找到真正物有所值的百元蓝牙耳机&#xff0c;我们…

Win10临时文件夹Temp无写入权限不能安装怎么解决?

网上很多解决方案&#xff0c;但其实大部分是C盘满了 使用treesize软件&#xff0c;来精准清理C盘的垃圾&#xff0c;释放空间 或者用everything来快速定位Temp的位置&#xff0c;先把里面能删的都删掉 上面两款软件都是非常好用的&#xff0c;建议大家使用

word怎么转换成pdf?分享3种PDF文件转换技巧

word怎么转换成pdf&#xff1f;在日常办公中&#xff0c;将Word转换成PDF可以带来诸多便利。首先&#xff0c;PDF格式具有跨平台的通用性&#xff0c;无论在哪个操作系统或设备上&#xff0c;都能保持文档的原始布局和格式。其次&#xff0c;PDF文件不容易被篡改&#xff0c;可…

对原生textarea加上:当前输入字数/最大输入字数

源码: <!DOCTYPE html> <html lang"en"> <head> <meta charset"UTF-8"> <meta name"viewport" content"widthdevice-width, initial-scale1.0"> <title>Textarea Character Counter with Dragga…

跨模型知识融合:大模型的知识融合

大模型&#xff08;LLMs&#xff09;在多个领域的应用日益广泛&#xff0c;但确保它们的行为与人类价值观和意图一致却充满挑战。传统对齐方法&#xff0c;例如基于人类反馈的强化学习&#xff08;RLHF&#xff09;&#xff0c;虽取得一定进展&#xff0c;仍面临诸多难题&#…

浅谈区块链

区块链是一种分布式数据库技术&#xff0c;也被称为分布式账本技术。它的本质是一个去中心化的数据库&#xff0c;使用密码学相关联产生的数据块串连而成&#xff0c;用于验证其信息的有效性&#xff08;防伪&#xff09;和生成下一个区块。区块链具有“不可伪造”“全程留痕”…

肆拾玖坊的商业模式,49坊新零售奖金制度体系,众筹众创+会员制

肆拾玖坊之所以能够在短时间内成为白酒行业的“现象级”企业,,不仅是依靠独特商业模式,同时也依靠的是坚持用户为核心,围绕用户需求,让用户与产品直接产生连接理念。 坐标&#xff1a;厦门&#xff0c;我是易创客肖琳 深耕社交新零售行业10年&#xff0c;主要提供新零售系统工…

《昇思25天学习打卡营第21天 | 昇思MindSporePix2Pix实现图像转换》

21天 本节学习了通过Pix2Pix实现图像转换。 Pix2Pix是基于条件生成对抗网络&#xff08;cGAN&#xff09;实现的一种深度学习图像转换模型。可以实现语义/标签到真实图片、灰度图到彩色图、航空图到地图、白天到黑夜、线稿图到实物图的转换。Pix2Pix是将cGAN应用于有监督的图…

【正点原子K210连载】 第十五章 按键中断实验 摘自【正点原子】DNK210使用指南-CanMV版指南

1&#xff09;实验平台&#xff1a;正点原子ATK-DNK210开发板 2&#xff09;平台购买地址https://detail.tmall.com/item.htm?id731866264428 3&#xff09;全套实验源码手册视频下载地址&#xff1a; http://www.openedv.com/docs/boards/xiaoxitongban 第十五章 按键中断实…

浅谈人工智能发展趋势

第三次浪潮 人类科技发展的主线正沿着“能源”和“新型”展开。AI的尽头是光伏和储能。 如今我们正在经历第三次浪潮——信息文明。 社会生产力 劳动对象 劳动工具 劳动者 生产要素 农业文明铜器铁器 材料 人力工具 农民 土地人力 工业文明机车电力 材料动力 动力…