后端如何防止重复提交

原文链接:

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";
            }
      }

}