一、前言
由于网络原因,用户操作有误(连续点击两次以上提交按钮),或者页面卡顿等原因,可能会出现请求重复提交,造成数据库保存多条重复数据。后端实现拦截器防重。
那么如何防止请求重复提交呢?一般有两种解决方案:
第一种:前端处理,在提交完成之后,将按钮禁用。
第二种:后端处理,使用拦截器拦截。
交给前端解决,判断多长时间内不能再次点击按钮,或者点击之后禁用按钮,当然,聪明的小伙伴能够绕过前端验证,因此推荐后端进行拦截处理。
二、实现思路
使用拦截器防止请求重复提交,本文模仿若依防重给大家分享,利用 AOP 切面在进入方法前拦截,通过 Session 或 Redis 的 key-value 键值对存储,指定 key+url+消息头 来拼成字符串组成 key,使用 请求参数+时间 封装 map 对象赋值 value,当 key 不存在时,则为新的请求;若存在,则对请求参数以及请求的间隔时间进行判断是否重复提交。
2.1、自定义注解防止表单重复提交
package com.dian.jiao.interceptor.annotation;import java.lang.annotation.*;/*** 自定义注解防止表单重复提交*/
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {/*** 间隔时间(ms),小于此时间视为重复提交*/public int interval() default 5000;/*** 提示消息*/public String message() default "不允许重复提交,请稍候再试";
}
2.2、构建包装器
package com.dian.jiao.interceptor.wrapper;import com.dian.jiao.util.ServletUtils;import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;/*** 构建可重复读取inputStream的request*/
public class RepeatedlyRequestWrapper extends HttpServletRequestWrapper {public final String UTF8 = "UTF-8";private final byte[] body;public RepeatedlyRequestWrapper(HttpServletRequest request, ServletResponse response) throws IOException {super(request);request.setCharacterEncoding(UTF8);response.setCharacterEncoding(UTF8);body = ServletUtils.getBodyString(request).getBytes(UTF8);}@Overridepublic BufferedReader getReader() throws IOException {return new BufferedReader(new InputStreamReader(getInputStream()));}@Overridepublic ServletInputStream getInputStream() throws IOException {final ByteArrayInputStream bais = new ByteArrayInputStream(body);return new ServletInputStream() {@Overridepublic int read() throws IOException {return bais.read();}@Overridepublic int available() throws IOException {return body.length;}@Overridepublic boolean isFinished() {return false;}@Overridepublic boolean isReady() {return false;}@Overridepublic void setReadListener(ReadListener readListener) {}};}
}
2.3、防止重复提交拦截器
package com.dian.jiao.interceptor;import com.dian.jiao.interceptor.annotation.RepeatSubmit;
import com.dian.jiao.interceptor.wrapper.RepeatedlyRequestWrapper;
import com.dian.jiao.pojo.User;
import com.dian.jiao.util.CommonUtils;
import com.dian.jiao.util.ServletUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;/*** 防止重复提交拦截器*/
public class RepeatSubmitInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (handler instanceof HandlerMethod) {// 处理器方法HandlerMethod handlerMethod = (HandlerMethod) handler;// 获取处理方法中的注册方法Method method = handlerMethod.getMethod();// 从注册方法中获取到自定义注解RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);// 判断注解是否存在if (annotation != null) {// 判断是否重复提交if (this.isRepeatSubmit(request, annotation)) {// 将弹框字符串渲染到客户端ServletUtils.alert(response, annotation.message());return false;}}}return true;}/*** 验证是否重复提交,实现具体的防重复提交的规则*/@SuppressWarnings("unchecked")private boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation) {HttpSession session = request.getSession();User user = (User) session.getAttribute("loginUser");String nowParams = "";if (request instanceof RepeatedlyRequestWrapper) {RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;nowParams = ServletUtils.getBodyString(repeatedlyRequest);}// body参数为空,获取Parameter的数据if (nowParams == null || "".equals(nowParams.trim()) {nowParams = CommonUtils.toJSONString(request.getParameterMap());}Map<String, Object> nowDataMap = new HashMap<>(2);nowDataMap.put(CommonUtils.REPEAT_PARAMS, nowParams);nowDataMap.put(CommonUtils.REPEAT_TIME, System.currentTimeMillis());// 请求地址(作为存放cache的key值)String url = request.getRequestURI();// 用户IDString submitKey = user == null ? ServletUtils.getIpAddr(request) : String.valueOf(user.getId());// 唯一标识(指定key + url + 消息头)String cacheRepeatKey = CommonUtils.REPEAT_SUBMIT_KEY + url + submitKey;Object sessionObj = session.getAttribute(cacheRepeatKey);if (sessionObj != null) {Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;if (sessionMap.containsKey(url)) {Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);if (CommonUtils.compareParams(nowDataMap, preDataMap) && CommonUtils.compareTime(nowDataMap, preDataMap, annotation.interval())) {return true;}}}Map<String, Object> cacheMap = new HashMap<>(1);cacheMap.put(url, nowDataMap);session.setAttribute(cacheRepeatKey, cacheMap);return false;}
}
2.4、客户端工具类
package com.dian.jiao.util;import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import java.io.*;
import java.nio.charset.StandardCharsets;/*** 客户端工具类*/
public class ServletUtils {/*** 获取body请求参数* @param request 请求对象{@link ServletRequest}* @return String*/public static String getBodyString(ServletRequest request) {StringBuilder sb = new StringBuilder();BufferedReader reader = null;try (InputStream inputStream = request.getInputStream()) {reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));String line = "";while ((line = reader.readLine()) != null) {sb.append(line);}} catch (IOException e) {e.printStackTrace();} finally {if (reader != null) {try {reader.close();} catch (IOException e) {e.printStackTrace();}}}return sb.toString();}/*** 将弹框字符串渲染到客户端** @param response 渲染对象* @param msg 待渲染的弹框字符串*/public static void alert(HttpServletResponse response, String msg) {try {response.reset();response.setHeader("Content-type", "text/html;charset=UTF-8");response.setCharacterEncoding("utf-8");PrintWriter out = response.getWriter();out.print("<script>");out.print("alert(\"" + msg + "\");");out.print("history.back();");out.print("</script>");} catch (IOException e) {e.printStackTrace();}}/*** 获取客户端IP* * @param request 请求对象* @return IP地址*/public static String getIpAddr(HttpServletRequest request) {if (request == null) {return "unknown";}String ip = request.getHeader("x-forwarded-for");if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("Proxy-Client-IP");}if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("X-Forwarded-For");}if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("WL-Proxy-Client-IP");}if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("X-Real-IP");}if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {ip = request.getRemoteAddr();}return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : getMultistageReverseProxyIp(ip);}/*** 从多级反向代理中获得第一个非unknown IP地址** @param ip 获得的IP地址* @return 第一个非unknown IP地址*/public static String getMultistageReverseProxyIp(String ip) {// 多级反向代理检测if (ip != null && ip.indexOf(",") > 0) {final String[] ips = ip.trim().split(",");for (String subIp : ips) {if (false == isUnknown(subIp)) {ip = subIp;break;}}}return StringUtils.substring(ip, 0, 255);}/*** 检测给定字符串是否为未知,多用于检测HTTP请求相关** @param checkString 被检测的字符串* @return 是否未知*/public static boolean isUnknown(String checkString) {return StringUtils.isBlank(checkString) || "unknown".equalsIgnoreCase(checkString);}
}
2.5、公共工具类
package com.dian.jiao.util;import com.fasterxml.jackson.databind.ObjectMapper;import java.util.Map;public class CommonUtils {public static final String REPEAT_PARAMS = "repeatParams";public static final String REPEAT_TIME = "repeatTime";public static final String REPEAT_SUBMIT_KEY = "repeat_submit:";/*** 判断参数是否相同*/public static boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap) {String nowParams = (String) nowMap.get(REPEAT_PARAMS);String preParams = (String) preMap.get(REPEAT_PARAMS);return nowParams.equals(preParams);}/*** 判断两次间隔时间*/public static boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap, int interval) {long time1 = (Long) nowMap.get(REPEAT_TIME);long time2 = (Long) preMap.get(REPEAT_TIME);if ((time1 - time2) < interval) {return true;}return false;}public static String toJSONString(Object object) {if (object != null) {try {return new ObjectMapper().writeValueAsString(object);} catch (Exception e) {e.printStackTrace();}}return null;}
}
2.6、配置springmvc-servlet.xml,添加拦截器
<mvc:interceptors><mvc:interceptor><mvc:mapping path="/**"/><!-- 配置不拦截的请求 --><mvc:exclude-mapping path="/login"/><mvc:exclude-mapping path="/getCode"/><bean class="com.dian.jiao.interceptor.RepeatSubmitInterceptor"/></mvc:interceptor>
</mvc:interceptors>
三、使用教程
在接口方法上添加 @RepeatSubmit
注解即可,注解参数说明:
参数 | 类型 | 默认值 | 描述 |
---|---|---|---|
interval | int | 5000 | 间隔时间(ms),小于此时间视为重复提交 |
message | String | 不允许重复提交,请稍后再试 | 提示消息 |
示例1:采用默认参数
@RepeatSubmit
public AjaxResult addSave()
{return AjaxResult.success();
}
示例2:指定防重复时间和错误消息
@RepeatSubmit(interval = 3000, message = "您已经报名,不能重复报名")
public AjaxResult addSave()
{return AjaxResult.success();
}