WebSocket协议的深入研究

cuixiaogang

前记

由于WebSocket协议是借助HTTP/1.1版本的“握手机制”完成的建立链接这一操作,同时基于TCP协议进行独立传输数据,所以最好了解一下TCP和HTTP协议的相关知识,可以参考另一篇笔记《TCP、HTTP协议的深入研究

简单介绍

WebSocket是一种基于TCP的全双工(Full-Duplex)应用层通信协议,位于 OSI 模型的应用层,由HTML5规范正式定义,核心价值是解决传统HTTP协议在实时通信场景中的痛点(如轮询效率低、服务器无法主动推数据),实现客户端与服务器之间的 “持久连接 + 双向实时通信”。

主要优点

  1. 全双工通信:我们都知道HTTP协议是半双工通信,这使得HTTP在一些特定平台(比如竞价平台、即时通信平台)上无法完美适配,基于此,WebSocket协议应运而生。这就是WebSocket协议的主要价值与目的。
  2. 持久化连接:WebSocket协议在创建TCP连接后,连接会长期保持(直到主动关闭),虽然HTTP/1.1以后得版本都支持了长连接,但是仍需 “请求触发响应”。
  3. 低开销:在握手阶段仅需一次HTTP兼容的“升级请求”,后续数据传输无需携带HTTP头。与HTTP/2.X类似,WebSocket同样使用了“帧(Frame)”来封装数据,降低了带宽占用。
  4. 多数据类型支持:可直接传输文本数据和二进制数据(图片、音频等),无需像HTTP哪样通过Base64编码转换(同样也会降低性能开销)
  5. 原生的跨域支持:握手阶段就能通过Origin头来指定跨域源,这个和HTTP的CORS很像,参考《CORS规范

WebSocket与HTTP连接的区别
WebSocket与HTTP连接的区别

数据结构

WebSocket协议规定,所有从客户端到服务器或从服务器到客户端的数据,都必须被封装在一个或多个“帧”中进行传输。一个完整的消息可能由一个或多个“帧”组成。

帧结构
帧结构

上图中是一个完整的帧结构示意图,以这个图中的帧数据为例,解释每个字段的含义:

字段 长度 解释
FIN 1 消息结束标志。1表示这是当前消息的最后一帧;0表示后面还有更多帧属于同一个消息(主要用于分片)。
RSV1,RSV2,RSV3 1,1,1 保留位。用于未来协议扩展(如压缩)。如果没有协商相应的扩展,这三个位必须为0,否则接收方必须关闭连接。
Opcode 4 操作码。定义了帧的类型(是文本、二进制、还是控制帧等)。
Mask 1 掩码标志。1表示“Payload Data”是经过掩码加密的;0表示未加密。客户端发送的所有帧此位必须为1。
Payload Length 7,16,64 数据载荷长度。表示“Payload Data”的字节数。这个字段是变长的,以优化小数据传输。
Masking Key 0 or 32 掩码密钥。仅当Mask位为1时存在。是一个32位(4字节)的随机数,用于解密 “Payload Data”。
Payload Data Variable 实际数据载荷。包含了真正要传输的应用数据(如文本消息、二进制文件等)。

Opcode的常用取值及含义

  • 0x00: 延续帧(Continuation Frame)。用于拼接分片消息。当一个消息被分片时,除了第一个帧外,后续所有帧的Opcode都必须是0x00。第一个帧的Opcode会是0x010x02
  • 0x01: 文本帧(Text Frame)。Payload Data必须是UTF-8编码的文本。这是最常用的帧类型之一。
  • 0x02: 二进制帧(Binary Frame)。Payload Data是任意二进制数据,如图片、音频、视频或序列化的对象。
  • 0x08: 关闭帧(Close Frame)。用于请求关闭连接。可以在Payload Data中包含一个关闭码和原因(UTF-8 文本)。
  • 0x09: Ping帧(Ping Frame)。心跳检测帧。接收方收到Ping帧后,必须立即返回一个Pong帧。主要用于确认连接是否仍然活跃。
  • 0x0A: Pong帧(Pong Frame)。对Ping帧的响应。Pong帧的Payload Data通常与收到的 Ping 帧相同。

