当前位置:首页 > 文章列表 > Golang > Go问答 > Go WebSocket 升级前为什么不能写响应体

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

来源:17golang原创 2026-10-26 08:41:27 0浏览 收藏

我第一次手写 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删除
Go os.File.Truncate 清理覆盖写入后旧内容的处理方案Go os.File.Truncate 清理覆盖写入后旧内容的处理方案
上一篇
Go os.File.Truncate 清理覆盖写入后旧内容的处理方案
Go sync.Pool Put 传入 nil 的丢弃语义与初始化边界
下一篇
Go sync.Pool Put 传入 nil 的丢弃语义与初始化边界
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    701次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    710次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    782次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    744次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    577次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码