Golang中如何解决表单提交时一次性点击触发两次的问题

Golang中如何解决表单提交时一次性点击触发两次的问题 我遇到一个问题:表单在单次点击时提交了两次。我已经移除了页面上的所有其他代码。没有JavaScript,也没有设置任何样式。我有很多其他页面使用相同的输入,它们都工作正常。我有一个日志文件显示,在同一秒内处理程序被调用了两次。我知道很多开发者都需要处理用户双击提交按钮的问题。我甚至尝试了建议的JavaScript代码来在点击时禁用按钮。但没有帮助。我绝对没有点击两次。我已经多次重现了这个问题。

这是一个用户管理表单,带有一个添加用户按钮。每次我点击它,它都会添加两个用户,是的,我的Go代码中的处理程序被调用了两次。

以下是简化后的页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Users</title>
</head>
<body>
        <form action="/generalLedger" method="get">
            <input name = "userID" value = "admin" hidden>
            <input name = "hashedPassword" value = "£¢¡" hidden>
            <input class="input-button" name="addUser" type="submit" value="Add User" id="addUser_btn">
        </form>
</body>
</html>

在服务器端,这是接收到的请求:

/generalLedger?userID=admin&hashedPassword=%C2%A3%C2%A2%C2%A1&addUser=Add+User
userID=admin
hashedPassword=%C2%A3%C2%A2%C2%A1
addUser=Add+User

这个请求在日志中出现了两次。


更多关于Golang中如何解决表单提交时一次性点击触发两次的问题的实战教程也可以访问 https://www.itying.com/category-94-b0.html

21 回复

如果让我猜的话,你可能有某些JavaScript代码或其他东西正在提交表单,并且没有阻止表单提交的默认行为。

更多关于Golang中如何解决表单提交时一次性点击触发两次的问题的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


感谢您的回复。但我没有使用JavaScript,也没有外部JS文件。确实有东西在提交,但我实在找不到它。

我最终得到这个: ��A2 香馘顶邇

这是由于您如何按百分号(%)分割字符串以及决定是否应对其间的代码进行解码。url.QueryUnescape 应该能为您处理所有这些。

code

嗯,我尝试调用 request.ParseForm() 然后 fmt.Println(request.Form),但得到了一个空映射,而 body, _ := ioutil.ReadAll(request.Body) 确实返回了想要的数据,尽管没有进行反转义处理。

func main() {
    fmt.Println("hello world")
}

非常感谢。这似乎确实解决了问题。我正在将我所有的“get”替换为“post”。在更新为 post 之后,我在处理请求体时遇到了一些错误,所以我现在正在处理这个问题。我对 HTML 相对较新,因为我一直是一名应用程序开发人员,从未做过网站开发。 当我获得更多信息时,我会更新。再次感谢。

打开开发者工具,观察网络活动,尝试了解发生了什么。我在想,那些GET请求是否指向浏览器会自动尝试获取的内容(例如favicon.ico;浏览器会在你未告知的情况下自动寻找它),并且由于你设置了一个全局处理器,所有请求都由该处理器处理。说到这个,你的路由设置代码是什么样的?你使用的是哪个路由器/多路复用器?

  1. url 包中提供了用于解码的函数,例如 PathUnescapeQueryUnescape
  2. 尝试使用 base64 编码来避免出现会被转义的不理想字符。

我无法猜测为什么后续请求会有所不同,但如果你有 JavaScript 代码向你的服务提交请求,你就能更好地控制转义,不过相应的责任就落在了你的代码上,需要确保 URL 包含正确的转义。

在这种情况下,查看浏览器的开发者工具,特别是网络部分,会很有帮助。你可以看到浏览器实际发出的请求,或者由于缓存而未发出的请求,以及它收到的状态码。注意查找重定向、重试等情况。此外,代理、反向代理、CDN等也可能在浏览器和你的服务代码之间进行干预。

这种情况也指出了服务幂等性的重要性。你可能会错误地多次收到相同的请求。什么是幂等性?

很高兴能帮你更进一步。你说得对,这确实是一个编码问题。请查阅这篇文章。你在服务器端是如何获取这个值的?是使用 request.FormValue 吗?这些字段是如何“到达”浏览器的?查看一下这个教程,我相信如果你按照它来做,就能解决你的问题。这个可能也有用。

我想知道是否出现了错误。尝试捕获错误:

