防止表单重复提交功能简单实现

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

}
相关推荐
熙客5 小时前
后端日志框架
java·开发语言·log4j·logback
全栈师5 小时前
LigerUI下frm与grid的交互
java·前端·数据库
摇滚侠5 小时前
Spring Boot3零基础教程,Kafka 的简介和使用,笔记76
spring boot·笔记·kafka
剑小麟5 小时前
maven中properties和dependencys标签的区别
java·前端·maven
Chief_fly5 小时前
Logback 配置精细化包日志控制
java·logback
i源5 小时前
IDEA好用的插件
java·intellij-idea
张乔246 小时前
spring boot项目快速整合xxl-job实现定时任务
spring boot·后端·xxl-job
程序定小飞6 小时前
基于springboot的论坛网站设计与实现
java·开发语言·spring boot·后端·spring
ZHE|张恒6 小时前
Java 泛型详解:类型参数的力量
java