面试官:你们项目中是怎么做防重复提交的?
# 重复提交产生原因
表单重复提交是在多用户Web应用中最常见、带来很多麻烦的一个问题。有很多的应用场景都会遇到重复提交问题:
- 点击提交按钮两次
- 点击刷新按钮
- 使用浏览器后退按钮重复之前的操作,导致重复提交表单
- 使用浏览器历史记录重复提交表单
- 浏览器重复的HTTP请求
- nginx重发等情况
# 解决方案
1、按钮禁用
设置标志位,提交之后禁止按钮。像一些短信验证码的按钮一般都会加一个前端的按钮禁用
- 优点
简单。基本可以防止重复点击提交按钮造成的重复提交问题。
- 缺陷
前进后退操作,或者F5刷新页面等问题并不能得到解决。
2、使用Post/Redirect/Get(PRG)模式
用来防止F5刷新重复提交表单。
在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。
这种方法实现起来相对比较简单,但此方法也不能防止所有情况。例如用户多次点击提交按钮;恶意用户避开客户端预防多次提交手段,进行重复提交请求。
3、利用Session防止表单重复提交
- 在服务器端生成一个唯一的随机标识号,称为Token(令牌),同时在当前用户的Session域中保存这个Token。
- 将Token发送到客户端的Form表单中,在Form表单中使用隐藏域来存储这个Token,表单提交的时候连同这个Token一起提交到服务器端。
- 在服务器端判断客户端提交的Token与服务器端生成的Token是否一致,如果不一致,那就是重复提交了,此时服务器端就可以不处理重复提交的表单。如果相同则处理表单提交,处理完后清除当前用户的Session域中存储的标识。
下面的场景将拒绝处理用户提交的表单请求:
- 存储Session域中的Token(令牌)与表单提交的Token(令牌)不同。
- 当前用户的Session中不存在Token(令牌)。
为什么要设置一个隐藏域?
假如恶意用户开两个浏览器窗口(同一浏览器的窗口共用一个session)这样窗口1提交完,系统删掉session,窗口1停留着,他打开第二个窗口进入这个页面,系统又为他们添加了一个session,这个时候窗口1按下F5,那么直接重复提交!
所以,我们必须得用hidden隐藏一个token,并且在后台比较它是否与session中的值一致,只有这样才能保证F5是不可能被重复提交的!
4、使用AOP
使用本地锁,本地锁有很多种,比如使用了 ConcurrentHashMap 并发容器 putIfAbsent 方法;使用guava cache的机制。使用Content-MD5 进行加密 只要参数不变,key存在就阻止提交。
本地锁只适用于单机部署的应用。
①配置注解
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Resubmit {
/**
* 延时时间 在延时多久后可以再次提交
*
* @return Time unit is one second
*/
int delaySeconds() default 20;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
②实例化锁
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 重复提交锁
*/
@Slf4j
public final class ResubmitLock {
private static final ConcurrentHashMap<String, Object> LOCK_CACHE = new ConcurrentHashMap<>(200);
private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(5, new ThreadPoolExecutor.DiscardPolicy());
private ResubmitLock() {
}
/**
* 静态内部类 单例模式
*
* @return
*/
private static class SingletonInstance {
private static final ResubmitLock INSTANCE = new ResubmitLock();
}
public static ResubmitLock getInstance() {
return SingletonInstance.INSTANCE;
}
public static String handleKey(String param) {
return DigestUtils.md5Hex(param == null ? "" : param);
}
/**
* 加锁 putIfAbsent 是原子操作保证线程安全
*
* @param key 对应的key
* @param value
* @return
*/
public boolean lock(final String key, Object value) {
return Objects.isNull(LOCK_CACHE.putIfAbsent(key, value));
}
/**
* 延时释放锁 用以控制短时间内的重复提交
*
* @param lock 是否需要解锁
* @param key 对应的key
* @param delaySeconds 延时时间
*/
public void unLock(final boolean lock, final String key, final int delaySeconds) {
if (lock) {
EXECUTOR.schedule(() -> {
LOCK_CACHE.remove(key);
}, delaySeconds, TimeUnit.SECONDS);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
③AOP 切面
import com.alibaba.fastjson.JSONObject;
import com.cn.xxx.common.annotation.Resubmit;
import com.cn.xxx.common.annotation.impl.ResubmitLock;
import com.cn.xxx.common.dto.RequestDTO;
import com.cn.xxx.common.dto.ResponseDTO;
import com.cn.xxx.common.enums.ResponseCode;
import lombok.extern.log4j.Log4j;
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.stereotype.Component;
import java.lang.reflect.Method;
/**
* 数据重复提交校验
**/
@Log4j
@Aspect
@Component
public class ResubmitDataAspect {
private final static String DATA = "data";
private final static Object PRESENT = new Object();
@Around("@annotation(com.cn.xxx.annotation.Resubmit)")
public Object handleResubmit(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
//获取注解信息
Resubmit annotation = method.getAnnotation(Resubmit.class);
int delaySeconds = annotation.delaySeconds();
Object[] pointArgs = joinPoint.getArgs();
String key = "";
//获取第一个参数
Object firstParam = pointArgs[0];
if (firstParam instanceof RequestDTO) {
//解析参数
JSONObject requestDTO = JSONObject.parseObject(firstParam.toString());
JSONObject data = JSONObject.parseObject(requestDTO.getString(DATA));
if (data != null) {
StringBuffer sb = new StringBuffer();
data.forEach((k, v) -> {
sb.append(v);
});
//生成加密参数 使用了content_MD5的加密方式
key = ResubmitLock.handleKey(sb.toString());
}
}
//执行锁
boolean lock = false;
try {
//设置解锁key
lock = ResubmitLock.getInstance().lock(key, PRESENT);
if (lock) {
//放行
return joinPoint.proceed();
} else {
//响应重复提交异常
return new ResponseDTO<>(ResponseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);
}
} finally {
//设置解锁key和解锁时间
ResubmitLock.getInstance().unLock(lock, key, delaySeconds);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
④注解使用案例
@PostMapping("/posts/save")
@Resubmit(delaySeconds = 10)
public ResponseDTO<BaseResponseDataDTO> saveOrder(@RequestBodyRequestDTO<OrderDTO> requestDto) {
// TODO
}
2
3
4
5
现在大多数部署方式都是集群,所以可以采用分布式锁,改造如下:
@Aspect
@Configuration
public class LockMethodInterceptor {
@Autowired
private RedisTemplate redisTemplate;
private final static String DATA = "data";
@Around("execution(public * *(..)) && @annotation(org.spring.springboot.interceptor.Resubmit)")
public Object interceptor(ProceedingJoinPoint pjp) {
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
Resubmit lock = method.getAnnotation(Resubmit.class);
Object[] pointArgs = pjp.getArgs();
String lockKey = DigestUtils.md5Hex(getRequest(pointArgs));
String value = UUID.randomUUID().toString();
try {
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, value, lock.delaySeconds(), TimeUnit.SECONDS);
if (!success) {
throw new RuntimeException("重复提交");
}
try {
return pjp.proceed();
} catch (Throwable throwable) {
throw new RuntimeException("系统异常");
}
} finally {
redisTemplate.delete(lockKey);
}
}
private String getRequest(Object... params) {
if (params == null) {
return "[]";
}
try {
StringBuilder sb = new StringBuilder();
sb.append("[");
for (Object param : params) {
if (param instanceof HttpServletRequest
|| param instanceof HttpServletResponse
|| param instanceof MultipartFile
|| param instanceof BindResult
|| param instanceof MultipartFile[]
|| param instanceof ModelMap
|| param instanceof Model
|| param instanceof ExtendedServletRequestDataBinder
|| param instanceof byte[]) {
continue;
}
sb.append(JSON.toJSON(param));
sb.append(",");
}
if (sb.lastIndexOf(",") != -1) {
sb.deleteCharAt(sb.lastIndexOf(","));
}
sb.append("]");
return sb.toString();
} catch (Exception e) {
return "error happen while print log";
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70