err := request.ParseForm()
if err != nil {
    fmt.Println("解析表单时出现问题:", err.Error())
}

如果让我猜测,可能是因为你保留了调用 ioutil.ReadAll 的代码,并且它发生在你调用 request.ParseForm 之前,这会将读取器(request.Body)推进到 EOF。所以当调用 ParseForm 时,它已经处于 EOF 状态了。但这只是我的猜测。捕获错误并查看它说了什么(如果有的话)。

哦。你肯定需要改变你的方法!你当前的方法远比实际需要的要手动得多。你只关心表单中的数据,对吧?在 case "POST": 中,尝试调用 request.ParseForm(),然后查看 request.Form(为了便于调试,你可以使用 fmt.Println(request.Form))。你也可以使用 request.FormValue("hashedPassword"),它会自动调用 ParseForm。如果你像这样使用标准库,你的值将被正确解码,你就可以去掉你的 queryUnescape 函数了。

func main() {
    fmt.Println("hello world")
}

啊,是的,任何能让代码更简单的方法都会有帮助。我会研究一下。你知道为什么在 Safari 中清除缓存(开发/清空缓存)会从浏览器发送一个 GET 请求,并且这个请求在任何地方都不包含输入字段吗?然后,真正奇怪的是,我的服务器应用程序认为这是一个凭据问题,并希望使用 templates.ExecuteTemplate 调出名为 ‘login.html’ 的登录表单。然而,浏览器中显示的仍然是同一个表单,而不是登录表单。浏览器从未接收到新页面,这一点可以通过 Web 检查器证明,其中仍然包含凭据信息,而这些信息并不是由服务器提供的。

templates.ExecuteTemplate

你说得对!我在调用 ioutil.ReadAll 之后才调用 request.ParseForm(),现在简单多了,因为它返回一个映射。不再需要解码了。不过,我又回到了最初的问题,点击添加用户按钮会触发两次服务器调用,第一次是正确的,是’post’请求,但随后它会发出第二次调用,是’get’请求。类似于清除缓存时提交的’get’请求。没有凭据。我已经把我所有的’get’请求都改成了’put’请求。我想我下一步的方法是仔细查看网络检查器,看看是否能弄清楚发生了什么。

我还有一个HTML Golang模板的问题,但我会为那个问题另开一个帖子。它与这个无关。

感谢您的建议。看起来 url.QueryUnescape() 可以完成这个任务。我正在编写我的 queryUnescape 函数来处理返回的输入字段。以下是我目前的代码。它在处理密码时效果很好,我现在正在处理来自其他输入的各种情况:

func queryUnescape(str string)string{
	// example: %C2%A3%C2%A2%C2%A1
	unescaped := ""
	if strings.Contains(str, "%"){
		splits := strings.Split(str, "%")
		for _, code := range splits{
			if len(code) ==2 {
				unescapedCharacter, _ := url.QueryUnescape("%" + code)
				unescaped = unescaped + unescapedCharacter
			} else {
				unescaped = unescaped + code
			}
		}
	}else {
		return str
	}
	return unescaped
}

是的,我经常使用 Safari 网页检查器,但之前从未使用过“网络”标签页。看起来“标头”部分包含很多有用的信息。我遇到了两个问题。第一个是,如果我有一个包含撇号的输入标签字符串,当它在我服务器的处理程序中接收到时,会变成 %27。我该如何将其转换回撇号?第二个问题是,我在页面之间持久化的哈希密码是“£¢¡”。这个密码在浏览器端正确到达,但在下一次提交时,它到达服务器时变成了: %C2%A3%C2%A2%C2%A1 这看起来很奇怪,因为原始密码是 3 个字符,而接收到的似乎有 6 个字符,因为其中有 6 个 % 符号。然后,你可能猜到了,它在每次提交/接收时都会增长。在第二次迭代中,它变成了: %25C2%25A3%25C2%25A2%25C2%25A1 显然,我遇到了编码/解码问题。

现在正在处理这个:☢ 香馘顶邇 从浏览器传来的是:%E2%98%A2 %E9%A6%99%E9%A6%98%E9%A1%B6%E9%82%87 我最终得到的结果是: ��A2 香馘顶邇 在将函数更新为:

