前记
由于WebSocket协议是借助HTTP/1.1版本的“握手机制”完成的建立链接这一操作,同时基于TCP协议进行独立传输数据,所以最好了解一下TCP和HTTP协议的相关知识,可以参考另一篇笔记《TCP、HTTP协议的深入研究》
简单介绍
WebSocket是一种基于TCP的全双工(Full-Duplex)应用层通信协议,位于 OSI 模型的应用层,由HTML5规范正式定义,核心价值是解决传统HTTP协议在实时通信场景中的痛点(如轮询效率低、服务器无法主动推数据),实现客户端与服务器之间的 “持久连接 + 双向实时通信”。
主要优点
- 全双工通信:我们都知道HTTP协议是半双工通信,这使得HTTP在一些特定平台(比如竞价平台、即时通信平台)上无法完美适配,基于此,WebSocket协议应运而生。这就是WebSocket协议的主要价值与目的。
- 持久化连接:WebSocket协议在创建TCP连接后,连接会长期保持(直到主动关闭),虽然HTTP/1.1以后得版本都支持了长连接,但是仍需 “请求触发响应”。
- 低开销:在握手阶段仅需一次HTTP兼容的“升级请求”,后续数据传输无需携带HTTP头。与HTTP/2.X类似,WebSocket同样使用了“帧(Frame)”来封装数据,降低了带宽占用。
- 多数据类型支持:可直接传输文本数据和二进制数据(图片、音频等),无需像HTTP哪样通过Base64编码转换(同样也会降低性能开销)
- 原生的跨域支持:握手阶段就能通过
Origin头来指定跨域源,这个和HTTP的CORS很像,参考《CORS规范》
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会是0x01或0x02。
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为数据帧(
0x01、0x02)时,内容为应用程序要发送的消息体
- 对于Opcode为关闭帧(
0x08)时,内容为一个2字节的关闭码(1000表示正常关闭)和一个可选的UTF-8编码的关闭原因
- 对于Opcode为Ping/Pong帧(
0x09、0x0A)时,内容是包含最多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
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https:
|
二、服务器响应升级
服务器在收到上述请求后,会依次执行如下步骤:
- 校验
Upgrade、Connection、Sec-WebSocket-Version等字段是否正确。
- 生成
Sec-WebSocket-Accept密钥。
- 发送响应:服务器返回一个HTTP 101状态码,告诉客户端 “协议切换成功”。
响应中的关键响应头:
1 2 3 4 5 6 7 8 9
| // 核心响应。101状态码表示服务器同意切换协议。 HTTP/1.1 101 Switching Protocols
Upgrade: websocket Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
|
三、建立连接
客户端收到服务器的HTTP 101响应后,会验证Sec-WebSocket-Accept的值是否正确。如果正确,客户端和服务器之间的WebSocket连接就正式建立了。
数据传输阶段
一旦握手成功,底层的TCP连接就被WebSocket协议接管,后续所有通信都不再遵循HTTP的 “请求 - 响应” 模式,而是基于帧 (Frame) 的全双工通信。
注意事项:
- 为了防止长时间没有数据传输导致连接被中间的防火墙或代理断开,客户端和服务器会定期发送心跳包,也就是上面所提到的ping/pong帧
- 发起方发送帧后,如果是数据帧(
0x01、0x02),协议不强制等待;而如果发送的是Ping帧(0x09),则必须等待Pong帧(0x0A)响应;如果发送的是关闭帧(0x08),则必须等待确认Close响应(详见下面的“”“关闭阶段”)
关闭阶段
- 发起方发送关闭帧
- 接收方收到关闭帧后,会发送一个同样的关闭帧作为确认,同时关闭底层TCP连接
- 发起方收到对方的确认关闭帧后,也会断开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 upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true }, } )
func main() { http.HandleFunc("/ws", handleWebSocket)
go handleBroadcast()
log.Println("服务启动,监听端口 15500...") log.Fatal(http.ListenAndServe(":15500", nil)) }
func handleWebSocket(w http.ResponseWriter, r *http.Request) { 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() 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> 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); }
messageInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') { sendMessage(); } }); </script> </body> </html>
|
效果截图
服务端效果
客户端1效果
客户端2效果
特殊事项
提问:如果使用nginx来做代理服务时,如果代理的服务中存在WebSocket请求,nginx需要做哪些特殊配置?
- 因为WebSocket是基于HTTP/1.1版本实现的握手操作,所以必须要保证nginx的HTTP链接为1.1版本
- 在升级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 }
|