通过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);
    }
相关推荐
_Evan_Yao5 小时前
Redis 缓存穿透、击穿、雪崩,一次讲清!
java·spring·mybatis
梓豪csd5 小时前
消息订阅中心:谁该收到哪条通知
java·开发语言·人工智能·低代码·自动化·1024程序员节
秋饼5 小时前
Spring AI 2.1 Message Parts 深度实战:推理签名回放与跨轮记忆持久化的源码级落地
java·ai·技术分享·后端开发
thefool1122666 小时前
TCP 1
java·1024程序员节
paopaokaka_luck8 小时前
宠物门店经营管理系统(销售与库存联动、进货自动记账、 退换与退款关联、员工个人业绩、ECharts 经营分析、滞销识别与客户分层、宠物档案年龄计算)
前端·javascript·spring boot·mysql·echarts·mybatis·1024程序员节
金銀銅鐵8 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(下)
java·后端·ai编程
AI人工智能+电脑小能手9 小时前
一次请求打几个下游,这个Java并行任务工具类6个方法按序聚合
java·线程池·并发编程·1024程序员节·批量处理·并行任务
paopaokaka_luck10 小时前
心理咨询管理系统(WebSocket实时沟通、基于用户的协同过滤、 ECharts多维分析、 预约业务流程、预约校验与定时处理)
java·vue.js·spring boot·mysql·spring·echarts·1024程序员节
2301_322414280411 小时前
springboot农产品交易平台的设计与实现08055-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·spring boot·redis·mysql·tomcat·maven·mybatis
考虑考虑12 小时前
JDK26的JEP 500属性
java·后端·java ee