通过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);
    }
相关推荐
盐真卿14 分钟前
python2
java·前端·javascript
一嘴一个橘子1 小时前
mybatis - 动态语句、批量注册mapper、分页插件
java
组合缺一1 小时前
Json Dom 怎么玩转?
java·json·dom·snack4
危险、1 小时前
一套提升 Spring Boot 项目的高并发、高可用能力的 Cursor 专用提示词
java·spring boot·提示词
kaico20182 小时前
JDK11新特性
java
钊兵2 小时前
java实现GeoJSON地理信息对经纬度点的匹配
java·开发语言
jiayong232 小时前
Tomcat性能优化面试题
java·性能优化·tomcat
秋刀鱼程序编程2 小时前
Java基础入门(五)----面向对象(上)
java·开发语言
sunnyday04262 小时前
基于Netty构建WebSocket服务器实战指南
服务器·spring boot·websocket·网络协议
纪莫2 小时前
技术面:MySQL篇(InnoDB的锁机制)
java·数据库·java面试⑧股