重复提交的定义:
重复提交指的是同一个请求(请求地址和请求参数都相同)在很短的时间内多次提交至服务器,从而对服务器造成不必要的资源浪费,甚至在代码不健壮的情况还会导致程序出错。
重复提交的原因或触发事件:
- 【场景一】一次请求处理过慢,用户等不及点了多次提交按钮。
- 【场景二】提交请求之后,用户又多次点了刷新按钮或者点了回退
- 【场景三】同时打开了多个窗口提交数据。
重复提交的解决方案:
- 对于【场景一】
可以通过JS在用户点击按钮之后立即disable按钮,让他不能点。
如果是ajax提交的方式,那可以出现一个遮罩层。在服务器响应之前不让用户在做操作。
如果使用Jquery的$.ajax方法提交,可以将async设置成false,或者在beforeSend方法中开启遮罩层。
- 对于【场景二】和【场景三】
简单的解决方案是:将表单提交方式改成ajax提交,然后按上面的方式控制用户只能点一次提交按钮。而且ajax方式用户也不能回退和刷新。
对于【场景三】则需要一种稍复杂的方案就是令牌(Token),下面就来详细介绍令牌方案
- 令牌(Token)解决方案
方案步骤
1. 开发自定义注解来区分哪些请求方法是需要控制重复提交的。
1 @Target(ElementType.METHOD) 2 @Retention(RetentionPolicy.RUNTIME) 3 public @interface Token { 4 /** 5 * <p>保存并更新token,请在需要生成token的方法上设置save=true,例如:页面跳转的方法</p> 6 * @since 2019年4月4日 7 */ 8 boolean save() default false; 9 /** 10 * <p>校验token,请在提交的方法上加上本属性</p> 11 * @since 2019年4月4日 12 */ 13 boolean check() default false; 14 }
2. 使用过滤器或拦截器,配合上文的注解生成令牌和校验令牌。
本文使用得是Spring拦截器,Spring配置文件:
1 <mvc:interceptors> 2 <!-- token过滤器--> 3 <mvc:interceptor> 4 <mvc:mapping path="/**/*.do" /> 5 <bean class="com.kedacom.plmext.util.TokenInterceptor"/> 6 </mvc:interceptor> 7 </mvc:interceptors>
令牌拦截器(TokenInterceptor)代码,令牌是用uuid的形式:

