Go WebSocket 升级前为什么不能写响应体
我第一次手写 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 前后,会让客户端把它当成不符合协议的数据,后续帧边界也会被污染。

先完成检查,再决定是否写响应体
完整工作流的第一个检查点是:还没有接管连接前,所有拒绝分支都可以使用 http.Error。检查方法、连接升级令牌、版本和 Key;只要有一项不满足,就写清楚错误并立即返回。不要在同一个请求里先写错误体,再继续走接受分支。
| 阶段 | 连接归属 | 允许写什么 | 写完后的动作 |
|---|---|---|---|
| 请求校验失败 | net/http | 400/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。

写入 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 帧生命周期。
Go os.File.Truncate 清理覆盖写入后旧内容的处理方案
- 上一篇
- Go os.File.Truncate 清理覆盖写入后旧内容的处理方案
- 下一篇
- Go sync.Pool Put 传入 nil 的丢弃语义与初始化边界
-
- Golang · Go问答 | 26分钟前 |
- Go reflect.Value.SetMapIndex 删除 map 元素的零值参数与类型边界
- 396浏览 收藏
-
- Golang · Go问答 | 43分钟前 |
- Go sync.Pool Put 传入 nil 的丢弃语义与初始化边界
- 189浏览 收藏
-
- Golang · Go问答 | 1小时前 |
- Go os.File.Truncate 清理覆盖写入后旧内容的处理方案
- 314浏览 收藏
-
- Golang · Go问答 | 1小时前 | go · 模板 · html/template · Go ParseFiles html/template 命名模板 lookup Templates
- Go html/template Templates 怎样列出已经解析的命名模板
- 122浏览 收藏
-
- Golang · Go问答 | 1小时前 | go ·
- Go netip.Addr.Zone 怎样读取 IPv6 地址的接口标识
- 363浏览 收藏
-
- Golang · Go问答 | 8小时前 |
- Go strconv.Unquote 为什么解析带反斜杠的配置值失败
- 203浏览 收藏
-
- Golang · Go问答 | 8小时前 | 构建 · go · Go 动态库 交叉编译 CGO_ENABLED
- Go 交叉编译后二进制为什么依赖动态库
- 397浏览 收藏
-
- Golang · Go问答 | 8小时前 |
- Go t.Parallel 子测试为什么读到被修改的共享变量
- 459浏览 收藏
-
- Golang · Go问答 | 8小时前 |
- Go race 检测为什么没有报告某次数据竞争
- 488浏览 收藏
-
- Golang · Go问答 | 8小时前 |
- Go slog JSONHandler 为什么字段顺序不能依赖
- 124浏览 收藏
-
- Golang · Go问答 | 8小时前 |
- Go pprof 采样为什么看不到短生命周期函数
- 447浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 701次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 710次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 782次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 744次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 577次使用
-
- 用Nginx反向代理部署go写的网站。
- 2023-01-17 502浏览
-
- GoLand调式动态执行代码
- 2023-01-13 502浏览
-
- Go JSONJSON null 解码到指针后状态不符合预期怎么办
- 2026-10-18 501浏览
-
- Go crypto/rand.Text 的长度为什么不是固定字符数
- 2026-10-04 501浏览
-
- Go strings.ToValidUTF8 清洗日志内容的边界
- 2026-10-03 501浏览

