重復提交的定義:
重復提交指的是同一個請求(請求地址和請求參數都相同)在很短的時間內多次提交至服務器,從而對服務器造成不必要的資源浪費,甚至在代碼不健壯的情況還會導致程序出錯。
重復提交的原因或觸發事件:
- 【場景一】一次請求處理過慢,用戶等不及點了多次提交按鈕。
- 【場景二】提交請求之后,用戶又多次點了刷新按鈕或者點了回退
- 【場景三】同時打開了多個窗口提交數據。
重復提交的解決方案:
- 對於【場景一】
可以通過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次就會報錯。