原文链接:
https://www.zhihu.com/question/324268535/answer/2320741346
后台防止表单重复提交的三种方法_xin_shou123的博客-CSDN博客_防止表单重复提交
·
方案一:利用Session防止表单重复提交
1)步骤:
1、在用户填写好用户名和密码的页面的时候,会向后台发送一次请求,这时服务器端会生成一个唯一的随机标识号,称为Token(令牌),同时在当前用户的Session域中保存这个Token。
2、将Token发送到客户端的Form表单中,在Form表单中使用隐藏域来存储这个Token,表单提交的时候连同这个Token一起提交到服务器端。
3.1、服务器端判断客户端提交上来的Token与服务器端生成的Token是否一致,如果不一致,那就是重复提交了,此时服务器端就可以不处理重复提交的表单。
3.2、如果两个Token相同,则处理该表单,处理完之后,清除当前用户的Session域中存储的标识号。
·
2)为什么要设置一个隐藏域?
假如恶意用户开两个浏览器窗口(同一浏览器的窗口共用一个session)这样窗口1提交完,系统删掉session,窗口1停留着,他打开第二个窗口进入这个页面,系统又为他们添加了一个session,这个时候窗口1按下F5,那么直接重复提交!
所以,我们必须得用hidden隐藏一个token,并且在后台比较它是否与session中的值一致,只有这样才能保证F5是不可能被重复提交的!
·
3)代码示例:
3.1)创建FormServlet,用于生成Token(令牌)
public class FormServlet extends HttpServlet {
private static final long serialVersionUID = -884689940866074733L;
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String token = UUID.randomUUID().toString() ;//创建令牌
System.out.println("在FormServlet中生成的token:"+token);
request.getSession().setAttribute("token", token); //在服务器使用session保存token(令牌)
request.getRequestDispatcher("/form.jsp").forward(request, response);//跳转到form.jsp页面
}
public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doGet(request, response);
}
}
·
3.2)在页面中使用隐藏域来存储Token(令牌)
<body>
<form action="${pageContext.request.contextPath}/servlet/DoFormServlet" method="post">
<%--使用隐藏域存储生成的token--%>
<%--
<input type="hidden" name="token" value="<%=session.getAttribute("token") %>">
--%>
<%--使用EL表达式取出存储在session中的token--%>
<input type="hidden" name="token" value="${token}"/>
用户名:<input type="text" name="username">
<input type="submit" value="提交">
</form>
</body>
·
3.3)后端判断用户是否是重复提交
public class DoFormServlet extends HttpServlet {
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
boolean b = isRepeatSubmit(request);//判断用户是否是重复提交
if(b==true){
System.out.println("请不要重复提交");
return;
}
request.getSession().removeAttribute("token");//移除session中的token
System.out.println("处理用户提交请求!!");
}
/**
* 判断客户端提交上来的令牌和服务器端生成的令牌是否一致
* @param request
* @return
* true 用户重复提交了表单
* false 用户没有重复提交表单
*/
private boolean isRepeatSubmit(HttpServletRequest request) {
String client_token = request.getParameter("token");
//1、如果用户提交的表单数据中没有token,则用户是重复提交了表单
if(client_token==null){
return true;
}
//取出存储在Session中的token
String server_token = (String) request.getSession().getAttribute("token");
//2、如果当前用户的Session中不存在Token(令牌),则用户是重复提交了表单
if(server_token==null){
return true;
}
//3、存储在Session中的Token(令牌)与表单提交的Token(令牌)不同,则用户是重复提交了表单
if(!client_token.equals(server_token)){
return true;
}
return false;
}
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}
·
方案二:判断请求url和数据是否和上一次相同
推荐,非常简单,页面不需要任何传入,只需要在验证的controller方法上,写上自定义注解即可
写好自定义注解
/**
* 一个用户 相同url 同时提交 相同数据 验证
* @author Administrator
*
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SameUrlData {
}
写好拦截器
/**
* 一个用户 相同url 同时提交 相同数据 验证
* 主要通过 session中保存到的url 和 请求参数。如果和上次相同,则是重复提交表单
* @author Administrator
*
*/
public class SameUrlDataInterceptor extends HandlerInterceptorAdapter{
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
SameUrlData annotation = method.getAnnotation(SameUrlData.class);
if (annotation != null) {
if(repeatDataValidator(request))//如果重复相同数据
return false;
else
return true;
}
return true;
} else {
return super.preHandle(request, response, handler);
}
}
/**
* 验证同一个url数据是否相同提交 ,相同返回true
* @param httpServletRequest
* @return
*/
public boolean repeatDataValidator(HttpServletRequest httpServletRequest)
{
String params=JsonMapper.toJsonString(httpServletRequest.getParameterMap());
String url=httpServletRequest.getRequestURI();
Map<String,String> map=new HashMap<String,String>();
map.put(url, params);
String nowUrlParams=map.toString();//
Object preUrlParams=httpServletRequest.getSession().getAttribute("repeatData");
if(preUrlParams==null)//如果上一个数据为null,表示还没有访问页面
{
httpServletRequest.getSession().setAttribute("repeatData", nowUrlParams);
return false;
}
else//否则,已经访问过页面
{
if(preUrlParams.toString().equals(nowUrlParams))//如果上次url+数据和本次url+数据相同,则表示城府添加数据
{
return true;
}
else//如果上次 url+数据 和本次url加数据不同,则不是重复提交
{
httpServletRequest.getSession().setAttribute("repeatData", nowUrlParams);
return false;
}
}
}
}
<mvc:interceptor>
<mvc:mapping path="/**"/>
<bean class="*.*.SameUrlDataInterceptor"/>
</mvc:interceptor>
·
方案三:使用本地锁,比如说AOP
使用本地锁,本地锁有很多种,比如使用了 ConcurrentHashMap 并发容器的putIfAbsent 方法;使用guava cache的机制。使用Content-MD5 进行加密 ,只要参数不变,key存在就阻止提交。
本地锁只适用于单机部署的应用。
①防止重复提交的注解
/**
* 防止重复提交的注解:
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Resubmit {
/**
* 延时时间 在延时多久后可以再次提交,默认为20
*
* @return Time unit is one second
*/
int delaySeconds() default 20;
}
②新建一个“锁”,通过对参数进行加锁和放锁来防止重复提交;
/**
* 防止重复提交的锁
*/
@Slf4j
public final class ResubmitLock {
/**
* 新建一个用于存放key的容器,容量为20;
*/
private static final ConcurrentHashMap<String, Object> LOCK_CACHEMAP = new ConcurrentHashMap<>(200);
/**
* 新建一个可定时线程池:
* - 核心线程数为5;
* - 设置任务数超过线程池容量以及任务队列的容量时的处理程序,这里是默默丢弃掉新来的任务,并抛出一个RejectedExecutionHandler拒绝处理异常
*/
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;
}
// 对参数进行md5加密:
public static String handleKey(String param) {
return DigestUtils.md5Hex(param == null ? "" : param);
}
/**
* 加锁:
* putIfAbsent 是原子操作,保证线程安全
* putIfAbsent在放入数据时,如果存在重复的key,那么putIfAbsent不会放入值,会返回存在的value,不进行替换
* @param key 对应的key
* @param value
* @return
*/
public boolean lock(final String key, Object value) {
//如果之前不存在该key,才会将该key和value存储起来,并返回true,
// 如果之前存在该key,这里会返回false;
return Objects.isNull(LOCK_CACHEMAP.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(),执行定时任务;
EXECUTOR.schedule(() -> {
LOCK_CACHEMAP.remove(key);
}, delaySeconds, TimeUnit.SECONDS);
}
}
}
·
③ 定义切面,完成环绕增强的逻辑,在里边进行加锁和放锁来实现防止重复提交:
关于Proceedingjoinpoint:
- Proceedingjoinpoint 继承了 JoinPoint,是在JoinPoint的基础上暴露出 proceed 这个方法。
- proceed很重要,这个是aop代理链执行的方法。
- 环绕通知=前置+目标方法执行+后置通知,proceed方法就是用于启动目标方法执行的;
- 暴露出proceed这个方法,就能支持 aop:around 这种环绕切面,就能走代理链中的增强方法;
- (而其他的几种切面只需要用到JoinPoint,这也是环绕通知和前置、后置通知方法的一个最大区别。这跟切面类型有关),
- 建议看一下 JdkDynamicAopProxy的invoke方法,了解一下代理链的执行原理。
/**
* 数据重复提交校验
**/
@Log4j
@Aspect
@Component
public class ResubmitDataAspect {
private final static String DATA = "data";
//因为key容器是一个Map类型,所以PRESENT是作为一个僵尸value、用于存储key的;
private final static Object PRESENT = new Object();
/**
* 处理重复提交的方法:
* @param joinPoint 连接点对象
* Proceedingjoinpoint 继承了 JoinPoint,是在JoinPoint的基础上暴露出 proceed 这个方法。
* proceed很重要,这个是aop代理链执行的方法。
* 环绕通知=前置+目标方法执行+后置通知,proceed方法就是用于启动目标方法执行的;
* 暴露出proceed这个方法,就能支持 aop:around 这种切面,就能走代理链中的增强方法;
* (而其他的几种切面只需要用到JoinPoint,这也是环绕通知和前置、后置通知方法的一个最大区别。这跟切面类型有关),
* 建议看一下 JdkDynamicAopProxy的invoke方法,了解一下代理链的执行原理。
* @return
* @throws Throwable
*/
@Around("@annotation(Resubmit)") //环绕增强
public Object handleResubmit(ProceedingJoinPoint joinPoint) throws Throwable {
//获取此连接点对象上的加@Resubmit防止重复提交注解的方法:
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 isLock = false;
try {
//如果是第一次提交,那么key容器内还没有该key参数,则返回true,加锁成功;
isLock = ResubmitLock.getInstance().lock(key, PRESENT);
if (isLock) {
//放行,进行AOP代理链中的下一个增强方法的调用
return joinPoint.proceed();
}
//如果是第二次第三次等重复提交,此时key容器内已有该key参数,则返回false,加锁失败;
else {
//抛出重复提交异常
return new RuntimeException("重复提交...");
}
} finally {
//设置解锁key和解锁时间,到时间了自动从key容器内移除该key;
ResubmitLock.getInstance().unLock(isLock, key, delaySeconds);
}
}
}
④使用@Resubmit注解,防止重复提交:
@RestController
public class TestController {
@PostMapping("/posts/save")
@Resubmit(delaySeconds = 10) //加了防止重复提交的注解
public ResponseDTO<BaseResponseDataDTO> saveOrder(OrderDTO requestDto) {
// TODO
return new ResponseDTO(); //模拟返回结果
}
}
·
方案四,使用分布式锁,比如说Redis:
现在大多数部署方式都是集群,所以可以采用分布式锁,改造如下:
之前AOP的方式是先建立一个concurrentHashMap、然后利用concurrentHashMap中的putIfAbsent()方法来进行判断key是否重复、继而判定当前请求是否重复提交,
使用Redis更为简单方便,直接利用redisTemplate的setIfAbsent方法即可,语义与Redis中的setnx命令一致;
- 如果key不存在,则加入当前key、并设置这个key的过期时间,并返回true,
- 如果set容器中已有该key了,则无法添加当前key、并返回false;
/**
* @user zyh
* @Date 2022/4/24 19:10
* @Title
* @Description
**/
@Aspect
@Component
public class LockMethodInterceptor {
//注入Redis模板类:
@Autowired
private RedisTemplate redisTemplate;
private final static String DATA = "data";
/**
* 处理重复提交的方法,对连接点对象中的方法即切入点,进行环绕增强;
*
* @param joinPoint
* @return
*/
@Around("execution(public * *(..)) && @annotation(Resubmit)")
public Object interceptor(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Resubmit lock = method.getAnnotation(Resubmit.class);
Object[] pointArgs = joinPoint.getArgs();
String lockKey = DigestUtils.md5Hex(getRequest(pointArgs));
String value = UUID.randomUUID().toString();
//之前AOP的方式是先建立一个concurrentHashMap、然后利用concurrentHashMap中的putIfAbsent()方法来进行判断key是否重复、继而判定当前请求是否重复提交,
//使用Redis更为简单方便,直接利用redisTemplate的setIfAbsent方法即可,语义与Redis中的setnx命令一致;
// 如果key不存在,则加入当前key、并设置这个key的过期时间,并返回true,
// 如果set容器中已有该key了,则无法添加当前key、并返回false;
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, value, lock.delaySeconds(), TimeUnit.SECONDS);
if (!success) {
throw new RuntimeException("重复提交...");
}
try {
//放行,进行AOP代理链中的下一个增强方法的调用
return joinPoint.proceed();
} catch (Throwable throwable) {
throw new RuntimeException("系统异常");
}
}
/**
* 将请求参数转化为key,用于存入Redis中的set容器时判断当前请求是否是重复提交;
*
* @param params
* @return
*/
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";
}
}
}