控制请求重复提交的方法总结(Token)

时间:2023-03-08 21:50:49

重复提交的定义:

  重复提交指的是同一个请求(请求地址和请求参数都相同)在很短的时间内多次提交至服务器,从而对服务器造成不必要的资源浪费,甚至在代码不健壮的情况还会导致程序出错。

重复提交的原因或触发事件:

  • 【场景一】一次请求处理过慢,用户等不及点了多次提交按钮。
  • 【场景二】提交请求之后,用户又多次点了刷新按钮或者点了回退
  • 【场景三】同时打开了多个窗口提交数据。

重复提交的解决方案:

  • 对于【场景一】

  可以通过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配置文件:

 <mvc:interceptors>
<!-- token过滤器-->
<mvc:interceptor>
<mvc:mapping path="/**/*.do" />
<bean class="com.kedacom.plmext.util.TokenInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>

  令牌拦截器(TokenInterceptor)代码,令牌是用uuid的形式:

 @SuppressWarnings("unchecked")
public class TokenInterceptor extends HandlerInterceptorAdapter { /**
* <a href="https://www.cnblogs.com/namelessmyth/p/10660526.html">使用Token控制重复提交方法</a><p>
* 请求预处理方法 <p>
* 对于标注@Token(save=true)的方法自动生成或刷新token,按URL来生成 <p>
* 对于标注@Token(check=true)的方法校验客户端的token是否在session中存在,如果不存在抛异常 <p>
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
//只有设有注解的方法才处理
Token annotation = method.getAnnotation(Token.class);
if (annotation != null) {
//以请求url为维度
String url = getWholeUrl(request);
boolean needCheck = annotation.check();
if (needCheck) {
// 校验令牌
checkToken(request);
}
boolean needSave = annotation.save();
if (needSave) {
// 保存或刷新令牌
saveToken(request, url);
}
}
return true;
} else {
return super.preHandle(request, response, handler);
}
} public String getWholeUrl(HttpServletRequest request) {
StringBuffer sb = new StringBuffer(request.getRequestURI());
String queryString = request.getQueryString();
if (queryString != null) {
sb.append('?').append(queryString);
}
return sb.toString();
} /**
* <p>生成或刷新Token,Token使用uuid生成.</p>
* @param tokenKey 令牌key,目前是url
* @since 2019年4月6日
*/
private void saveToken(HttpServletRequest request, String tokenKey) {
HttpSession session = request.getSession(false);
Object tokenObj = session.getAttribute("tokenMap");
Map<String, String> tokenMap = null;
if (tokenObj == null) {
// 如果tokenMap为空
tokenMap = new HashMap<String, String>();
tokenMap.put(tokenKey, UUID.randomUUID().toString());
session.setAttribute("tokenMap", tokenMap);
} else if (tokenObj instanceof Map) {
// 如果tokenMap已经存在,就直接覆盖更新
tokenMap = (Map<String, String>) tokenObj;
tokenMap.put(tokenKey, UUID.randomUUID().toString());
}
if (tokenMap != null) {
request.setAttribute("token", tokenMap.get(tokenKey));
}
} /**
* <p>Token校验,比对客户端传过来的token是否在session中存在.</p>
* @since 2019年4月6日
*/
private void checkToken(HttpServletRequest request) {
// 判断客户端传过来的token是否在session的tokenMap中存在,存在则移除,不存在就是重复提交
String tokenKey = null;
HttpSession session = request.getSession(false);
if (session == null) {
throw new BusinessRuntimeException("当前会话已结束,请重新登录!");
}
Map<String, String> tokenMap = (Map<String, String>) session.getAttribute("tokenMap");
if (tokenMap != null && !tokenMap.isEmpty()) {
String clinetToken = request.getParameter("token");
if (clinetToken != null) {
Iterator<Map.Entry<String, String>> it = tokenMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, String> entry = it.next();
if (clinetToken.equals(entry.getValue())) {
tokenKey = entry.getKey();
break;
}
}
}
}
if (tokenKey == null) {
// 如果最终没有在Session中找到已存在的Key
throw new BusinessRuntimeException("当前页面已过期,请刷新页面后再试!或者您也可以在最后打开的页面中操作!");
}
}
}

在控制器的页面初始化方法和提交方法中配置注解

  1     @RequestMapping("replaceConfig.do")
2 @Token(save = true)
3 public ModelAndView replaceConfig(HttpServletRequest req, ReplaceVO input) throws Exception {
4 String changeNumber = req.getParameter("ContextName");
5 input.setChangeNumber(changeNumber);
6 input.setCurrentUserAccount(ContextUtil.getCurrentUser().getAccount());
7 if (BooleanUtils.isFalse(input.getIsAdmin())) {
8 input.setIsAdmin(ContextUtil.currentUserHasRole(BusinessConstants.ROLE_PLM_ADMIN));
9 }
10 return new ModelAndView("plm/replace/replaceConfig").addObject("vo", input);
11 }
12
13 @RequestMapping("saveReplace.do")
14 @ResponseBody
15 @Token(check = true)
16 public Result saveReplace(HttpServletRequest req, String jsonStr) throws Exception {
17 Result result = Result.getInstanceError();
18 ReplaceVO vo = JSON.parseObject(jsonStr, ReplaceVO.class);
19 try {
20 synchronized (lock) {
21 if (lock.contains(vo.getPitemNumber())) {
22 throw new BusinessRuntimeException("受影响物件存在未完成操作,请稍后再试!当前正在处理的受影响物件列表:" + lock);
23 } else {
24 lock.add(vo.getPitemNumber());
25 }
26 }
27 result = replaceService.saveReplace(vo);
28 } catch (BusinessRuntimeException e) {
29 result.setMsg(e.getMessage());
30 log.error("saveReplace_exception:" + e.getMessage());
31 } catch (Exception e) {
32 result.setMsg(ExceptionUtils.getRootCauseMessage(e));
33 log.error("saveReplace_exception_input:" + jsonStr, e);
34 } finally {
35 lock.remove(vo.getPitemNumber());
36 }
37 return result;
38 }

在页面隐藏域保存令牌,并在提交时将令牌传给服务端

<input type="hidden" id="token" name="token" value="${token}" />

效果:

如果用户又打开了一个相同的页面,服务器端令牌就会刷新,这时候再提交先前打开的页面就会报错。

如果将提交请求也设置为可以刷新令牌,那同样的请求提交2次就会报错。