Go WebSocket 全双工通信实战:握手原理与 Echo 服务端实现(build-web-application-with-golang 第 8.2 节)

原创2026-10-04 23:24:401,416 阅读
文章标签:文档教程后端

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 握手原理

握手的建立过程

WebSocket 的协议本身颇为简单:第一次握手(handshake)通过之后,连接便建立成功。整个过程通常被称为“握手”(handshaking)——浏览器发出 WebSocket 连接请求,服务器做出回应,随后连接建立成功。

下面这张图(pt-br/images/8.2.websocket2.png)展示了浏览器开发者工具中抓取到的握手请求与响应报文:

WebSocket 握手请求与响应报文:请求携带 ws:// 地址与 Sec-WebSocket-Key,响应返回 101 Switching Protocols 与 Sec-WebSocket-Accept

请求中的关键信息包括:

  • 请求 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

接下来对这个拼接结果做两步处理:

  1. 先用 SHA-1 安全散列算法计算出二进制散列值;
  2. 再对散列值进行 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)
	}
}

这段代码可以拆解为三层:

  1. 路由注册:websocket.Handler(Echo) 是一个类型转换,它把 func(*websocket.Conn) 类型的 Echo 函数适配为 http.Handler,从而可以被 http.Handle("/", ...) 注册到默认多路复用器上。也就是说,WebSocket 处理器完全可以嵌入标准的 net/http 服务中。
  2. 监听端口:http.ListenAndServe(":1234", nil) 让服务监听本机 1234 端口,与客户端代码中的 ws://127.0.0.1:1234 一一对应;若监听失败则通过 log.Fatal 记录错误并退出。
  3. 回声循环:Echo 函数内部是一个无限 for 循环——先用 websocket.Message.Receive 阻塞接收客户端消息,收到后打印日志并构造 "Received: " + reply 作为应答,再用 websocket.Message.Send 推送回客户端。任一步出错(连接断开、网络异常等)都会打印错误信息并 break 退出循环,结束该连接的处理。

一次完整的消息往返流程

把客户端与服务端串联起来,一次典型交互的完整链路如下:

  1. 浏览器加载页面,window.onload 中执行 new WebSocket(wsuri) 发起握手;
  2. 握手成功,客户端触发 onopen,控制台输出 connected to ws://127.0.0.1:1234;
  3. 用户在输入框输入内容(默认值 Hello, world!),点击 Send Message,sock.send(msg) 将消息经 WebSocket 帧发送至服务端;
  4. 服务端 websocket.Message.Receive 收到消息,控制台打印 Received back from client: Hello, world!;
  5. 服务端构造应答 Received: Hello, world!,通过 websocket.Message.Send 推送回客户端;
  6. 客户端触发 onmessage,控制台输出 message received: Received: Hello, world!。

下图(pt-br/images/8.2.websocket3.png)展示了服务端运行时的实际输出:它先打印了从客户端接收到的 Hello, world!,随后打印了回送给客户端的 Received: Hello, world!,完整印证了上述往返流程。

WebSocket 服务端运行日志:接收客户端消息 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。
登录后查看全文
build-web-application-with-golang