原创

Sprint Boot切面+Redis防止前端重复提交

温馨提示:
本文最后更新于 2024年02月07日,已超过 19 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

最近项目上遇到重复提交的情况,虽然前端对按钮进行了禁用,但是不知道是什么原因,后端仍然接收到了多个请求,因为是分布式系统,所以不能简单的使用lock,最终考虑决定使用redis实现。

一、环境准备

  • MySql:测试数据库
  • Redis:使用Redis实现
  • Another Redis Desktop Manager:跟踪Redis信息
  • ApiFox:模拟请求,单线程循环及多线程循环
  • Spring Boot:2.7.4

二、准备测试数据及接口

2.1、创建表

创建一个最简单的用户表,只包含idname两列

create table User
(
    id   int          null,
    name varchar(200) null
);

2.2、创建接口

2.2.1、配置依赖及数据库、Redis连接信息

项目依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>net.xiangcaowuyu</groupId>
    <artifactId>RepeatSubmit</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>RepeatSubmit</name>
    <description>RepeatSubmit</description>
    <properties>
        <java.version>8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Druid -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.16</version>
        </dependency>
        <!--jdbc-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!-- mybatis-plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

yaml文件配置数据库及Redis连接信息

spring:
  redis:
    host: 192.168.236.2
    port: 6379
    password:
  datasource:
    #使用阿里的Druid
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.236.2/TestRepeatSubmit?serverTimezone=UTC
    username: root
    password: root

2.2.2、创建实体

@Data
@TableName("User")
public class User {

    private Long id;

    private String name;

}

2.2.3、创建数据访问层

public interface UserMapper extends BaseMapper<User> {
}

2.2.4、创建异常处理类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResultRet<T> {

    private Integer code;

    private String msg;

    private T data;

    //成功码
    public static final Integer SUCCESS_CODE = 200;
    //成功消息
    public static final String SUCCESS_MSG = "SUCCESS";

    //失败
    public static final Integer ERROR_CODE = 201;
    public static final String ERROR_MSG = "系统异常,请联系管理员";
    //没有权限的响应码
    public static final Integer NO_AUTH_COOD = 999;

    //执行成功
    public static <T> ResultRet<T> success(T data){
        return new ResultRet<>(SUCCESS_CODE,SUCCESS_MSG,data);
    }
    //执行失败
    public static <T> ResultRet failed(String msg){
        msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
        return new ResultRet(ERROR_CODE,msg,"");
    }
    //传入错误码的方法
    public static <T> ResultRet failed(int code,String msg){
        msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
        return new ResultRet(code,msg,"");
    }
    //传入错误码的数据
    public static <T> ResultRet failed(int code,String msg,T data){
        msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
        return new ResultRet(code,msg,data);
    }

}

2.2.5、简单的全局异常

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {


    @ExceptionHandler(value = Throwable.class)
    public ResultRet handleException(Throwable throwable){
        log.error("错误",throwable);
        return ResultRet.failed(500, throwable.getCause().getMessage());
    }

}

2.2.6、配置模拟接口

模拟一个get请求的接口,用户新增用户,orm框架使用mybatis-plus,使用最简单的插入

@RestController
@RequestMapping("/user")

public class UserController {

    @Resource
    private UserMapper userMapper;

    @GetMapping("/add")
    public ResultRet<User> add() {
        User user = new User();
        user.setId(1L);
        user.setName("张三");
        userMapper.insert(user);
        return ResultRet.success(user);
    }

}

以上配置完成后,当我们访问/user/add接口时,肯定访问几次,数据库就会重复插入多少信息。

三、改造接口,防止重复提交

改造的原理起始很简单,我们前端访问接口时,首先在头部都会携带token信息,我们通过切面,拦截请求,获取到token及请求的url,拼接后作为redis的key值,通过redis锁的方式写入key值,如果写入成功,设置一个过期时间,在有效期时间内,多次请求,先判断redis中是否有对应的key,如果有,抛出异常,禁止再次写入。

3.1、配置RedisTemplate

