SpringBoot实战:SpringBoot接口防抖的一些实现方案?

科技   2024-09-17 12:39   河北  

前言

所谓防抖机制,其核心目的有二:一是预防用户因手部不稳而导致的误操作,二是应对网络环境的不稳定(即网络抖动)。在Web系统的设计中,表单提交是一个极为常见的功能。若缺乏适当的控制,很容易因为用户的无意重复点击或网络延迟,导致同一请求被多次发送,进而在数据库中产生冗余的数据记录。为了缓解用户误操作带来的问题,前端开发者通常会实现一种机制,使得在用户点击提交按钮后,按钮进入加载(loading)状态,从而阻止用户在同一时间段内进行多次点击。然而,针对网络波动可能引起的请求重复发送问题,仅仅依靠前端的措施是不足以完全解决的。因此,后端系统也需要部署相应的防抖逻辑,以确保在网络条件不稳定的情况下,不会错误地接收并处理同一请求的多次传输。

二、思路

哪些接口需要实现防抖?

  • 用户输入类接口,如搜索框输入或表单输入等场景,用户的输入行为往往十分频繁,但这并不意味着每次输入都需要立即触发接口请求。为了提高效率和用户体验,可以设计一种机制,使得系统在检测到用户输入停止一段时间后,再统一发送请求。这样既能减少不必要的请求次数,又能确保在用户完成输入后,数据能够及时更新。

  • 对于按钮点击类接口,如提交表单或保存设置等操作,用户可能会因为各种原因(如误操作、网络延迟反馈等)而频繁点击按钮。为了避免因此产生的重复请求,可以引入防抖逻辑,即在用户停止点击按钮并等待一段时间后,再发送请求。这样能够有效防止因用户连续点击而导致的重复处理和数据冗余。

  • 在滚动加载类接口中,如下拉刷新或上拉加载更多内容的场景,用户滚动页面的行为可能非常频繁。然而,频繁地触发接口请求不仅会增加服务器的负担,还可能影响用户体验。因此,可以设置一个防抖策略,即在用户停止滚动并等待一段时间后,再触发接口请求以加载新的内容。这样既能保证内容的及时加载,又能避免不必要的请求开销。

三、解决方案

使用共享缓存:

使用分布式锁:

四、代码示例

controller层

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

request层

/**
 * @description 新增入参
 * @author kyle0432
 * @date 2024/03/01 15:17
 */

public class AddReq {
 
    /**
     * 用户名称
     */

    private String userName;
 
    /**
     * 用户手机号
     */

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

    private List<Long> roleIdList;
}

自定义注解

package com.example.requestlock.lock.annotation;
 
import java.lang.annotation.*;
 
/**
 * @description 加上这个注解可以将参数设置为key
 * @author kyle0432
 * @date 2024/03/01 15:27
 */

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


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 {
 
    /**
     * @description 获取LockKey
     * @param joinPoint 切入点
     * @author kyle0432
     * @date 2024/03/01 15:48
     */

    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;
    }
}
> 由于``@RequestLock``可以放在方法的参数上,也可以放在对象的属性上,所以这里需要进行两次判断,一次是获取方法上的注解,一次是获取对象里面属性上的注解。

Redis缓存方式

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 缓存实现
 * @author kyle0432
 * @date 2024/03/01 16:01
 */

@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, "系统异常");
        }
    }
}

 Redisson分布式方式

分布式需要一个额外依赖,引入方式

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.10.6</version>
</dependency>

引入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;
 
/**
 * @description redis配置类
 * @author kyle0432
 * @date 2024/03/01 16:16
 */

@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);
    }
}

核心代码

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 分布式锁实现
 * @author kyle0432
 * @date 2024/03/01 14:36
 */

@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();
            }
        }
 
    }
}

代码演示

第一次提交,"添加用户成功

短时间内重复提交

过几秒后再次提交,"添加用户成功"



Java技术前沿
专注分享Java技术,包括但不限于 SpringBoot,SpringCloud,Docker,消息中间件等。
 最新文章