在絕大多數提交的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) {
// 生成請求唯一的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的緩存,防止下次提交的請求也被攔截。
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()獲取請求頭了。
/**
* 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;
}
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;
@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()方法。
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);
}
}