1 @SuppressWarnings("unchecked") 2 public class TokenInterceptor extends HandlerInterceptorAdapter { 3 4 /** 5 * <a href="https://www.cnblogs.com/namelessmyth/p/10660526.html">使用Token控制重复提交方法</a><p> 6 * 请求预处理方法 <p> 7 * 对于标注@Token(save=true)的方法自动生成或刷新token,按URL来生成 <p> 8 * 对于标注@Token(check=true)的方法校验客户端的token是否在session中存在,如果不存在抛异常 <p> 9 */ 10 @Override 11 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 12 throws Exception { 13 if (handler instanceof HandlerMethod) { 14 HandlerMethod handlerMethod = (HandlerMethod) handler; 15 Method method = handlerMethod.getMethod(); 16 //只有设有注解的方法才处理 17 Token annotation = method.getAnnotation(Token.class); 18 if (annotation != null) { 19 //以请求url为维度 20 String url = getWholeUrl(request); 21 boolean needCheck = annotation.check(); 22 if (needCheck) { 23 // 校验令牌 24 checkToken(request); 25 } 26 boolean needSave = annotation.save(); 27 if (needSave) { 28 // 保存或刷新令牌 29 saveToken(request, url); 30 } 31 } 32 return true; 33 } else { 34 return super.preHandle(request, response, handler); 35 } 36 } 37 38 public String getWholeUrl(HttpServletRequest request) { 39 StringBuffer sb = new StringBuffer(request.getRequestURI()); 40 String queryString = request.getQueryString(); 41 if (queryString != null) { 42 sb.append('?').append(queryString); 43 } 44 return sb.toString(); 45 } 46 47 /** 48 * <p>生成或刷新Token,Token使用uuid生成.</p> 49 * @param tokenKey 令牌key,目前是url 50 * @since 2019年4月6日 51 */ 52 private void saveToken(HttpServletRequest request, String tokenKey) { 53 HttpSession session = request.getSession(false); 54 Object tokenObj = session.getAttribute("tokenMap"); 55 Map<String, String> tokenMap = null; 56 if (tokenObj == null) { 57 // 如果tokenMap为空 58 tokenMap = new HashMap<String, String>(); 59 tokenMap.put(tokenKey, UUID.randomUUID().toString()); 60 session.setAttribute("tokenMap", tokenMap); 61 } else if (tokenObj instanceof Map) { 62 // 如果tokenMap已经存在,就直接覆盖更新 63 tokenMap = (Map<String, String>) tokenObj; 64 tokenMap.put(tokenKey, UUID.randomUUID().toString()); 65 } 66 if (tokenMap != null) { 67 request.setAttribute("token", tokenMap.get(tokenKey)); 68 } 69 } 70 71 /** 72 * <p>Token校验,比对客户端传过来的token是否在session中存在.</p> 73 * @since 2019年4月6日 74 */ 75 private void checkToken(HttpServletRequest request) { 76 // 判断客户端传过来的token是否在session的tokenMap中存在,存在则移除,不存在就是重复提交 77 String tokenKey = null; 78 HttpSession session = request.getSession(false); 79 if (session == null) { 80 throw new BusinessRuntimeException("当前会话已结束,请重新登录!"); 81 } 82 Map<String, String> tokenMap = (Map<String, String>) session.getAttribute("tokenMap"); 83 if (tokenMap != null && !tokenMap.isEmpty()) { 84 String clinetToken = request.getParameter("token"); 85 if (clinetToken != null) { 86 Iterator<Map.Entry<String, String>> it = tokenMap.entrySet().iterator(); 87 while (it.hasNext()) { 88 Map.Entry<String, String> entry = it.next(); 89 if (clinetToken.equals(entry.getValue())) { 90 tokenKey = entry.getKey(); 91 break; 92 } 93 } 94 } 95 } 96 if (tokenKey == null) { 97 // 如果最终没有在Session中找到已存在的Key 98 throw new BusinessRuntimeException("当前页面已过期,请刷新页面后再试!或者您也可以在最后打开的页面中操作!"); 99 } 100 } 101 }
在控制器的页面初始化方法和提交方法中配置注解

1 1 @RequestMapping("replaceConfig.do") 2 2 @Token(save = true) 3 3 public ModelAndView replaceConfig(HttpServletRequest req, ReplaceVO input) throws Exception { 4 4 String changeNumber = req.getParameter("ContextName"); 5 5 input.setChangeNumber(changeNumber); 6 6 input.setCurrentUserAccount(ContextUtil.getCurrentUser().getAccount()); 7 7 if (BooleanUtils.isFalse(input.getIsAdmin())) { 8 8 input.setIsAdmin(ContextUtil.currentUserHasRole(BusinessConstants.ROLE_PLM_ADMIN)); 9 9 } 10 10 return new ModelAndView("plm/replace/replaceConfig").addObject("vo", input); 11 11 } 12 12 13 13 @RequestMapping("saveReplace.do") 14 14 @ResponseBody 15 15 @Token(check = true) 16 16 public Result saveReplace(HttpServletRequest req, String jsonStr) throws Exception { 17 17 Result result = Result.getInstanceError(); 18 18 ReplaceVO vo = JSON.parseObject(jsonStr, ReplaceVO.class); 19 19 try { 20 20 synchronized (lock) { 21 21 if (lock.contains(vo.getPitemNumber())) { 22 22 throw new BusinessRuntimeException("受影响物件存在未完成操作,请稍后再试!当前正在处理的受影响物件列表:" + lock); 23 23 } else { 24 24 lock.add(vo.getPitemNumber()); 25 25 } 26 26 } 27 27 result = replaceService.saveReplace(vo); 28 28 } catch (BusinessRuntimeException e) { 29 29 result.setMsg(e.getMessage()); 30 30 log.error("saveReplace_exception:" + e.getMessage()); 31 31 } catch (Exception e) { 32 32 result.setMsg(ExceptionUtils.getRootCauseMessage(e)); 33 33 log.error("saveReplace_exception_input:" + jsonStr, e); 34 34 } finally { 35 35 lock.remove(vo.getPitemNumber()); 36 36 } 37 37 return result; 38 38 }
在页面隐藏域保存令牌,并在提交时将令牌传给服务端
<input type="hidden" id="token" name="token" value="${token}" />
效果:
如果用户又打开了一个相同的页面,服务器端令牌就会刷新,这时候再提交先前打开的页面就会报错。
如果将提交请求也设置为可以刷新令牌,那同样的请求提交2次就会报错。