【Go进阶】手写 Go websocket 库(一)|WebSocket 通信协议
前言
这里是白泽,我将利用一个系列,为你分享如何基于 websocket 协议的 rfc 文档,编写一个库的过程。并从0开始写一遍 gorilla/websocket 这个库,从中你可以学习到 websocket 库中高质量、高性能的写法(多协程、缓冲池使用)。
仓库地址:https://github.com/gorilla/websocket,数量:22.8k
项目体量不大、核心代码5k,虽然难度较高,但在引导下也可以完成。

B站:白泽talk
公众号:白泽talk
开源 Golang 学习仓库:https://github.com/BaiZe1998/go-learning
开源短视频应用 DouTok:https://github.com/cloudzenith/DouTok
WebSocket 协议
这是第一课,本文将详细介绍 WebSocket 协议的帧结构、握手过程、数据传输机制及其在实际应用中的优势。并以 Golang 的 websocket 开源库作为案例进行前瞻,探究协议是如何用 Golang 代码实现的,以及如何使用这个库,进行 websocket 通信。
一、WebSocket 协议概述
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。与 HTTP 相比,WebSocket 无需像 HTTP 那样每次通信都需要建立新的连接,从而大大减少了延迟和资源消耗。它使得客户端和服务器之间的数据交换变得更加高效和实时。
二、WebSocket 帧结构
作为 Go 语言 数最多的 websocket 库,本质上是通过 Golang,实现了 websocket rfc 文档定义的消息格式、以及交互过程。
rfc:https://datatracker.ietf.org/doc/html/rfc6455#section-5.2

这个库的核心实现包含在几个文件中(client.go/serve.go/conn.go)。其中用于定义 websocket 链接的 conn.go 文件中,定义了一些常量,用于表示各个标志位,乍一眼你可能看不明白。
const (
// Frame header byte 0 bits from Section 5.2 of RFC 6455
finalBit = 1 << 7
rsv1Bit = 1 << 6
rsv2Bit = 1 << 5
rsv3Bit = 1 << 4
// Frame header byte 1 bits from Section 5.2 of RFC 6455
maskBit = 1 << 7
maxFrameHeaderSize = 2 + 8 + 4 // Fixed header + length + mask
maxControlFramePayloadSize = 125
writeWait = time.Second
defaultReadBufferSize = 4096
defaultWriteBufferSize = 4096
continuationFrame = 0
noFrame = -1
)
WebSocket 协议通过帧(Frame)来传输数据。每个帧都包含一系列固定的字段,用于标识帧的类型、长度、掩码以及实际的数据内容。

