Go WebSocket 全双工通信实战:握手原理与 Echo 服务端实现(build-web-application-with-golang 第 8.2 节)
Go WebSocket 全双工通信实战:握手原理与 Echo 服务端实现(build-web-application-with-golang 第 8.2 节)
本文围绕开源 Go 电子书 build-web-application-with-golang 第 8 章“Web services”中的第 8.2 节(pt-br/08.2.md)展开,系统讲解 WebSocket 作为 HTML5 全双工通信协议的核心原理:从“轮询”到长连接的演进、ws:///wss:// 地址与握手流程、Sec-WebSocket-Key 到 Sec-WebSocket-Accept 的完整推导过程,并给出可直接运行的前端 JavaScript 客户端与 Go 服务端 Echo 完整示例。读完本文,你将掌握 WebSocket 握手协议的每一步细节,并能在 Go 中借助官方维护的 websocket 包快速实现一个全双工消息收发服务。
为什么需要 WebSocket:从轮询到全双工长连接
WebSocket 是 HTML5 的重要特性,它实现了基于浏览器的远程 socket,使浏览器和服务器可以进行全双工(full-duplex)通信。主流浏览器如 Firefox、Google Chrome 和 Safari 均对这一特性提供了支持。
在 WebSocket 出现之前,即时通信类服务大多采用“轮询”(polling)方案:浏览器在特定的时间间隔内不断向服务器发出 HTTP Request,服务器收到请求后把最新数据返回给浏览器刷新。轮询的致命缺陷在于:客户端必须持续发送大量请求,每次请求都携带完整的 HTTP 头,会占用大量带宽,而且实时性受轮询间隔的制约。
WebSocket 采用了一些特殊的报头,使得浏览器和服务器只需要完成一次握手(handshake),就能在两者之间建立一条持续活动的连接通道。此后你可以像操作常规 TCP Socket 一样,使用 JavaScript 向连接写入数据或从中接收数据。相比传统 HTTP,它主要有以下优势:
- 一个 Web 客户端只建立一个 TCP 连接,避免反复建连的开销;
- WebSocket 服务端可以主动向 Web 客户端推送(push)数据,突破 HTTP 请求-响应单向模型的限制;
- 拥有更轻量级的报头,显著减少数据传送量。
WebSocket 的 URL 以 ws:// 开头,加密版本以 wss://(基于 SSL)开头。下图(pt-br/images/8.2.websocket.png)展示了 WebSocket 的通信过程:一个带有特定报头的 HTTP 握手被发送到服务器端,连接建立后,客户端与服务端即可通过 JavaScript 相互收发数据,并通过事件句柄异步地接收数据。
WebSocket 握手原理
握手的建立过程
WebSocket 的协议本身颇为简单:第一次握手(handshake)通过之后,连接便建立成功。整个过程通常被称为“握手”(handshaking)——浏览器发出 WebSocket 连接请求,服务器做出回应,随后连接建立成功。
下面这张图(pt-br/images/8.2.websocket2.png)展示了浏览器开发者工具中抓取到的握手请求与响应报文:
请求中的关键信息包括:
- 请求 URL 使用
ws://协议前缀; - 请求头携带
Sec-WebSocket-Key,这是客户端随机生成的、经过 base64 编码的字符串; - 服务器返回 HTTP 状态码
101 Switching Protocols,表示协议切换成功; - 响应头携带
Sec-WebSocket-Accept,是服务器对客户端握手请求的验证应答。
Sec-WebSocket-Key 与 Sec-WebSocket-Accept 的推导
请求中的 Sec-WebSocket-Key 是随机生成的,一眼即可看出它是经过 base64 编码的数据。服务器端接收到这个请求后,需要把这个字符串连接上一个固定的字符串(WebSocket 协议规范定义的 GUID):
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
假设客户端的 Key 为 f7cb4ezEAl6C3wRaU6JORA==,则拼接后的字符串为:
f7cb4ezEAl6C3wRaU6JORA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
接下来对这个拼接结果做两步处理:
- 先用 SHA-1 安全散列算法计算出二进制散列值;
- 再对散列值进行 base64 编码。
最终得到的字符串即为握手后的应答值:
rE91AJhfC+6JdVcVXOGJEADEJdQ=
服务器将这一值作为响应头 Sec-WebSocket-Accept 的值反馈给客户端。客户端在本地用同样的算法(base64(sha1(key + GUID)))验算,即可确认服务器确实完成了合法握手,从而建立连接。
握手后的数据传输格式
握手成功、连接建立之后,后续的通讯数据都以 \x00 开头、以 \xFF 结尾。对客户端而言这一层是透明的——WebSocket 组件会自动将原始数据的“掐头去尾”,开发者拿到的就是纯净的业务数据。这种基于帧(frame)的封装方式,配合轻量级报头,正是 WebSocket 相对 HTTP 长轮询更省带宽的原因之一。
Go 实现 WebSocket
安装官方维护的 websocket 包
Go 语言的标准包(标准库)中没有提供对 WebSocket 的支持,但在官方维护的 go.net 子包中有相应实现。可以通过如下命令获取该包:
go get golang.org/x/net/websocket
需要说明的是:本仓库的葡萄牙语译本 pt-br/08.2.md 正文中给出的安装命令为 go get code.google.com/p/go.net/websocket(旧版导入路径),而中文原版 zh/08.2.md 与服务端代码中实际使用的导入路径为 golang.org/x/net/websocket。两者指向同一官方维护的包,golang.org/x/net/websocket 是当前更通用的规范路径,下文代码即采用该路径。
WebSocket 分为客户端和服务端两侧。接下来实现一个最简单的回声(Echo)示例:用户在客户端输入信息,客户端通过 WebSocket 将信息发送给服务器端;服务器端收到信息后主动将信息推送回客户端,客户端输出收到的内容。
客户端:HTML + JavaScript
客户端使用浏览器原生的 WebSocket 对象,无需引入任何第三方库:
<html>
<head></head>
<body>
<script type="text/javascript">
var sock = null;
var wsuri = "ws://127.0.0.1:1234";
window.onload = function() {
console.log("onload");
sock = new WebSocket(wsuri);
sock.onopen = function() {
console.log("connected to " + wsuri);
}
sock.onclose = function(e) {
console.log("connection closed (" + e.code + ")");
}
sock.onmessage = function(e) {
console.log("message received: " + e.data);
}
};
function send() {
var msg = document.getElementById('message').value;
sock.send(msg);
};
</script>
<h1>WebSocket Echo Test</h1>
<form>
<p>
Message: <input id="message" type="text" value="Hello, world!">
</p>
</form>
<button onclick="send();">Send Message</button>
</body>
</html>
可以看到,客户端 JavaScript 很容易地通过 new WebSocket(wsuri) 建立了与服务器的连接对象 sock。当握手成功后,会触发 WebSocket 对象的 onopen 事件,告知客户端连接已经成功建立。客户端一共绑定了四个事件,分别对应连接生命周期中的不同阶段:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
onopen |
建立连接(握手完成)后触发 | 确认连接可用,可在此后开始发送数据 |
onmessage |
收到消息后触发 | 通过 e.data 读取服务端推送的数据 |
onerror |
发生错误时触发 | 处理连接异常,进行日志记录或重连 |
onclose |
关闭连接时触发 | 通过 e.code 获取关闭码,执行清理逻辑 |
用户点击“Send Message”按钮时,send() 函数读取输入框 message 的值并调用 sock.send(msg) 将消息发送给服务端。
服务端:Go Echo 处理器
服务端实现如下:
package main
import (
"golang.org/x/net/websocket"
"fmt"
"log"
"net/http"
)
func Echo(ws *websocket.Conn) {
var err error
for {
var reply string
if err = websocket.Message.Receive(ws, &reply); err != nil {
fmt.Println("Can't receive")
break
}
fmt.Println("Received back from client: " + reply)
msg := "Received: " + reply
fmt.Println("Sending to client: " + msg)
if err = websocket.Message.Send(ws, msg); err != nil {
fmt.Println("Can't send")
break
}
}
}
func main() {
http.Handle("/", websocket.Handler(Echo))
if err := http.ListenAndServe(":1234", nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
这段代码可以拆解为三层:
- 路由注册:
websocket.Handler(Echo)是一个类型转换,它把func(*websocket.Conn)类型的Echo函数适配为http.Handler,从而可以被http.Handle("/", ...)注册到默认多路复用器上。也就是说,WebSocket 处理器完全可以嵌入标准的net/http服务中。 - 监听端口:
http.ListenAndServe(":1234", nil)让服务监听本机 1234 端口,与客户端代码中的ws://127.0.0.1:1234一一对应;若监听失败则通过log.Fatal记录错误并退出。 - 回声循环:
Echo函数内部是一个无限for循环——先用websocket.Message.Receive阻塞接收客户端消息,收到后打印日志并构造"Received: " + reply作为应答,再用websocket.Message.Send推送回客户端。任一步出错(连接断开、网络异常等)都会打印错误信息并break退出循环,结束该连接的处理。
一次完整的消息往返流程
把客户端与服务端串联起来,一次典型交互的完整链路如下:
- 浏览器加载页面,
window.onload中执行new WebSocket(wsuri)发起握手; - 握手成功,客户端触发
onopen,控制台输出connected to ws://127.0.0.1:1234; - 用户在输入框输入内容(默认值
Hello, world!),点击 Send Message,sock.send(msg)将消息经 WebSocket 帧发送至服务端; - 服务端
websocket.Message.Receive收到消息,控制台打印Received back from client: Hello, world!; - 服务端构造应答
Received: Hello, world!,通过websocket.Message.Send推送回客户端; - 客户端触发
onmessage,控制台输出message received: Received: Hello, world!。
下图(pt-br/images/8.2.websocket3.png)展示了服务端运行时的实际输出:它先打印了从客户端接收到的 Hello, world!,随后打印了回送给客户端的 Received: Hello, world!,完整印证了上述往返流程。
运行与验证要点
- 先启动服务端:执行
go run运行上述 Go 程序,保持 1234 端口监听; - 再打开客户端:直接用浏览器打开保存好的 HTML 页面,并打开开发者工具(Console 面板)观察
console.log输出; - 观察日志对应关系:服务端终端的
Received back from client与浏览器 Console 的message received内容应相互呼应,即可确认全双工通道工作正常; - 关闭行为:关闭浏览器页面或刷新时,客户端会触发
onclose并打印connection closed,服务端对应的Receive调用返回错误并退出Echo循环。
小结与延伸阅读
通过上面的例子可以看到,客户端和服务端实现 WebSocket 都相当方便:客户端只需使用浏览器内置的 WebSocket 对象绑定四个事件;服务端则借助官方维护的 golang.org/x/net/websocket 包,把处理函数转换为 http.Handler 嵌入标准 net/http 服务,再通过 websocket.Message.Send / websocket.Message.Receive 完成全双工收发。随着 HTML5 的发展,WebSocket 在实时 Web 开发(即时聊天、消息推送、网页游戏等)中的地位会越来越重要,理解其握手原理与消息帧结构,是进一步学习 REST 服务实现 之前值得储备的基础知识。
本章内容在全书中的位置:
- 章节导览:第 8 章 Web services 概述了 socket、WebSocket、REST 与 RPC 四大主题的定位;
- 前置基础:Socket 编程 介绍了 TCP/UDP socket 与 Go
net包的基本用法,WebSocket 的底层连接正是建立在这一套 socket 抽象之上; - 目录入口:pt-br/preface.md;
- 对应中文原版章节:zh/08.2.md。


