如何解决表达重复提交? -- 2022-01-05

哈喽,大家好,我是指北君。
关于表单的提交相信作为一个后端开发接触过不少,本文将介绍如何解决表单重复提交的问题。

1、表单提交案例

我们通过一个 jsp 页面提交表单到 servlet 进行处理。项目结构如下:
首先看 JSP 页面:from01.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8"%>
<%
    String path = request.getContextPath();
    String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort()
            + path;
%>
<!DOCTYPE html>
<head>
    <title>Title</title>
</head>
<body>

    <form action="<%=basePath%>/toServlet01" method="post">
        用户名:<input type="text" name="userName">
        <input type="submit" value="提交" id="submit">
    </form>
</body>
</html>

接着我们看 servlet 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.ys.servlet;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * Create by YSOcean
 */
@WebServlet("/toServlet01")
public class FormServlet01 extends HttpServlet{
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String username = req.getParameter("userName");
        try {
            //模拟网络延时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("提交表单");
        resp.setContentType("text/html;charset=UTF-8");
        resp.getWriter().print("提交成功!!!");
    }
}

我们将该项目部署到 tomcat 服务器,然后启动服务器,在浏览器中输入相应地址,点击表单中的提交按钮,后台正常情况下应该打印出提交表单的字样,然后前台页面输出提交成功。

2、表单重复提交的三种情况

上面我们演示的是正常点击提交的情况,但是实际上用户可能进行多次提交的操作。

①、多次点击提交按钮

这是最明显的一种情况,可能由于我们点击一次按钮后,系统后台对提交操作进行处理有一定的延时,于是页面停在表单提交页面。而当前用户不知道,以为没有提交表单,于是又进行按钮点击,造成表单多次提交。

②、用户提交表单成功之后不断点击浏览器【刷新】按钮

③、提交表单成功后,点击浏览器【回退】箭头,回到表单提交页面,然后重新点击提交按钮

3、前端解决办法

①、onsubmit() 方法

在表单中增加onsubmit() 方法,该方法在表单提交时触发,返回false时,表单就不会被提交。针对用户多次点击按钮提交的问题,我们在前端控制表单提交一次之后,将 onsubmit() 方法返回值改为false,那么第二次点击提交按钮,表单将不能进行提交。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8"%>
<%
    String path = request.getContextPath();
    String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort()
            + path;
%>
<!DOCTYPE html>
<head>
    <title>Title</title>
</head>
<script type="text/javascript">
    var isFlag = false;
    function dosubmit(){
        if(!isFlag){
            isFlag = true;
            return true;
        }else{
            return false;
        }
    }

</script>
<body>
    <form action="<%=basePath%>/toServlet01" method="post" onsubmit="return dosubmit()">
        用户名:<input type="text" name="userName">
        <input type="submit" value="提交" id="submit">
    </form>
</body>
</html>


②、表单提交之后,将按钮设置不可点击

1
2
3
4
5
6
7
8
function dosubmit(){
        //获取表单提交按钮
        var btnSubmit = document.getElementById("submit");
        //将表单提交按钮设置为不可用,这样就可以避免用户再次点击提交按钮
        btnSubmit.disabled= "disabled";
        //返回true让表单可以正常提交
        return true;
    }

存在问题:前面这两种方法只能应对用户多次点击提交按钮的情况,也就是上面的第一种情况。但是对于提交之后多次刷新以及点击回退按钮,再次提交的这两种情况却没有效果。这时候就需要在后端进行解决。

4、后端解决

具体做法:

在服务器端生成一个唯一的随机标识号,专业术语称为Token(令牌),同时在当前用户的Session域中保存这个Token。然后将Token发送到客户端的Form表单中,在Form表单中使用隐藏域来存储这个Token,表单提交的时候连同这个Token一起提交到服务器端,然后在服务器端判断客户端提交上来的Token与服务器端生成的Token是否一致,如果不一致,那就是重复提交了,此时服务器端就可以不处理重复提交的表单。如果相同则处理表单提交,处理完后清除当前用户的Session域中存储的标识号。

在下列情况下,服务器程序将拒绝处理用户提交的表单请求:

  1、存储Session域中的Token(令牌)与表单提交的Token(令牌)不同。(包括伪造Token)

  2、当前用户的Session中不存在Token(令牌)。

  3、用户提交的表单数据中没有Token(令牌)。

①、首先通过服务器端的 servlet 跳转到表单提交页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.ys.servlet;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

/**
 * Create by YSOcean
 */
@WebServlet("/toForm")
public class ToFromServlet extends HttpServlet{
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String tokenId = UUID.randomUUID().toString();
        req.getSession().setAttribute("tokenId",tokenId);
        req.getRequestDispatcher("from01.jsp").forward(req,resp);
    }
}

②、表单页面增加隐藏域存储tokenId

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8" isELIgnored="false"%>
<%
    String path = request.getContextPath();
    String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort()
            + path;