1. FIN
FIN 字段是一个 1 位的标志位,用于指示当前帧是否为消息中的最后一个片段。如果消息仅由一个片段组成,该位也应被设置为 1。
2. RSV1, RSV2, RSV3
RSV1、RSV2 和 RSV3 是三个 1 位的保留位,它们必须为 0,除非在 WebSocket 握手阶段已经协商了具有特定含义的扩展。如果使用了扩展,并且这些位被赋予了特定的意义,那么它们将用于指示该帧是否遵循了这些扩展的特定规则。
3. Opcode
Opcode 字段是一个 4 位的操作码,用于定义有效载荷数据的含义。不同的操作码代表不同类型的帧:
- %x0:表示连续帧(Continuation Frame),用于将消息分割成多个片段。
- %x1:表示文本帧(Text Frame),包含 UTF-8 编码的文本数据。
- %x2:表示二进制帧(Binary Frame),包含二进制数据。
- %x8:表示连接关闭帧(Connection Close Frame),用于关闭连接。
- %x9:表示 Ping 帧,用于连接检测。
- %xA:表示 Pong 帧,作为对 Ping 帧的响应。
4. Mask
Mask 字段是一个 1 位的标志位,用于指示是否对有效载荷数据进行了掩码处理。在客户端发送到服务器的帧中,该位必须设置为 1,并附带一个掩码键(Masking-key)。服务器发送到客户端的帧则不应被掩码,因此该位应为 0。
5. Payload length
Payload length 字段用于表示有效载荷数据的总长度。它可以是 7 位、7+16 位或 7+64 位,具体取决于数据的长度:
- 如果长度小于或等于 125 字节,则使用 7 位表示。
- 如果长度在 126 到 65,535 字节之间,则前 7 位设置为 126,并使用随后的 16 位来表示长度。
- 如果长度超过 65,535 字节,则前 7 位设置为 127,并使用随后的 64 位来表示长度。
6. Masking-key
如果 Mask 位为 1,则 Masking-key 字段存在,并包含 32 位的掩码。该掩码用于对有效载荷数据进行掩码处理,以确保数据的安全性。
7. Payload data
Payload data 字段包含实际要传输的数据,它由扩展数据(Extension data)和应用数据(Application data)组成。扩展数据是可选的,其长度和格式取决于在 WebSocket 握手阶段协商的扩展。应用数据则包含了实际要传输的数据,如文本消息、二进制数据等。
思考一下:
提问:websocket/conn.go:31 中,maxFrameHeaderSize = 2 + 8 + 4 的含义是?
回答:2字节的固定长度 + 8字节的最大负载长度(可变) + 4字节掩码(可变)。
三、WebSocket 握手过程
WebSocket 的握手过程是基于 HTTP 的。当客户端希望与服务器建立 WebSocket 连接时,它会首先发送一个 HTTP 请求到服务器。这个请求包含了几个关键的头部字段,用于指示客户端希望升级到 WebSocket 协议。
func main() {
flag.Parse()
hub := newHub()
go hub.run()
http.HandleFunc("/", serveHome) // HTTP 请求
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r) // HTTP 协议升级成 ws 协议
})
err := http.ListenAndServe(*addr, nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
// serveWs handles websocket requests from the peer.
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) // 劫持 conn,并升级为 ws 协议的函数,这部分是 websocket 库实现的
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client.hub.register <- client
// Allow collection of memory referenced by the caller by doing all work in
// new goroutines.
go client.writePump()
go client.readPump()
}
服务器在接收到这个请求后,会验证请求的有效性,并返回一个包含 Upgrade: websocket 和 Connection: Upgrade 头部的 HTTP 响应。这个响应表示服务器同意升级到 WebSocket 协议,并且连接已经成功建立。
四、WebSocket 数据传输机制
一旦 WebSocket 连接建立成功,客户端和服务器就可以通过帧来传输数据了。每个帧都包含上述的帧结构,用于标识帧的类型、长度、掩码以及实际的数据内容。
在数据传输过程中,客户端和服务器可以自由地发送和接收数据帧,实现全双工通信。这种通信方式使得实时应用能够高效地处理数据交换,减少延迟和资源消耗。
五、一个聊天室的通信 demo
服务端通过维护一个 hub 管理所有客户端活跃的链接。
// 比如服务端通过维护一个 hub 管理所有客户端活跃的链接。
type Hub struct {
// Registered clients.
clients map[*Client]bool
// Inbound messages from the clients.
broadcast chan []byte
// Register requests from the clients.
register chan *Client
// Unregister requests from clients.
unregister chan *Client
}
服务端启动协程监听客户端实例的注册,以及接收来自客户端的消息,并且广播给所有的客户端。
func (h *Hub) run() {
for {
select {
case client := <-h.register: // 升级成 ws 协议之后,注册 client 到 hub
h.clients[client] = true
case client := <-h.unregister: // 连接断开则注销 client 实例,释放资源
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case message := <-h.broadcast: // 一个阻塞的 channal,接收来自 client 的需要广播的消息
for client := range h.clients { // 发送给所有的 client 实例
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
客户端的结构。
// Client is a middleman between the websocket connection and the hub.
type Client struct {
hub *Hub
// The websocket connection.
conn *websocket.Conn
// Buffered channel of outbound messages.
send chan []byte
}
从 http 协议升级成 ws 协议之后,将 client 注册到 hub 中。
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r) // HTTP 协议升级成 ws 协议
})
// serveWs handles websocket requests from the peer.
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) // 升级为 ws 协议,劫持 conn,后续进行全双工通信
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client.hub.register <- client
// Allow collection of memory referenced by the caller by doing all work in
// new goroutines.
go client.writePump() // 开启 client 向 web 端回写消息的协程
go client.readPump() // 开启 client 监听来自 web 端的消息,并发送给 hub,进行广播
}
注册在 hub 中的客户端实例,不断监听读取来自 web 端的消息,并转发给 hub。
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := c.conn.ReadMessage() // 读取来自 web 端的消息
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("error: %v", err)
}
break
}
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) // 格式化
c.hub.broadcast <- message
}
}
注册在 hub 中的客户端实例,同时监听发送给自己的消息,并且写入 conn 句柄,将消息再次转发回 web 端的实例。
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send: // 是否有需要回写给 web 端的消息
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The hub closed the channel.
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message) // 发送文本消息
// Add queued chat messages to the current websocket message.
n := len(c.send)
for i := 0; i < n; i++ {
w.Write(newline)
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C: // 周期发送 ping 消息
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
六、WebSocket 协议在实际应用中的优势
- 实时性:WebSocket 协议允许客户端和服务器之间进行实时的双向通信,使得应用能够更快地响应用户操作。
- 高效性:与 HTTP 相比,WebSocket 无需每次通信都建立新的连接,从而大大减少了延迟和资源消耗。
- 灵活性:WebSocket 协议支持文本和二进制数据的传输,使得应用能够处理多种类型的数据。
- 安全性:WebSocket 协议提供了对数据的掩码处理,以确保数据在传输过程中的安全性。
小结
未完待续,期待你的关注。
【Go进阶】手写 Go websocket 库(一)|WebSocket 通信协议的更多相关文章
- (手写识别) Zinnia库及其实现方法研究
Zinnia库及其实现方法研究 (转) zinnia是一个开源的手写识别库.采用C++实现.具有手写识别,学习以及文字模型数据制作转换等功能. 项目地址 [http://zinnia.sourcefo ...
- 在opencv3中实现机器学习算法之:利用最近邻算法(knn)实现手写数字分类
手写数字digits分类,这可是深度学习算法的入门练习.而且还有专门的手写数字MINIST库.opencv提供了一张手写数字图片给我们,先来看看 这是一张密密麻麻的手写数字图:图片大小为1000*20 ...
- 前端进阶之认识与手写compose方法
目录 前言:为什么要学习这个方法 compose简介 compose的实现 最容易理解的实现方式 手写javascript中reduce方法 redux中compose的实现 参考文章 最后 前言:为 ...
- 手写一个虚拟DOM库,彻底让你理解diff算法
所谓虚拟DOM就是用js对象来描述真实DOM,它相对于原生DOM更加轻量,因为真正的DOM对象附带有非常多的属性,另外配合虚拟DOM的diff算法,能以最少的操作来更新DOM,除此之外,也能让Vue和 ...
- 如何手写一个js工具库?同时发布到npm上
自从工作以来,写项目的时候经常需要手写一些方法和引入一些js库 JS基础又十分重要,于是就萌生出自己创建一个JS工具库并发布到npm上的想法 于是就创建了一个名为learnjts的项目,在空余时间也写 ...
- 利用html 5 websocket做个山寨版web聊天室(手写C#服务器)
在之前的博客中提到过看到html5 的websocket后很感兴趣,终于可以摆脱长轮询(websocket之前的实现方式可以看看Developer Works上的一篇文章,有简单提到,同时也说了web ...
- UI进阶之--网易彩票手写plist文件,动态创建控制器与tableViewcell
点击右上角设置按钮 点击按钮后发生的事件:1. 控制器的跳转,进入新的控制器.view, 2. 跳转的时候对将要跳转的目标控制的子控件进行了布局.---通过手写plist文件的方式加载 为按钮注册单击 ...
- 手写编程语言-如何为 GScript 编写标准库
版本更新 最近 GScript 更新了 v0.0.11 版本,重点更新了: Docker 运行环境 新增了 byte 原始类型 新增了一些字符串标准库 Strings/StringBuilder 数组 ...
- UI到底应该用xib/storyboard完成,还是用手写代码来完成?
UI到底应该用xib/storyboard完成,还是用手写代码来完成? 文章来源:http://blog.csdn.net/libaineu2004/article/details/45488665 ...
- 手写spring
体系结构 Spring 有可能成为所有企业应用程序的一站式服务点,然而,Spring 是模块化的,允许你挑选和选择适用于你的模块,不必要把剩余部分也引入.下面的部分对在 Spring 框架中所有可用的 ...
随机推荐
- 1.1 HELLO 窗口
跟着教程,开始第一步创建窗口吧!这一节不涉及太多知识. 本节会出现一些名词,我们现在只需要知道它们大概是干什么的就行. ● GLFW:一个专门针对OpenGL的C语言库,通过它提供的接口,我们就可以渲 ...
- 5.6 函数y=Asin(ωx+φ)的图像和性质
\({\color{Red}{欢迎到学科网下载资料学习 }}\) [基础过关系列]2022-2023学年高一数学上学期同步知识点剖析精品讲义(人教A版2019) \({\color{Red}{ 跟贵哥 ...
- Vue 项目优化解决方案 有哪些 ?
1. 打包的时候 , 通过插件把css代码单独抽离出来使用 link 引入 2. 打包的时候 , 通过webpack内置插件optimization 把 公共的js代码抽离出来 ps:打包之后,代码 ...
- 009 Pycharm的使用(各种骚操作和快捷键)
博客配套视频链接: https://space.bilibili.com/383551518?spm_id_from=333.1007.0.0 b 站直接看 配套 github 链接:https:// ...
- FFmpeg开发笔记(五十七)使用Media3的Transformer加工视频文件
继音视频播放器ExoPlayer之后,谷歌又推出了音视频转换器Transformer,要在音视频加工领域施展拳脚.根据Android开发者官网介绍:Jetpack Media3是Android媒体库 ...
- ToDesk云电脑开启公测!支持AIGC、高性能渲染等场景,价格低至0.98元
在云计算和人工智能技术飞速发展的今天,云电脑作为一种新型的计算模式,正逐渐改变着传统电脑的使用方式.近日,ToDesk云电脑宣布开启公测,以其支持AIGC(人工智能.大数据.云计算等技术的融合应用). ...
- Vue写一个图片轮播组件【转载】
一.理清思路,理解需求和原理 1. 要写一个什么样的轮播? 在点击右侧箭头时,图片向左滑动到下一张:点击左侧箭头时,图片向右滑到下一张 点击下面的小圆点,滑到对应的图片,相应小圆点的样式也发生改变 要 ...
- CSS动画(毛玻璃按钮)
1.整体效果 https://mmbiz.qpic.cn/sz_mmbiz_gif/EGZdlrTDJa4ofJ9W4ibgD5asQcBesp1f1CXVnrQmicnzqDPskBNEQC4ia0 ...
- 每日学学Java开发规范,OOP规约(附阿里巴巴Java开发手册(终极版))
前言 每次去不同的公司,码不同的代码,适应不同的规范,经常被老大教育规范问题,我都有点走火入魔的感觉,还是要去看看阿里巴巴Java开发规范,从中熟悉一下,纠正自己,码出高效,码出质量. 想细看的可以去 ...
- 如果让你处理hbase 怎么保证数据的安全性可靠性 不需要具体的设置 要一套方案
有关数据安全及可靠我们认为大体上分为存储安全和使用安全 1 数据存储安全 hbase是基于hdfs的一种数据存储解决方案,所以有关数据的安全性可靠性可以利用hdfs自身的副本机制保障.另外原生的hba ...