防止表单重复提交功能简单实现
在绝大多数提交的post请求中,重复提交数据不但会导致重复操作,可能有些操作比较耗时(比如:导入数据,可能需要频繁操作数据库),所以用户很可能会误以为请求没有响应,所以再次点击操作按钮。
这种情况下的重复请求是没有意义的,而且还会占用服务端资源。
因此,防止表单重复提交功能很有必要,是对系统功能的优化。
本篇文章就介绍前端+后端的防止表单重复提交功能实现。
前端异步提交HTTP请求通常会使用两种技术:ajax和axios
实现这个功能的思路大致如下:
1、前端为每次提交数据的请求生成一个唯一的UUID,在提交数据的时候一起提交到服务端。
2、如果用户再次提交同样的请求,就判断这个uuid是不是一样,如果是,说明是重复的请求。
3、服务端使用处理器拦截器拦截所有提交数据的请求,获取请求携带的唯一UUID然后缓存起来,如果下次请求携带的UUID和这个一样,说明是重复的请求。
Ajax
前端代码
原生的JavaScript提供了异步请求的API,同时,JQuery也对ajax进行了封装,使得ajax更易于使用。这个章节介绍的就是使用JQuery Ajax在前端实现防止表单重复提交的功能。
1、通过前端缓存技术将这个uuid缓存起来,然后请求完成之后删除这个uuid。
- 这样就实现了一个请求一个uuid,不存在重复提交的可能
- 无论请求成功和失败,都应该从缓存删除uuid
2、发送请求时通过参数的形式将这个uuid一起发送到服务端。
/*** 当前应用的统一key前缀* @type {string}*/
const basePrefix = "mhxysy:";/*** 设置缓存到webStorage中* @param key 缓存的key* @param value 缓存的值*/
function setCacheToWebStorage(key, value) {localStorage.setItem(basePrefix + key, value);
}/*** 从webStorage中删除缓存* @param key 缓存的key*/
function removeCacheFromWebStorage(key) {localStorage.removeItem(basePrefix + key);
}/*** 从webStorage中获取缓存* @param key 缓存的key* @returns {string}*/
function getCacheFromWebStorage(key) {return localStorage.getItem(basePrefix + key);
}/*** 生成随机字符串* @param length 字符串的长度,默认11* @returns {string}*/
function generateRandomString(length = 11) {let charset = "abcdefghijklmnopqrstuvwxyz-_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";let values = new Uint32Array(length);window.crypto.getRandomValues(values);let str = "";for (let i = 0; i < length; i++) {str += charset[values[i] % charset.length];}return str;
}/*** 封装的ajax post请求* @param url 请求url* @param params 请求参数* @param success 成功回调函数* @param error 失败回调函数* @param async 是否异步*/
function ajaxPost(url, params, success, error, async = true) {let submitId = getCacheFromWebStorage(url);if (!submitId) {// 生成请求唯一的UUIDsubmitId = generateRandomString();setCacheToWebStorage(url , submitId);} else {// submitId已经存在,说明是重复提交alert("请勿重复操作!");return;}// 获取参数的类型:对象或字符串const type = typeof params;if (type === "string") { // 参数是字符串,则是提交表单数据的请求params += "&submitId=" + submitId;} else if (type === "object") { // 参数是JSON对象params.submitId = submitId;} else {throw new Error("非法的数据类型:" + type);}$.ajax({type: "POST",url: base + url,data: params,async: async,cache: false,dataType: "json",processData: true,success: function (resp) {removeCacheFromWebStorage(url);success(resp);},error: function (resp) {removeCacheFromWebStorage(url);error(resp);}});
}/*** 错误回调函数* @param resp*/
const error = (resp) => {let response = resp.responseJSON;// 请求有响应if (resp && response) {// 得到响应状态码let status = resp.status;if (status) {let message;if (status === 404) { // 404 not foundif (response.path) {message = "路径" + response.path + "不存在。";} else {message = response.message;}} else {message = response.message;}alertMsg(message, "error");} else {console.log("请求没有响应状态码~");}} else {console.log("请求无响应~");}
}后端代码
在后端需要对提交的这个uuid进行处理,创建一个处理器拦截器,获取这个uuid,如果请求是post请求,则进行防重处理。
- 在请求处理之前:缓存uuid,可以缓存到Redis里,设置过期时间,防止类似死锁的问题。
- 在请求处理完之后:删除这个uuid的缓存,防止下次提交的请求也被拦截。
package cn.edu.sgu.www.mhxysy.support;import cn.edu.sgu.www.common.exception.GlobalException;
import cn.edu.sgu.www.common.restful.ResponseCode;
import cn.edu.sgu.www.common.util.StringUtils;
import cn.edu.sgu.www.mhxysy.consts.RedisKeyPrefixes;
import cn.edu.sgu.www.mhxysy.redis.RedisUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;/*** 防止表单重复提交的拦截器* @author 沐雨橙风ιε* @version 1.0*/
@SuppressWarnings("all")
@Component
public class DoubleSubmitInterceptor implements HandlerInterceptor {private final RedisUtils redisUtils;@Autowiredpublic DoubleSubmitInterceptor(RedisUtils redisUtils) {this.redisUtils = redisUtils;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {String method = request.getMethod();// 对post请求进行拦截,防止表单重复提交/重复操作if ("post".equalsIgnoreCase(method)) {String submitId = request.getParameter("submitId");if (StringUtils.isNullOrEmpty(submitId)) {throw new GlobalException(ResponseCode.BAD_REQUEST, "缺失重要参数:submitId");}String requestURI = request.getRequestURI();String key = RedisKeyPrefixes.PREFIX_REQUEST + requestURI + ":" + submitId;if (redisUtils.hasKey(key)) {throw new GlobalException(ResponseCode.CONFLICT, "请勿重复操作!");}// 缓存到Redis中redisUtils.set(key, "1", 3, TimeUnit.MINUTES);}return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {String method = request.getMethod();// 对post请求进行拦截,防止表单重复提交/重复操作if ("post".equalsIgnoreCase(method)) {String submitId = request.getParameter("submitId");if (StringUtils.isNullOrEmpty(submitId)) {throw new GlobalException(ResponseCode.BAD_REQUEST, "缺失重要参数:submitId");}String requestURI = request.getRequestURI();String key = RedisKeyPrefixes.PREFIX_REQUEST + requestURI + ":" + submitId;redisUtils.delete(key);}}}Axios
Axios的功能和Ajax类似,都是提交异步请求的技术,当前主流的前端JavaScript框架Vue.js一般就会使用Axios发送异步请求。
前端代码
注意:Axios携带uuid的方式和Ajax有所区别。
由于Axios通过post请求提交的数据是放在响应体中的,后端需要通过@RequestBody注解获取提交的数据。所以,直接获取请求参数是获取不到的。
因此,通过请求头的方式携带这个uuid,在后端就可以通过request.getHeader()获取请求头了。
/*** axios工具类* @author 沐雨橙风ιε*/
import axios from "axios";
import {Message} from 'element-ui';
import {getCacheFromWebStorage,removeCacheFromWebStorage,setCacheToWebStorage
} from "@/assets/webStorage.js";// 天天生鲜超市后端项目地址
let baseURL = "http://localhost:8088";
// 网关api
//baseURL = "http://localhost:9091/api/ttsx";const instance = axios.create({baseURL: baseURL
});// 添加请求拦截器
instance.interceptors.request.use(function(config) {// 设置axios请求携带请求头const tokenName = getCacheFromWebStorage("tokenName");const tokenValue = getCacheFromWebStorage(tokenName);if (tokenValue) {config.headers[tokenName] = tokenValue;}return config;
}, function(err) {return Promise.reject(err);
});// 添加响应拦截器
instance.interceptors.response.use(function (resp) {return resp.data;
}, function (err) {error(err);return Promise.reject(err);
});/*** 发送post请求* @param url 请求路径* @param params 请求参数* @param success 成功回调*/
export const axiosPost = function (url, params, success) {let submitId = getCacheFromWebStorage(url);if (!submitId) {// 生成请求唯一的UUIDsubmitId = generateRandomString();setCacheToWebStorage(url , submitId);} else {// submitId已经存在,说明是重复提交alert("请勿重复操作!");return;}params.submitId = submitId;instance.post(url, params, {headers: {"submitId": submitId}}).then(function (response) {removeCacheFromWebStorage(url)success(response);}).catch(function () {removeCacheFromWebStorage(url);});
}/*** 统一的异常回调方法* @param err 异常对象*/
export const error = function (err) {let response = null;let resp = err.response;if (resp.data) {response = resp.data;}if (resp && response) {let code = response.code;if (code === 401) {window.alert("请先登录!");location.href = "/login";} else {Message({type: "error",showClose: true,message: response.message});}} else {console.log(err);}
}/*** 生成随机字符串* @param length 字符串的长度,默认11* @returns {string}*/
function generateRandomString(length = 11) {let charset = "abcdefghijklmnopqrstuvwxyz-_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";let values = new Uint32Array(length);window.crypto.getRandomValues(values);let str = "";for (let i = 0; i < length; i++) {str += charset[values[i] % charset.length];}return str;
}后端代码
只需要对前面的后端代码进行微调,修改两个方法里获取uuid的方式。
package cn.edu.sgu.www.ttsx.support;import cn.edu.sgu.www.common.exception.GlobalException;
import cn.edu.sgu.www.common.restful.ResponseCode;
import cn.edu.sgu.www.common.util.StringUtils;
import cn.edu.sgu.www.ttsx.consts.RedisKeyPrefixes;
import cn.edu.sgu.www.ttsx.redis.RedisUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;/*** 防止表单重复提交的拦截器* @author 沐雨橙风ιε* @version 1.0*/
@SuppressWarnings("all")
@Component
public class DoubleSubmitInterceptor implements HandlerInterceptor {private final RedisUtils redisUtils;@Autowiredpublic DoubleSubmitInterceptor(RedisUtils redisUtils) {this.redisUtils = redisUtils;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {String method = request.getMethod();// 对post请求进行拦截,防止表单重复提交/重复操作if ("post".equalsIgnoreCase(method)) {String submitId = request.getHeader("submitId");if (StringUtils.isNullOrEmpty(submitId)) {throw new GlobalException(ResponseCode.BAD_REQUEST, "缺失重要请求头:submitId");}String requestURI = request.getRequestURI();String key = RedisKeyPrefixes.PREFIX_REQUEST + requestURI + ":" + submitId;if (redisUtils.hasKey(key)) {throw new GlobalException(ResponseCode.CONFLICT, "请勿重复操作!");}// 缓存到Redis中redisUtils.set(key, "1", 3, TimeUnit.MINUTES);}return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {String method = request.getMethod();// 对post请求进行拦截,防止表单重复提交/重复操作if ("post".equalsIgnoreCase(method)) {String submitId = request.getHeader("submitId");if (StringUtils.isNullOrEmpty(submitId)) {throw new GlobalException(ResponseCode.BAD_REQUEST, "缺失重要请求头:submitId");}String requestURI = request.getRequestURI();String key = RedisKeyPrefixes.PREFIX_REQUEST + requestURI + ":" + submitId;redisUtils.delete(key);}}}注册拦截器
最后,还需要添加处理器拦截器到SpringMVC中。
在SpringMVC配置类中重写addInterceptors()方法。
import cn.edu.sgu.www.mhxysy.support.DoubleSubmitInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;/*** Spring Web MVC配置类* @author 沐雨橙风ιε* @version 1.0*/
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {private final DoubleSubmitInterceptor doubleSubmitInterceptor;@Autowiredpublic SpringMvcConfig(DoubleSubmitInterceptor doubleSubmitInterceptor) {this.doubleSubmitInterceptor = doubleSubmitInterceptor;}@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(doubleSubmitInterceptor);}}