%>
<!DOCTYPE html>
<head>
    <title>Title</title>
</head>
<script type="text/javascript">
    var isFlag = false;
    /*function dosubmit(){
        if(!isFlag){
            isFlag = true;
            return true;
        }else{
            return false;
        }
    }*/
    function dosubmit(){
        //获取表单提交按钮
        var btnSubmit = document.getElementById("submit");
        //将表单提交按钮设置为不可用,这样就可以避免用户再次点击提交按钮
        btnSubmit.disabled= "disabled";
        //返回true让表单可以正常提交
        return true;
    }

</script>
<body>
    <form action="<%=basePath%>/toServlet01" method="post" onsubmit="return dosubmit()">
        <input type="hidden" name="tokenId" value="${tokenId}">
        用户名:<input type="text" name="userName">
        <input type="submit" value="提交" id="submit">
    </form>
</body>
</html>

③、提交表单,后端进行是否重复判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.ys.servlet;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * Create by YSOcean
 */
@WebServlet("/toServlet01")
public class FormServlet01 extends HttpServlet{
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("text/html;charset=UTF-8");

        String username = req.getParameter("userName");
        Boolean flag = isRepeatSubmit(req);
        if(flag){
            resp.getWriter().print("请不要重复提交!!!");
            return;
        }
        try {
            //模拟网络延时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("提交表单");

        resp.getWriter().print("提交成功!!!");
    }

    private boolean isRepeatSubmit(HttpServletRequest request){
        //1、获取存储在request域中的tokenId
        String req_tokenId = request.getParameter("tokenId");
        //req_tokenId == null 表示表单中没有token,即用户不是通过servlet跳转到该页面或者是重复提交
        if(req_tokenId == null) {
            return true;
        }

        //2、获取存储在session域中的tokenId
        String session_tokenId = (String) request.getSession().getAttribute("tokenId");
        //如果当前session域中的tokenId为null,则表示用户重复提交(每次提交之后会移除该session域中的tokenId)
        if(session_tokenId == null){
            return true;
        }

        //3、存储在session域中的tokenId和表单隐藏域保存提交的tokenId不同,则表示用户伪造tokenId或者重复提交
        if(!session_tokenId.equals(req_tokenId)){
            return true;
        }
        //移除session域中的tokenId
        request.getSession().removeAttribute("tokenId");
        return false;
    }
}


上面主要是利用一次回话中session域存储的数据是保持不变的,而request域只能保存一次请求的数据。

注意:页面首先要通过 servlet 进行跳转过去,不能直接访问jsp页面。先在 servlet 中生成一个 tokenId,然后将tokenId存入到session域中,在转发到jsp表单页面,在表单页面中,通过隐藏域存放生成的tokenId,然后点击提交按钮,会将隐藏域的tokenId 也一起提交到后端。后端首先判断表单中的tokenId值,以及和session域中的tokenId 值进行对比,表单中的tokenId为null,则说明是直接访问的jsp页面,session域中的tokenId 为null,则说明不是第一次提交,因为第一次提交成功之后会清空session域中的tokenId。都不为null,且两者不相等,则说明可能是伪造的tokenId;不为null,且相等,则说明是第一次提交。

这里要注意销毁session域中的tokenId时机,是在判断完是否重复提交的方法中最后就销毁了,这样可以防止还没销毁session域中的tokenId,客户端的请求又来了。

5、session共享问题

通过上面前后端的解决表单重复提交的问题,我们看似解决了,其实不然,对于各种分布式项目,为了解决高并发的问题,我们会将前端请求通过 nginx 负载到多个tomcat服务器,如下:

这里会存在这样一个问题:

首先通过 tomcat1 将请求跳转到表单页面,这时候tokenId 是存放在tomcat1 session域中,然后点击提交按钮,nginx 可能会将我们的请求分发到 tomcat2 上,而tomcat2 的session 域中是不存在 tokenId 的,这时候我们提交不了表单。

这也是session共享问题。也就是说我们必须找到一个存放 tokenId 的公共介质,无论是哪个服务器去处理请求,都是从公共介质中获取 tokenId,那么当然不会存在tokenId 不一致的问题。

解决办法:

①、利用数据库同步:也就说将 tokenId 存放在数据库中,每次获取的时候从数据库中查询,这能解决,但是对数据的访问压力增大,不太合适。

②、利用 cookie 同步:因为 cookie 是存在本地客户端的,第一次请求我们将tokenId 存放在cookie中,然后从cookie进行是否重复提交校验,这也能解决问题。但是cookie 存在安全性问题,而且每次http请求都要带上参数也增加了带宽消耗。

③、利用 Redis 同步:这是最好的一种办法,Redis是一个高性能缓存框架,我们将 tokenId 存放在Redis中,获取也从Redis中获取,而且Redis性能极佳。

Java Geek Tech wechat
欢迎订阅 Java 技术指北,这里分享关于 Java 的一切。