在絕大多數提交的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);
    }

}