Payload Length的三种编码方式

  • 情况1:值在0-125之间,Payload Length字段仅用7位。该7位的值就直接代表数据长度(字节)。
  • 情况2:值为126,表示数据长度超过125字节,但小65536字节(64KB)。此时,后续的16位(2字节将作为无符号整数,表示实际的长度。
  • 情况3:值为127,表示数据长度大于等于65536字节。此时,后续的64位(8字节)将作为无符号整数,表示实际的长度

Payload Data内容

  • 对于Opcode为数据帧(0x010x02)时,内容为应用程序要发送的消息体
  • 对于Opcode为关闭帧(0x08)时,内容为一个2字节的关闭码(1000表示正常关闭)和一个可选的UTF-8编码的关闭原因
  • 对于Opcode为Ping/Pong帧(0x090x0A)时,内容是包含最多125字节的任意数据,这些数据会被原封不动地在Pong帧中返回。

通信流程

握手阶段

一、客户端发起升级请求

客户端(通常是浏览器)会向服务器发送一个特殊的HTTP GET请求,明确表示 “将该GET请求升级为Websocket请求”。主要注意的是,必须是HTTP/1.1的请求,同时存在如下关键请求头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 必须是GET的HTTP/1.1的
GET /chat HTTP/1.1

// 目标的服务地址
Host: example.com

// 核心字段。告诉服务器,客户端希望将协议升级到websocket
Upgrade: websocket

// 核心字段。告诉服务器,这个连接的意图是 “升级”。
Connection: Upgrade

// 客户端生成的一个随机的16字节值,并进行了Base64编码。服务器会用它来验证并生成Sec-WebSocket-Accept响应。这是一种安全机制,防止服务器误将普通HTTP请求当作WebSocket升级请求。
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

// 客户端支持的WebSocket协议版本。13是当前的主流和标准版本。
Sec-WebSocket-Version: 13

// 表示请求的来源页面,用于服务器进行跨域验证。
Origin: https://example.com

二、服务器响应升级

服务器在收到上述请求后,会依次执行如下步骤:

  1. 校验UpgradeConnectionSec-WebSocket-Version等字段是否正确。
  2. 生成Sec-WebSocket-Accept密钥。
  3. 发送响应:服务器返回一个HTTP 101状态码,告诉客户端 “协议切换成功”。

响应中的关键响应头:

1
2
3
4
5
6
7
8
9
// 核心响应。101状态码表示服务器同意切换协议。
HTTP/1.1 101 Switching Protocols

// 确认协议将升级为WebSocket。
Upgrade: websocket
Connection: Upgrade

// 刚刚计算的那个值
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

三、建立连接

客户端收到服务器的HTTP 101响应后,会验证Sec-WebSocket-Accept的值是否正确。如果正确,客户端和服务器之间的WebSocket连接就正式建立了。

数据传输阶段

一旦握手成功,底层的TCP连接就被WebSocket协议接管,后续所有通信都不再遵循HTTP的 “请求 - 响应” 模式,而是基于帧 (Frame) 的全双工通信。

注意事项

  1. 为了防止长时间没有数据传输导致连接被中间的防火墙或代理断开,客户端和服务器会定期发送心跳包,也就是上面所提到的ping/pong帧
  2. 发起方发送帧后,如果是数据帧(0x010x02),协议不强制等待;而如果发送的是Ping帧(0x09),则必须等待Pong帧(0x0A)响应;如果发送的是关闭帧(0x08),则必须等待确认Close响应(详见下面的“”“关闭阶段”)

关闭阶段

  1. 发起方发送关闭帧
  2. 接收方收到关闭帧后,会发送一个同样的关闭帧作为确认,同时关闭底层TCP连接
  3. 发起方收到对方的确认关闭帧后,也会断开TCP连接

与其他协议的区别

维度 WebSocket HTTP(1.1/2.X) Socket(TCP Socket)
协议层级 应用层(TCP) 应用层(TCP) 传输层
连接类型 持久连接 半持久连接(HTTP/1.1长连接需请求触发) 持久连接
通信方式 全双工 半双工 全双工
开销 极低(仅TCP头)
浏览器支持 HTML5后原生支持 原生支持 不支持

实战

服务端

因为PHP本身不支持Websocket,如果需要使用Websocket,则需要安装Swoole相关组件,所以不建议使用PHP来实现这个功能。

这里使用GO来实现服务端代码,需要引入相关依赖github.com/gorilla/websocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package main

import (
"log"
"net/http"
"sync"

"github.com/gorilla/websocket"
)

// 客户端连接管理(并发安全)
var (
clients = make(map[*websocket.Conn]bool) // 存储所有活跃连接
broadcast = make(chan []byte) // 广播消息通道
mu sync.Mutex // 保护 clients map 的互斥锁
upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { // 允许跨域(开发环境)
return true
},
}
)