func queryUnescape(str string)string{
	// example: %C2%A3%C2%A2%C2%A1
	unescaped := ""
	if strings.Contains(str, "%"){

		splits := strings.Split(str, "%")
		for _, code := range splits{
			if len(code) ==2 {
				unescapedCharacter, err := url.QueryUnescape("%" + code)
				if err != nil {
					unescaped = unescaped + code
				}else {
					unescaped = unescaped + unescapedCharacter
				}

			} else {
				unescaped = unescaped + code
			}
		}
	}else {
		return str
	}
	return unescaped
}

奇怪,我本以为会得到一个错误。

你能把它改成POST请求吗?无论如何,你很可能不应该为此使用GET方法。根据w3 schools

  • 切勿使用GET发送敏感数据!(数据将在URL中可见)
  • 适用于用户希望为结果添加书签的表单提交
  • GET更适用于非安全数据,例如Google中的查询字符串

……以及来自MDN的内容:

GET方法是浏览器用于请求服务器返回给定资源的方法:“嘿服务器,我想获取这个资源。” … POST方法则略有不同。当浏览器需要服务器根据HTTP请求体中提供的数据来返回相应结果时,会使用此方法与服务器通信:“嘿服务器,看看这些数据,然后给我返回一个合适的结果。”

我在想,是否由于某些原因你的浏览器正在刷新,并且因为是GET请求,所以再次提交了表单。无论如何,这几乎肯定应该使用POST方法。

再次感谢。我会查看这些文章。我没有使用 request.FormValue。我使用的是 ioutil.ReadAll(request.Body)。 这在大多数情况下是有效的,但有时会包含比我想要更多的数据。

func setDataBodyMapForRequest(request *http.Request, p *generalLedgerPage) error {
	var dataBody string

	switch request.Method {
	case "POST":

		body, _ := ioutil.ReadAll(request.Body)
		dataBody = string(body)

		break
	case "GET":
		dataBody = request.URL.RawQuery
		break
	default:
		return errors.New(fmt.Sprintf("bad html request method: ", request.Method))
	}
	p.DataBodyMap = make(map[string]string, 0)
	lines := strings.Split(dataBody, "&")
	for _, line := range lines {
		keyValues := strings.Split(line, "=")
		if len(keyValues) < 2 {
			log.Println("invalid post body: ", dataBody)
			return errors.New(fmt.Sprintf("invalid post body: ", dataBody))
		}
		p.DataBodyMap[keyValues[0]] =  strings.ReplaceAll(keyValues[1], "+", " ")
		log.Println("keyValues: ", keyValues)
		fmt.Println("keyValues: ", keyValues)
	}

	return nil
}

我刚刚因为发帖太多被论坛警告了,但我认为这个讨论对其他人会有用。在完全解决这些问题之前,我会保持安静。

我对路由器/多路复用器不太熟悉。但以下是我的处理程序:

	http.HandleFunc("/generalLedger/login", loginHandler)
	http.HandleFunc("/generalLedger", generalLedgerHandler)
	http.HandleFunc("/generalLedger/segments", segmentsHandler)

然后,来自 http 包:

func (mux *ServeMux) HandleFunc

Image 3-4-22 at 2.58 PM

看起来你关于网站图标的看法是对的。 左侧窗格中有两个 generalLedger 条目,第一个包含 HTML 代码,第二个显示一个问号图标。 我想我可以忽略它。我在请求中找不到任何指示网站图标的地方,尽管有一个 accept 值,但它似乎不包含 .ico 文件类型:

image/webp,image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5

经过一些网络搜索,我添加了以下代码:

	http.HandleFunc("/favicon.ico", faviconHandler)
func faviconHandler(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "./images/favicon.ico")
}

但它没有被调用。请求中的 URL 路径是: /generalLedger 它调用了我的主处理程序。也许它在请求其他东西,但我不知道是什么。

这个问题通常是由于浏览器预加载机制或表单重复提交导致的。以下是几种解决方案:

1. 使用同步令牌(推荐)

在表单中添加隐藏的同步令牌,服务器端验证后立即失效:

// 生成令牌
func generateToken() string {
    return fmt.Sprintf("%d", time.Now().UnixNano())
}

// 处理表单
func handleForm(w http.ResponseWriter, r *http.Request) {
    if r.Method == "POST" {
        // 验证令牌
        token := r.FormValue("_token")
        if token == "" || !isValidToken(token) {
            http.Error(w, "Invalid request", http.StatusBadRequest)
            return
        }
        
        // 处理表单逻辑
        // ...
        
        // 使令牌失效
        invalidateToken(token)
    }
}

