登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go WebSocket 升级前为什么不能写响应体

来源:17golang原创

时间:2026-10-26 08:41:27 197浏览 收藏

我第一次手写 WebSocket 握手时,最容易犯的错不是算错 Sec-WebSocket-Accept,而是在判断失败时顺手给 ResponseWriter 写了一段提示,随后又想把状态码改成 101 Switching Protocols。结果客户端看到的仍然是普通 HTTP 响应,升级自然失败。

核心原因很简单:WebSocket 升级前,连接还属于 HTTP。接受升级时,服务器要发送的是一组 101 响应头,响应体不能插入普通业务文本;完成切换后,才开始发送 WebSocket 帧。官方接口说明可参考 https://pkg.go.dev/net/http。

要点速览
  • 拒绝握手可以写 400、403 等普通 HTTP 响应体,但写完就要返回。
  • 接受握手不能先调用 ResponseWriter.Write 写业务 body,否则可能隐式提交 200。
  • Hijack 后由应用接管连接,后续不要继续使用原来的 ResponseWriter。

先把升级前的目标说清楚

WebSocket 的 opening handshake 仍然是 HTTP 请求。客户端带着 Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key 等字段来请求切换。服务器接受时,返回 101 Switching Protocols,并给出 Upgrade、Connection 和 Sec-WebSocket-Accept。

这里有两个互斥结果:请求不符合条件时,它还是普通 HTTP 请求,可以返回带正文的错误说明;请求符合条件时,响应的任务是完成协议切换,不是返回一段欢迎词。把“握手说明文字”塞进 101 前后,会让客户端把它当成不符合协议的数据,后续帧边界也会被污染。

Go WebSocket 升级请求经过检查后分成 101 无响应体和普通 HTTP 拒绝响应的协议边界说明图
图1:WebSocket 握手与普通 HTTP 拒绝响应的边界说明图,不是运行截图。

先完成检查,再决定是否写响应体

完整工作流的第一个检查点是:还没有接管连接前,所有拒绝分支都可以使用 http.Error。检查方法、连接升级令牌、版本和 Key;只要有一项不满足,就写清楚错误并立即返回。不要在同一个请求里先写错误体,再继续走接受分支。

阶段连接归属允许写什么写完后的动作
请求校验失败net/http400/403 等普通 HTTP 响应体立即 return
准备接受升级net/http暂不写 body,准备 101 头调用 Hijack
握手已发送应用WebSocket 帧,不是 HTTP body只操作 conn
// headerToken 判断逗号分隔的 HTTP 头里是否包含目标令牌。
func headerToken(value, want string) bool {
    for _, part := range strings.Split(value, ",") {
        if strings.EqualFold(strings.TrimSpace(part), want) {
            return true
        }
    }
    return false
}

// validUpgrade 只做握手前的普通 HTTP 检查,不接管连接。
func validUpgrade(r *http.Request) (string, bool) {
    // WebSocket 客户端通常用 GET,并且必须明确声明升级意图。
    if r.Method != http.MethodGet ||
        !headerToken(r.Header.Get("Connection"), "Upgrade") ||
        !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
        return "", false
    }

    // 版本 13 是常见的 RFC 6455 握手版本,Key 需要是 16 字节随机值的 Base64。
    if r.Header.Get("Sec-WebSocket-Version") != "13" {
        return "", false
    }
    key := strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key"))
    nonce, err := base64.StdEncoding.DecodeString(key)
    if err != nil || len(nonce) != 16 {
        return "", false
    }
    return key, true
}

用 Hijacker 完成连接所有权交接

检查通过后,才进入第二个阶段。http.Hijacker 会把底层连接和缓冲读写器交给应用。这个接口主要对应 HTTP/1.x;如果当前响应写入器不支持它,例如 HTTP/2 场景,应该在接管前返回错误,而不是假设一定能拿到裸连接。

我更愿意把这里理解成一道所有权边界:Hijack 之前由 net/http 管理状态码和响应;Hijack 成功后,应用负责关闭连接、刷新缓冲区以及后续帧协议。此时继续调用原来的 ResponseWriter.Write,得到的不是另一个 HTTP body,而是 http.ErrHijacked。

Go ResponseWriter 通过 http.Hijacker 交接到 net.Conn 后写入 101 和 WebSocket 帧的所有权说明图
图2:`ResponseWriter` 到 `net.Conn` 的连接所有权交接说明图,不是运行截图。

写入 101 并 Flush,再进入帧通信

下面的示例刻意把顺序写完整:先校验,校验失败就返回普通错误体;校验成功后调用 Hijack,用返回的 bufio.ReadWriter 写 101 响应头并刷新;之后只把连接当作 WebSocket 连接使用。示例只展示握手边界,不实现完整的帧解析。