func main() {
// 注册 WebSocket 处理路由
http.HandleFunc("/ws", handleWebSocket)

// 启动广播协程
go handleBroadcast()

// 启动服务
log.Println("服务启动,监听端口 15500...")
log.Fatal(http.ListenAndServe(":15500", nil))
}

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

// 将新连接加入客户端列表(加锁保证并发安全)
mu.Lock()
clients[conn] = true
mu.Unlock()

log.Println("新客户端连接,当前连接数:", len(clients))

// 循环读取客户端消息
for {
_, msg, err := conn.ReadMessage() // 读取二进制消息(也可使用 ReadJSON)
if err != nil {
log.Println("读取消息失败:", err)
// 移除断开的连接
mu.Lock()
delete(clients, conn)
mu.Unlock()
log.Println("客户端断开,当前连接数:", len(clients))
break
}
// 将消息发送到广播通道
broadcast <- msg
}
}

// 处理广播:将消息发送给所有客户端
func handleBroadcast() {
for {
msg := <-broadcast // 从广播通道取消息

// 遍历所有客户端,发送消息(加锁保证并发安全)
mu.Lock()
for conn := range clients {
err := conn.WriteMessage(websocket.TextMessage, msg)
if err != nil {
log.Println("发送消息失败:", err)
conn.Close()
delete(clients, conn) // 移除发送失败的连接
}
}
mu.Unlock()
}
}

客户端代码

客户端使用传统的原生JS+HTML的组合,目前通用的浏览器都是支持H5的,所以直接使用既可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebSocket 通信Demo</title>
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
#chatLog {
border: 1px solid #ccc;
height: 400px;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
}
.input-area {
display: flex;
gap: 10px;
}
#messageInput {
flex: 1;
padding: 8px;
font-size: 16px;
}
button {
padding: 8px 20px;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
</style>
</head>
<body>
<div class="container">
<h1>WebSocket 实时聊天Demo</h1>
<div id="chatLog"></div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="输入消息...">
<button onclick="sendMessage()">发送</button>
</div>
</div>

<script>
// 建立 WebSocket 连接(注意协议:ws 对应 http,wss 对应 https)
const ws = new WebSocket('ws://dev9.white.tjcorp.qihoo.net:15500/ws');

const chatLog = document.getElementById('chatLog');
const messageInput = document.getElementById('messageInput');

// 连接成功时触发
ws.onopen = function() {
addMessageToLog('✅ 已连接到服务器');
};

ws.onmessage = function(event) {
const message = event.data; // 接收文本消息
addMessageToLog('📩 收到消息:' + message);
};

ws.onclose = function() {
addMessageToLog('❌ 连接已关闭');
};

ws.onerror = function(error) {
addMessageToLog('❌ 发生错误:' + error);
};

// 发送消息到服务器
function sendMessage() {
const message = messageInput.value.trim();
if (message === '') return;

// 发送文本消息
ws.send(message);
addMessageToLog('📤 发送消息:' + message);

// 清空输入框
messageInput.value = '';
}

// 将消息添加到聊天记录
function addMessageToLog(text) {
const div = document.createElement('div');
div.style.margin = '5px 0';
div.textContent = text;
chatLog.appendChild(div);
}

// 支持Enter发送消息
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>

效果截图

  • 服务端效果

服务端效果
服务端效果

  • 客户端1效果

客户端1效果
客户端1效果

  • 客户端2效果

客户端2效果
客户端2效果

特殊事项

提问:如果使用nginx来做代理服务时,如果代理的服务中存在WebSocket请求,nginx需要做哪些特殊配置?

  1. 因为WebSocket是基于HTTP/1.1版本实现的握手操作,所以必须要保证nginx的HTTP链接为1.1版本
  2. 在升级HTTP GET请求为WebSocket请求时,需要在GET请求中携带特殊Header,也需要做额外的配置

具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
location / {
proxy_pass https://127.0.0.1:9200;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# 特殊配置项
proxy_http_version 1.1; # WebSocket需要HTTP/1.1支持
proxy_set_header Upgrade $http_upgrade; # 传递升级请求头
proxy_set_header Connection "upgrade"; # 指示连接升级为WebSocket
}