前言
1. 为什么会出现用户重复提交
- 网络延迟的情况下用户多次点击submit按钮导致表单重复提交;
- 用户提交表单后,点击【刷新】按钮导致表单重复提交(点击浏览器的刷新按钮,就是把浏览器上次做的事情再做一次,因为这样也会导致表单重复提交);
- 用户提交表单后,点击浏览器的【后退】按钮回退到表单页面后进行再次提交。
2. 重复提交不拦截可能导致的问题
- 重复数据入库,造成脏数据。即使数据库表有UK索引,该操作也会增加系统的不必要负担;
- 会成为黑客爆破攻击的入口,大量的请求会导致应用崩溃;
- 用户体验差,多条重复的数据还需要一条条的删除等。
3. 解决办法
办法有很多,我这里只说一种,利用Redis的set方法搞定(不是redisson)
项目代码
项目结构
配置文件
pom.xml
xml
<?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.9</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>RequestLock</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>RequestLock</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- web依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 切面 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.5</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.properties
properties
spring.application.name=RequestLock
server.port=8080
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=20
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=10
# 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=1000
代码文件
RequestLockApplication.java
java
package com.example.requestlock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RequestLockApplication {
public static void main(String[] args) {
SpringApplication.run(RequestLockApplication.class, args);
}
}
User.java
java
package com.example.requestlock.model;
import com.example.requestlock.lock.annotation.RequestKeyParam;
public class User {
private String name;
private Integer age;
@RequestKeyParam(name = "phone")
private String phone;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
", phone='" + phone + '\'' +
'}';
}
}
RequestKeyParam.java
java
package com.example.requestlock.lock.annotation;
import java.lang.annotation.*;
/**
* @description 加上这个注解可以将参数也设置为key,唯一key来源
*/
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {
/**
* key值名称
*
* @return 默认为空
*/
String name() default "";
}
RequestLock.java
java
package com.example.requestlock.lock.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* @description 请求防抖锁,用于防止前端重复提交导致的错误
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestLock {
/**
* redis锁前缀
*
* @return 默认为空,但不可为空
*/
String prefix() default "";
/**
* redis锁过期时间
*
* @return 默认2秒
*/
int expire() default 2;
/**
* redis锁过期时间单位
*
* @return 默认单位为秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* redis key分隔符
*
* @return 分隔符
*/
String delimiter() default ":";
}
RequestLockMethodAspect.java
java
package com.example.requestlock.lock.aspect;
import com.example.requestlock.lock.annotation.RequestLock;
import com.example.requestlock.lock.keygenerator.RequestKeyGenerator;
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.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;
import java.lang.reflect.Method;
/**
* @description 请求锁切面处理器
*/
@Aspect
@Configuration
public class RequestLockMethodAspect {
private final StringRedisTemplate stringRedisTemplate;
private final RequestKeyGenerator requestKeyGenerator;
@Autowired
public RequestLockMethodAspect(StringRedisTemplate stringRedisTemplate, RequestKeyGenerator requestKeyGenerator) {
this.requestKeyGenerator = requestKeyGenerator;
this.stringRedisTemplate = stringRedisTemplate;
}
@Around("execution(public * * (..)) && @annotation(com.example.requestlock.lock.annotation.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 RuntimeException("重复提交前缀不能为空");
return "重复提交前缀不能为空";
}
//获取自定义key
final String lockKey = requestKeyGenerator.getLockKey(joinPoint);
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 RuntimeException("您的操作太快了,请稍后重试");
return "您的操作太快了,请稍后重试";
}
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
// throw new RuntimeException("系统异常");
return "系统异常";
}
}
}
RequestKeyGenerator.java
java
package com.example.requestlock.lock.keygenerator;
import org.aspectj.lang.ProceedingJoinPoint;
/**
* 加锁key生成器
*/
public interface RequestKeyGenerator {
/**
* 获取AOP参数,生成指定缓存Key
*
* @param joinPoint 切入点
* @return 返回key值
*/
String getLockKey(ProceedingJoinPoint joinPoint);
}
RequestKeyGeneratorImpl.java
java
package com.example.requestlock.lock.keygenerator.impl;
import com.example.requestlock.lock.annotation.RequestKeyParam;
import com.example.requestlock.lock.annotation.RequestLock;
import com.example.requestlock.lock.keygenerator.RequestKeyGenerator;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Service;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
@Service
public class RequestKeyGeneratorImpl implements RequestKeyGenerator {
@Override
public 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 cacheParams = parameters[i].getAnnotation(RequestKeyParam.class);
//如果属性不是CacheParam注解,则不处理
if (cacheParams == null) {
continue;
}
//如果属性是CacheParam注解,则拼接 连接符(:)+ CacheParam
sb.append(requestLock.delimiter()).append(args[i]);
}
//如果方法上没有加CacheParam注解
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) {
//判断字段上是否有CacheParam注解
final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class);
//如果没有,跳过
if (annotation == null) {
continue;
}
//如果有,设置Accessible为true(为true时可以使用反射访问私有变量,否则不能访问私有变量)
field.setAccessible(true);
//如果属性是CacheParam注解,则拼接 连接符(:)+ CacheParam
sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));
}
}
}
//返回指定前缀的key
return requestLock.prefix() + sb;
}
}
UserController.java
java
package com.example.requestlock.controller;
import com.example.requestlock.lock.annotation.RequestLock;
import com.example.requestlock.model.User;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping("/addUser1")
public String addUser1(@RequestBody User user) {
System.out.println("不做任何处理" + user);
return "添加成功";
}
@PostMapping("/addUser2")
@RequestLock(prefix = "addUser")
public String addUser2(@RequestBody User user) {
System.out.println("防重提交" + user);
return "添加成功";
}
}
原理解释
该RequestLock(请求锁)利用了Redis的单线程处理以及Key值过期特点,核心通过RequestLock、RequestKeyParam注解生成一个唯一的key值,存入redis后设置一个过期时间(1-3秒),当第二次请求的时候,判断生成的key值是否在Redis中存在,如果存在则认为第二次提交是重复的。
流程图如下:
用法说明
1. 在controller的方法上增加@RequestLock注解,并给一个前缀
java
@PostMapping("/addUser2")
@RequestLock(prefix = "addUser")
public String addUser2(@RequestBody User user)
加了@RequestLock注解代表这个方法会进行重复提交校验,没有加则不会进行校验。通过注解的方式可以使用法变得灵活。
2. @RequestKeyParam注解用在对象的属性上
java
@RequestKeyParam(name = "phone")
private String phone;
在对象的属性上加@RequestKeyParam注解后,Redis的key则由 @RequestLock定义的prefix加上字段的值组成,比如当传入传入phone是123456789,那么当前的key值则为: addUser:123456789
。
效果展示
调用addUser1接口
这里无论点击多少次提交,都会展示添加"添加成功",这样是不行的。
调用addUser2接口
第一次提交,"添加成功"。 快速点击第二次提交,就会出现"您的操作太快了,请稍后重试"提示。