package main

import (
    "bufio"
    "crypto/sha1"
    "encoding/base64"
    "fmt"
    "log"
    "net"
    "net/http"
    "strings"
)

const websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

// websocketAccept 按 RFC 6455 计算服务器确认值。
func websocketAccept(key string) string {
    // Key 与固定 GUID 拼接后做 SHA-1,再编码成 Base64。
    sum := sha1.Sum([]byte(key + websocketGUID))
    return base64.StdEncoding.EncodeToString(sum[:])
}

// serveWebSocket 展示从普通 HTTP 到 WebSocket 的完整交接顺序。
func serveWebSocket(w http.ResponseWriter, r *http.Request) {
    key, ok := validUpgrade(r)
    if !ok {
        // 这里仍由 net/http 管理,所以可以返回普通错误正文;写完必须结束分支。
        http.Error(w, "invalid websocket handshake", http.StatusBadRequest)
        return
    }

    h, ok := w.(http.Hijacker)
    if !ok {
        // HTTP/2 等场景可能没有 Hijacker,接管前仍可返回普通 HTTP 错误。
        http.Error(w, "hijacking is not supported", http.StatusHTTPVersionNotSupported)
        return
    }
    conn, rw, err := h.Hijack()
    if err != nil {
        log.Printf("hijack failed: %v", err)
        return
    }
    defer conn.Close() // 应用已接管连接,因此由应用负责释放资源。

    // 接管后写的是 101 握手头,不是业务响应体;刷新后协议才正式切换。
    if err := writeSwitchingProtocols(rw, websocketAccept(key)); err != nil {
        log.Printf("write handshake failed: %v", err)
        return
    }

    // 后续应在 conn 上读写 WebSocket 帧,不再调用原来的 ResponseWriter。
    _ = conn
}

// writeSwitchingProtocols 只写握手头,并把缓冲内容送到客户端。
func writeSwitchingProtocols(rw *bufio.ReadWriter, accept string) error {
    // 101 响应结束后直接进入 WebSocket 帧阶段,不附加普通 HTTP body。
    if _, err := fmt.Fprintf(rw,
        "HTTP/1.1 101 Switching Protocols\\r\\n"+
            "Upgrade: websocket\\r\\n"+
            "Connection: Upgrade\\r\\n"+
            "Sec-WebSocket-Accept: %s\\r\\n\\r\\n", accept); err != nil {
        return err
    }
    return rw.Flush()
}

var _ net.Conn

如果把 w.Write([]byte("welcome")) 放到 Hijack 前,第一次写入可能已经提交了 200;即使随后再调用 WriteHeader(101),也无法把已发出的状态码改回来。如果把它放到 Hijack 后,写入对象又错了,应该改用连接上的 WebSocket 帧编码器。

常见误区与速查表

  • 误区一:把 101 当成普通 JSON 接口。 101 是切换协议的控制响应,欢迎词、JSON 或 HTML 都不属于握手正文。
  • 误区二:先写 body 再改状态。 ResponseWriter.Write 可能隐式提交成功状态,状态提交后不能回滚。
  • 误区三:Hijack 后继续用 w。 连接所有权已交接,原写入器的写操作会返回 ErrHijacked。
  • 误区四:忽略 HTTP 版本。 代码依赖 http.Hijacker 时,要确认运行环境确实是支持该接口的 HTTP/1.x 连接。
看到的现象优先检查正确动作
客户端收到 200是否提前调用了 Write 或 http.Error把接受分支的 body 写入删掉
收到 101 但立刻断开101 头是否完整、是否 Flush检查 Upgrade、Connection、Accept
写入返回 ErrHijacked是否在 Hijack 后还使用 w改用 conn 和帧编码逻辑

常见问题

拒绝 WebSocket 握手时可以返回响应体吗?

可以。此时请求没有升级成功,返回 400、403 等普通 HTTP 响应并附带错误体是合理的;关键是写完后立即返回,不要继续执行 101 分支。

调用 WriteHeader(101) 后还能写响应体吗?

不要把它当作普通 HTTP body 使用。WebSocket 客户端期待握手结束后读取帧,额外正文会破坏协议边界;握手头刷新后应进入帧通信。

为什么 Hijack 后不能继续使用 ResponseWriter?

因为底层连接已经交给应用。Go 的 HTTP 服务端会把后续对原写入器的写操作视为发生在已接管连接上,通常返回 ErrHijacked。

排查这类问题时只记住一条顺序即可:先在 HTTP 层做完所有拒绝判断;接受时不要写普通 body;再 Hijack、写 101、Flush;最后才进入 WebSocket 帧生命周期。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>