简单的 Go WebSocket 服务器
简单的 Go WebSocket 服务器
在现代 Web 开发中,WebSocket 是实现实时通信(Real-Time Communication)的标准解决方案。与传统的 HTTP 请求-响应模式不同,WebSocket 允许客户端和服务器之间建立全双工的长连接。
本博客通过编写一个简单的回显服务器,认识 Go 语言处理 WebSocket 的核心机制,特别是它如何从 HTTP 协议“升级”而来,以及底层的 Goroutine 模型是如何变化的。
服务端与客户端代码
使用 Go 社区最广泛使用的 WebSocket 库:gorilla/websocket。
这段代码建立了一个 HTTP 服务器,并将 /ws 路径的请求升级为 WebSocket 连接。
1 | package main |
一个原生的 HTML 页面,用于连接上面的 Go 服务器进行测试。
1 |
|
从 HTTP 到 WebSocket 的“夺权”
如果了解 Go 的 HTTP 编程(参考之前的博客:最简单的 Go HTTP 服务器),会发现上面的代码结构非常相似。net/http 库本身并不理解 WebSocket 的帧格式,它只负责 HTTP 协议。
那么,WebSocket 是如何介入的?答案在于 Upgrade(协议升级)。
握手过程
WebSocket 的建立始于一个标准的 HTTP 请求。浏览器发送的请求头中包含特殊的字段:
1 | GET /ws |
这一步依然由 Go 的 standard library net/http 处理。当请求到达 wsHandler 时,我们调用了 upgrader.Upgrade(w, r, nil)。这个函数内部执行了关键的“夺权”操作:
- 校验:检查 HTTP 请求头中的
Upgrade、Connection和Sec-WebSocket-Key是否合法。 - 响应:向客户端写入 HTTP 状态码
101 Switching Protocols,告知浏览器“我们要切换协议了”。 - 劫持 (Hijack):这是最关键的一步。
gorilla/websocket会通过http.ResponseWriter提供的Hijack接口,将底层的 TCP 连接对象 (net.Conn) 直接提取出来。 - 接管:一旦 TCP 连接被提取,
net/http库就失去了对该连接的控制权。此后,这条 TCP 连接的所有读写操作完全由 WebSocket 库接管。
Goroutine 模型的质变
理解 WebSocket 在 Go 中运行机制的关键,在于理解 Goroutine 生命周期的变化。
普通 HTTP 模式(短连接)
net/http 的设计模型是“一请求一处理”:
- 接收请求。
- 启动一个 Goroutine 执行 Handler。
- Handler 写入响应并返回。
- Goroutine 结束,连接处于 Idle 状态或被关闭。
1 | TCP 连接 |
WebSocket 模式(长连接)
在调用 Upgrade 之后,Handler Goroutine 的角色发生了本质改变:它不再是一个处理完就跑的短工,而变成了维护这条长连接的“守护者”。
1 | TCP 连接 |
关键点:Upgrade 之后,Handler 函数是否返回,完全决定了连接的生死。
- 如果
return—— 连接关闭。 - 如果
for {}—— 连接保持。 - 如果
go func()—— 可以将连接对象传递给其他 Goroutine 管理,主 Handler 退出也没关系(因为底层 TCP 连接已经被劫持,不再受net/http的超时控制)。
服务端:发送与接收循环
一旦进入 WebSocket 模式,我们就进入了纯粹的网络编程领域。
1 | for { |
ReadMessage 是一个阻塞调用。如果没有数据到达,当前 Goroutine 会暂停执行(挂起),并不消耗 CPU。当以下情况发生时,它会唤醒并返回:
- 收到完整的 WebSocket 消息:返回数据。
- 收到关闭帧 (Close Frame):客户端主动断开。
- 底层 TCP 断开:网线拔了、WiFi 断了等。
- 读取超时:如果设置了
SetReadDeadline。
在网络编程中,错误 (Error) 是常态,而非异常。
1 | if err := conn.WriteMessage(msgType, msg); err != nil { |
任何读写错误通常都意味着连接不可用(发送缓冲区满、网络中断、对端关闭)。因此,标准的处理范式是:一旦发生 I/O 错误,立即跳出循环,关闭连接。这也是为什么我们在代码开头写了 defer conn.Close() 的原因。
浏览器客户端
为什么用箭头函数?
在 JavaScript 代码中,我们使用了 const log = (s) => ...。
1 | // 传统写法 |
这里主要有两点考虑: 1. 简洁性:对于这种单行的辅助函数,箭头函数写起来更干净。 2. this 绑定机制: * function 定义的函数有自己的 this 上下文(动态作用域)。 * 箭头函数 => 不会创建自己的 this,它会“捕获”外层上下文的 this(词法作用域)。 * 虽然在这个简单的 log 函数中我们没有用到 this,但在编写 React/Vue 组件或类的方法时,箭头函数能避免很多 this 指向错误的坑。
WebSocket 的生命周期事件
我们在 JS 中定义了四个关键的回调函数,它们完整描述了 WebSocket 的一生:
ws.onopen- 含义:连接握手成功,通道已建立。
- 时机:服务器返回
101 Switching Protocols之后。 - 用途:通常在这里更新 UI(如显示“已连接”),或发送第一条初始化消息(如身份 Token)。
ws.onmessage- 含义:收到了服务器发来的数据。
- 参数:
e.data包含了实际的消息内容(字符串或 Blob/ArrayBuffer)。 - 注意:这是异步触发的,服务器随时可能发消息过来。
ws.onerror- 含义:发生了错误(如网络不可达、协议解析错误)。
- 注意:
onerror触发后,通常紧接着会触发onclose。
ws.onclose- 含义:连接已彻底关闭。
- 用途:清理资源、重置 UI、或者发起断线重连。
- 注意:无论是服务器主动踢人、客户端主动关闭、还是网络异常断开,最终都会走到这里。
浏览器里的 URL
1 | const ws = new WebSocket("ws://localhost:8080/ws"); |
浏览器看到 ws://(或加密的 wss://)协议头时,会自动构建一个 HTTP 请求,并附带必要的 Upgrade 头。
注意:不能直接在浏览器地址栏输入 ws://...,地址栏只支持 http/https。WebSocket 必须由 JavaScript 发起。
总结
通过这个简单的示例,我们揭示了 WebSocket 的核心:
- 它始于 HTTP,但在服务器端通过
Upgrade劫持了底层 TCP 连接。 - Go 的处理模型从“请求响应式”转变为“长连接独占 Goroutine”模式。
- 需要自己编写循环来维护消息的读写,并时刻警惕网络错误的发生。
掌握了这些,就拥有了构建聊天室、实时游戏或即时通知系统的基石。