@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }

}

3.2、增加Redis工具类

@Component
@Slf4j
public class RedisUtils {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * Redis分布式锁
     *
     * @return 加锁成功返回true,否则返回false
     */
    public boolean tryLock(String key, String value, long timeout) {
        Boolean isSuccess = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
        //设置过期时间,防止死锁
        if (Boolean.TRUE.equals(isSuccess)) {
            stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);
        }
        return Boolean.TRUE.equals(isSuccess);
    }

    /**
     * Redis 分布式锁释放
     *
     * @param key
     * @param value
     */
    public void unLock(String key, String value) {
        try {
            String currentValue = stringRedisTemplate.opsForValue().get(key);
            if (StringUtils.isNotEmpty(currentValue) && StringUtils.equals(currentValue, value)) {
                stringRedisTemplate.opsForValue().getOperations().delete(key);
            }
        } catch (Exception e) {
            //这个是我的自定义异常,你可以删了
            log.info("报错了");
        }
    }

}

3.3、添加注解

@Target(ElementType.METHOD) // 注解只能用于方法
@Retention(RetentionPolicy.RUNTIME) // 修饰注解的生命周期
@Documented
public @interface RepeatSubmitAnnotation {

    /**
     * 防重复操作过期时间,默认1s
     */
    long expireTime() default 1;

}

3.4、添加切面

@Slf4j
@Component
@Aspect
public class RepeatSubmitAspect {

    @Resource
    private RedisUtils redisUtils;

    /**
     * 定义切点
     */
    @Pointcut("@annotation(net.xiangcaowuyu.repeatsubmit.annotation.RepeatSubmitAnnotation)")
    public void repeatSubmit() {
    }

    @Around("repeatSubmit()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {

        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        // 获取防重复提交注解
        RepeatSubmitAnnotation annotation = method.getAnnotation(RepeatSubmitAnnotation.class);
        // 获取token当做key,小编这里是新后端项目获取不到哈,先写死
        String token = request.getHeader("token");
        if (StringUtils.isBlank(token)) {
            throw new RuntimeException("token不存在,请登录!");

        }
        String url = request.getRequestURI();
        /**
         *  通过前缀 + url + token 来生成redis上的 key
         *  可以在加上用户id,小编这里没办法获取,大家可以在项目中加上
         */
        String redisKey = "repeat_submit_key:"
                .concat(url)
                .concat(token);
        log.info("==========redisKey ====== {}", redisKey);
        boolean lock = redisUtils.tryLock(redisKey, redisKey, annotation.expireTime());
        if (lock) {
            log.info("获取分布式锁成功");
            try {
                //正常执行方法并返回
                return joinPoint.proceed();
            } catch (Throwable throwable) {
                throw new Throwable(throwable);
            } finally {
                //释放锁
//                redisUtils.unLock(redisKey, redisKey);
//                System.out.println("释放分布式锁成功");
            }
        } else {
            // 抛出异常
            throw new Throwable("请勿重复提交");
        }
    }

}

3.5、接口添加注解

这里为了方便演示,我们把提交间隔时间设置为30s

@RestController
@RequestMapping("/user")

public class UserController {

    @Resource
    private UserMapper userMapper;

    @GetMapping("/add")
    @RepeatSubmitAnnotation(expireTime = 30L)
    public ResultRet<User> add() {
        User user = new User();
        user.setId(1L);
        user.setName("张三");
        userMapper.insert(user);
        return ResultRet.success(user);
    }

}

至此,我们所有的配置都完成了,接下来使用ApiFox模拟一下接口访问。

3.6、模拟测试

我们先把数据库及Redis清空(本来其实就是空的)

配置好自动化测试接口

3.6.1、单线程测试

先模拟单线程操作,循环50次

查看Redis,查看有一个key

打开数据库,可以看到只成功插入了一条

3.6.3、模拟多线程

先把数据库清空,Redis等待过期后自动删除

再次模拟,10个线程,循环10次

此时查看数据库,仍然只有一条插入成功了

正文到此结束
本文目录