通过spring boot/redis/aspect 防止表单重复提交【防抖】

一、啥是防抖

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

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

逻辑正确,也就是不能误判;

响应迅速,不能太慢;

易于集成,逻辑与业务解耦;

良好的用户反馈机制,比如提示"您点击的太快了"

二、思路解析

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

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

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

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

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

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

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

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

  • 定义一个RequestLock,配置超时时间、异常消息、分组标识(用户标识)
java 复制代码
/**
 * 请求锁,防止重复提交
 *
 * @author xt
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLock {
    /**
     * 过期时间
     *
     * @return
     */
    long expire() default 3;

    /**
     * 异常提示
     *
     * @return
     */
    String message() default "您的操作太快了,请稍后重试";

    /**
     * 参数分隔符
     *
     * @return
     */
    String delimiter() default "|";

    /**
     * 时间单位
     *
     * @return
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 前缀(从请求header key)
     *
     * @return
     */
    String group() default "loginuserid";
}
  • 定义一个aspect 实现对注解RequestLock的endpoint进行拦截
java 复制代码
@EnableAspectJAutoProxy
@Aspect
@Configuration
@Order
public class RequestLockAspect {
    @Resource
    private RedisTemplate redisTemplate;

    @Pointcut("execution(public * * (..)) && @annotation(org.xt.shisui.redis.duplicate.RequestLock)")
    public void endpointPointcut() {
    }

    @Around("endpointPointcut()")
    public Object interceptor(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        if (redisTemplate != null) {
            String key = RequestLockKeyGenerator.getLockKey(joinPoint);
            Boolean success = redisTemplate.opsForValue().setIfAbsent(key, new byte[0], requestLock.expire(), requestLock.timeUnit());
            if (Boolean.FALSE.equals(success)) {
                return Response.no(requestLock.message());
            }
        }
        return joinPoint.proceed();
    }
}
  • 根据请求参数构建RequestLock锁的key,即Redis存储的key
java 复制代码
/**
 * 根据请求参数构建锁的key
 *
 * @author xt
 * @date 2022-07-15 14:21
 */
public class RequestLockKeyGenerator {
    public static String getLockKey(ProceedingJoinPoint joinPoint) {
        String ipAddress = null, group = null;
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        //方法名称
        String methodName = signature.getName();
        //类路径
        String declaringTypeName = signature.getDeclaringTypeName();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        if (attributes != null) {
            //加上请求中的ip和分组标识,防止错误拦截
            HttpServletRequest request = attributes.getRequest();
            ipAddress = request.getRemoteAddr();
            group = request.getHeader(requestLock.group());
        }
        final Object[] args = joinPoint.getArgs();
        final Parameter[] parameters = method.getParameters();
        StringBuilder params = new StringBuilder();
        String delimiter = requestLock.delimiter();
        for (int i = 0; i < parameters.length; i++) {
            //忽略特殊参数,如图片、大文本等,如果是存hashcode 可以不需要这个注解
            final RequestLockKeyIgnore keyIgnore = parameters[i].getAnnotation(RequestLockKeyIgnore.class);
            if (keyIgnore != null) {
                continue;
            }
            Object arg = args[i];
            if (arg != null) {
                params.append(delimiter).append(arg);
            }
        }
        StringBuilder result = new StringBuilder();
        result.append(declaringTypeName).append(delimiter).append(methodName).append(delimiter).append(ipAddress).append(delimiter).append(delimiter).append(group).append(params.hashCode());
        return result.toString();
    }
}
  • 如果Redis存储请求参数字符串,可以增加特殊参数忽略注解,如图片等属性,建议用hashcode
java 复制代码
/**
 * 忽略该参数,防止一些base64字符串被当做主键
 *
 * @author xt
 * @date 2022-01-05 14:37
 */
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestLockKeyIgnore {
}
  • 具体使用demo
java 复制代码
    @RequestLock(expire = 5)
    @ApiOperation("新增")
    @RequestMapping(value = "/create", method = RequestMethod.POST)
    public Response<ChatSpeechcraftCategoryCreateResp> create(@RequestBody @Validated ChatSpeechcraftCategoryCreateReq req, final HttpServletRequest request) throws SimpleException {
        return chatSpeechcraftCategoryApiService.create(req);
    }
相关推荐
摇滚侠10 分钟前
DBeaver 导入数据库 导入 SQL 文件 MySQL 备份恢复
java·数据库·mysql
keep one's resolveY34 分钟前
SpringBoot实现重试机制的四种方案
java·spring boot·后端
天空属于哈夫克31 小时前
企业微信API常见的错误和解决方案
java·数据库·企业微信
摇滚侠2 小时前
VMvare 虚拟机 Oracle19c 安装步骤,远程连接 Oracle19c,百度网盘安装包
java·oracle
梁萌2 小时前
idea报错找不到XX包的解决方法
java·intellij-idea·启动报错·缺少包
Agent产品评测局2 小时前
生产排期与MES/ERP系统打通,实操方法详解 —— 2026企业级智能体自动化选型与实战指南
java·运维·人工智能·ai·chatgpt·自动化
阿丰资源2 小时前
基于Spring Boot的电影城管理系统(直接运行)
java·spring boot·后端
呱牛do it3 小时前
企业级门户网站设计与实现:基于SpringBoot + Vue3的全栈解决方案(Day 8)
java
消失的旧时光-19433 小时前
Spring Boot 工程化进阶:统一返回 + 全局异常 + AOP 通用工具包
java·spring boot·后端·aop·自定义注解
NE_STOP4 小时前
Redis--发布订阅命令和Redis事务
java