在绝大多数提交的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一起发送到服务端。
javascript
/**
* 当前应用的统一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) {
// 生成请求唯一的UUID
submitId = 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 found
if (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的缓存,防止下次提交的请求也被拦截。
java
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;
@Autowired
public DoubleSubmitInterceptor(RedisUtils redisUtils) {
this.redisUtils = redisUtils;
}
@Override
public 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;
}
@Override
public 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()获取请求头了。
javascript
/**
* 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) {
// 生成请求唯一的UUID
submitId = 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的方式。
java
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;
@Autowired
public DoubleSubmitInterceptor(RedisUtils redisUtils) {
this.redisUtils = redisUtils;
}
@Override
public 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;
}
@Override
public 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()方法。
java
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;
@Autowired
public SpringMvcConfig(DoubleSubmitInterceptor doubleSubmitInterceptor) {
this.doubleSubmitInterceptor = doubleSubmitInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(doubleSubmitInterceptor);
}
}