2. 使用请求ID跟踪

为每个请求生成唯一ID:

var requestCounter int64

func handleForm(w http.ResponseWriter, r *http.Request) {
    // 从请求头获取请求ID
    reqID := r.Header.Get("X-Request-ID")
    if reqID == "" {
        reqID = fmt.Sprintf("%d", atomic.AddInt64(&requestCounter, 1))
    }
    
    // 使用sync.Map或Redis检查重复请求
    if isDuplicateRequest(reqID) {
        http.Error(w, "Duplicate request", http.StatusConflict)
        return
    }
    
    // 标记请求已处理
    markRequestProcessed(reqID)
    
    // 处理表单逻辑
    // ...
}

3. 客户端按钮禁用(配合服务器端验证)

虽然你提到已尝试过,但这里是一个完整的实现:

// HTML模板中添加JavaScript
const formHTML = `
<!DOCTYPE html>
<html>
<body>
    <form action="/generalLedger" method="post" onsubmit="disableButton()">
        <input type="hidden" name="userID" value="admin">
        <input type="hidden" name="hashedPassword" value="£¢¡">
        <button type="submit" id="submitBtn">Add User</button>
    </form>
    
    <script>
        let isSubmitting = false;
        function disableButton() {
            if (!isSubmitting) {
                isSubmitting = true;
                document.getElementById('submitBtn').disabled = true;
                return true;
            }
            return false;
        }
    </script>
</body>
</html>
`

4. 使用中间件防止重复提交

创建一个中间件来检测短时间内相同来源的请求:

type requestTracker struct {
    mu      sync.RWMutex
    requests map[string]time.Time
}

func (rt *requestTracker) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method == "POST" {
            key := fmt.Sprintf("%s-%s", r.RemoteAddr, r.URL.Path)
            
            rt.mu.RLock()
            lastTime, exists := rt.requests[key]
            rt.mu.RUnlock()
            
            if exists && time.Since(lastTime) < 2*time.Second {
                http.Error(w, "Please wait before submitting again", http.StatusTooManyRequests)
                return
            }
            
            rt.mu.Lock()
            rt.requests[key] = time.Now()
            rt.mu.Unlock()
            
            // 清理旧记录
            go rt.cleanup()
        }
        
        next.ServeHTTP(w, r)
    })
}

func (rt *requestTracker) cleanup() {
    rt.mu.Lock()
    defer rt.mu.Unlock()
    
    cutoff := time.Now().Add(-5 * time.Minute)
    for key, timestamp := range rt.requests {
        if timestamp.Before(cutoff) {
            delete(rt.requests, key)
        }
    }
}

5. 检查HTTP方法

确保使用POST方法而不是GET:

// 修改表单使用POST方法
const formHTML = `
<form action="/generalLedger" method="post">
    <input type="hidden" name="userID" value="admin">
    <input type="hidden" name="hashedPassword" value="£¢¡">
    <button type="submit" name="addUser" value="Add User">Add User</button>
</form>
`

// 服务器端只处理POST请求
func handleForm(w http.ResponseWriter, r *http.Request) {
    if r.Method != "POST" {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    // 处理表单逻辑
    // ...
}

6. 使用数据库事务和唯一约束

在数据库层面防止重复:

func addUserHandler(w http.ResponseWriter, r *http.Request) {
    // 开始事务
    tx, err := db.Begin()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer tx.Rollback()
    
    // 检查是否已存在(根据业务逻辑)
    var count int
    err = tx.QueryRow("SELECT COUNT(*) FROM users WHERE created_at > NOW() - INTERVAL '5 seconds' AND ip_address = $1", 
        r.RemoteAddr).Scan(&count)
    
    if count > 0 {
        http.Error(w, "Recent submission detected", http.StatusConflict)
        return
    }
    
    // 插入用户
    _, err = tx.Exec("INSERT INTO users (...) VALUES (...)")
    if err != nil {
        // 检查是否是重复键错误
        if strings.Contains(err.Error(), "duplicate key") {
            http.Error(w, "User already exists", http.StatusConflict)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // 提交事务
    if err := tx.Commit(); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusCreated)
}

最可靠的解决方案是结合使用同步令牌(方案1)和中间件(方案4),这样可以同时在客户端和服务器端防止重复提交。

回到顶部