img

隔了半个月才发出来,咕咕咕


WebSocket 这玩意用的不少了,从 JavaScript 到 Go,用过各种各样的包来实现这玩意的服务器或客户端。然而,直到现在我对这玩意的原理还是模模糊糊的,趁这个机会,好好学一下 WebSocket 的原理

从 HTTP 到 Websocket

二者关系

虽然 WebSocket 区别于 HTTP,是全双工通信协议。但其并非完全独立于 HTTP,而是基于 HTTP 的升级机制

其核心原理,是通过一次 HTTP 的握手,建立持久连接,然后转变为双向二进制帧传输

升级过程

握手请求

首先,客户端应该先发送 HTTP 握手请求

GET /ws HTTP/1.1
Host: example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==  # 随机 Base64 字符串
Sec-WebSocket-Version: 13

其中,Connection: UpgradeUpgrade: websocket字段,明确要求协议升级

Sec-WebSocekt-Key用于服务端生成生成响应密钥,防止中间代理缓存 WebSocket 帧

最后那个字段用于指定协议版本

响应

成功响应的响应头如下所示

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  # 基于客户端密钥生成

这里唯一值得注意的操作,就是这里的密钥生成规则

服务端将客户端发送的Sec-WebSocket-Key字段内容,拼接在字符串常量后,计算 SHA-1 后,转为 Base64 编码,作为Sec-WebSocket-Accept字段的内容返回

在 Go 语言中,girilla/websocket库自动完成此计算,我们并不需要手动处理这块内容

TCP 连接复用

握手完成后,Websocket 接管刚刚完成的 TCP 连接,后续的数据将以 WebSocket 帧格式传输

Go 语言中,存在net/http包将原始 TCP 连接抽象为http.ResponseWriterhttp.Request,我们用这两个对象向 Websocket 中读写消息

同时,在girilla/websocket库中存在Upgrader.Upgrade()方法,从http.ResponseWriter中提取底层的 TCP 连接net.Conn,并切换为 WebSocket 协议处理器

Go 语言实例

以下提供一个简单的 Go 实现的 WebSocket 服务器:

package main

import (
	"log"
	"net/http"
	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println("WebSocket 升级失败:", err)
		return
	}
	defer conn.Close()

	log.Println("客户端已连接:", conn.RemoteAddr())

	for {
		messageType, message, err := conn.ReadMessage()
		if err != nil {
			log.Println("读取消息失败:", err)
			break
		}

		log.Printf("收到消息: %s", message)

		if err := conn.WriteMessage(messageType, message); err != nil {
			log.Println("发送消息失败:", err)
			break
		}
	}

	log.Println("客户端已断开:", conn.RemoteAddr())
}

func main() {
	http.HandleFunc("/ws", handleWebSocket)

	log.Println("WebSocket 服务器已启动,监听端口 8080...")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal("服务器启动失败:", err)
	}
}

来看代码

依赖

import (
	"log"
	"net/http"
	"github.com/gorilla/websocket"
)
  • log:用于记录日志(虽然但是,Go 的日志系统还是依托)
  • net/http:提供 HTTP 服务器功能
  • github.com/gorilla/websocket:提供 WebSocket 协议的实现

实例化 WebSocket 升级器

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

将升级的关键函数Upgrader通过upgrader实例化,并重写了CheckOrigin字段中的函数

这玩意是用来控制跨域请求的,默认只允许同源请求,即 WebSocket 请求的 Origin 头与服务器主机完全匹配时,连接才会被允许,即

  • 相同的协议(http/https)
  • 相同的主机名
  • 相同的端口号

所以,不管时调试还是生产环境,一般都是要重写的

处理 WebSocket 连接

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println("WebSocket 升级失败:", err)
		return
	}
	defer conn.Close()
	...
}

我们用实例化的upgrader.Upgrade,拿到 HTTP 中的 TCP 读写,然后交给 WebSocket 控制,使其升级为 WebSocket 连接conn

吐槽一下 Go 的 err 机制,太繁琐了

记得使用defer conn.Close确保函数结束时连接关闭,释放资源

消息处理循环

for {
	messageType, message, err := conn.ReadMessage()
	if err != nil {
		log.Println("读取消息失败:", err)
		break
	}
	log.Printf("收到消息: %s", message)
	if err := conn.WriteMessage(messageType, message); err != nil {
		log.Println("发送消息失败:", err)
		break
	}
}

使用connReadMessage方法,从 WebSocket 客户端中读取消息,返回消息类型和内容

这里的WriteMessage方法可以将消息写入 WebSocket 客户端,这里为了演示,直接将接收到的消息原样返回了

启动 HTTP 服务器

func main() {
	http.HandleFunc("/ws", handleWebSocket)
	log.Println("WebSocket 服务器已启动,监听端口 8080...")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal("服务器启动失败:", err)
	}
}

这里使用http.HandleFunc方法,将/ws路径的请求交给handleWebSocket处理,并使用http.ListenAndServe方法,将服务启动在本机 8080 端口