为什么要有 WebSocket?
已经有了 HTTP 了为什么还要有 WebSocket 呢?
因为,HTTP 的请求只能由客户端发起,服务器接收。但是,现在想要让服务器端也可以主动发起请求。那么使用 HTTP 是无法满足的。
其次,还有一种就是,如果想要监听服务端发送的请求。那么,可以让客户端始终处于一种轮询状态。客户端每隔一段就发起一个询问,看一下服务端有没有请求信息。
使用轮询的缺点非常明显,流量一旦很大的话,后端服务的压力就很大。而且在高并发的情况下,很容易引起雪崩。例如,服务器端因为压力大导致在轮询的时候没有办法返回正常的响应,使得客户端进一步轮询等。综合使用 HTTP 的效率极其低下。
就在这样的场景下,工程师们经过不懈的思考,WebSocket由此诞生。
WebSocket 的特点&场景
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,设计用于在客户端(通常是浏览器)和服务器之间建立持久连接,以实现实时数据传输。
1. 实时通信
优点:
- 低延迟:WebSocket 连接一旦建立,客户端和服务器之间可以立即传输数据,无需每次都进行 HTTP 请求和响应,从而减少延迟。
- 双向通信:WebSocket 支持全双工通信,客户端和服务器可以同时发送和接收数据,适合实时应用。
使用场景:
- 实时聊天应用:如 Slack、WhatsApp Web 版。
- 在线游戏:需要快速、实时的数据交换。
- 金融交易平台:如股票交易、外汇交易,需要快速获取市场数据和执行交易。
2. 持久连接
优点:
- 减少带宽消耗:由于 WebSocket 连接是持久的,避免了 HTTP 请求的头部开销,特别是在频繁通信的场景下,可以显著减少带宽消耗。
- 状态保持:持久连接使得客户端和服务器可以保持状态,不需要每次请求都重新建立连接和身份验证。
使用场景:
- 物联网(IoT)设备:需要持续监控和控制设备状态。
- 协作工具:如 Google Docs,实现多个用户实时编辑同一文档。
3. 简化开发
优点:
- 标准化协议:WebSocket 是一种标准化协议,浏览器和服务器端库普遍支持,简化了开发和集成。
- 统一接口:WebSocket API 提供了一个统一的接口,用于建立连接、发送和接收消息,简化了开发过程。
使用场景:
- 实时通知系统:如网站的即时通知、消息推送。
- 实时数据流:如实时数据分析和可视化仪表盘。
4. 更高效的资源使用
优点:
- 服务器资源优化:WebSocket 持久连接减少了服务器需要处理的连接建立和关闭的开销。
- 客户端资源优化:客户端不需要频繁发起新的连接请求,减少了资源消耗。
使用场景:
- 社交媒体:如 Twitter 的实时更新流。
- 在线协作工具:如 Trello 的实时看板更新。
5. 替代轮询和长轮询
优点:
- 减少不必要的请求:传统的轮询和长轮询需要客户端频繁向服务器发送请求以检查新数据,浪费网络带宽和服务器资源。WebSocket 通过持久连接避免了这种情况。
- 更好的用户体验:实时更新和低延迟通信为用户提供了更好的互动体验。
使用场景:
- 实时数据更新应用:如天气预报、股票市场数据。
- 在线协作和会议:如视频会议、实时文档编辑。
WebSocket 的建立的过程
WebSocket的初始化过程非常简单,可以理解为是在 HTTP 基础上进行了协商之后,将 HTTP 协议升级成了 WebSocket 协议。
1. 建立连接(握手)
WebSocket 连接始于客户端向服务器发送一个 HTTP 请求,以启动 WebSocket 握手过程。
客户端请求
客户端发送一个 HTTP GET 请求,其中包含一些特定的头部字段来表示请求升级到 WebSocket 协议。以下是一个典型的握手请求示例:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Upgrade: websocket
: 表示请求将协议升级为 WebSocket。Connection: Upgrade
: 表示连接需要升级。Sec-WebSocket-Key
: 一个随机生成的 Base64 编码的密钥,用于服务器生成响应密钥。Sec-WebSocket-Version
: WebSocket 协议的版本,当前常用的是 13。
服务器响应
服务器接收到握手请求后,生成一个响应,并确认协议升级。以下是一个典型的握手响应示例:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101 Switching Protocols
: 表示协议切换成功。Upgrade: websocket
: 确认升级到 WebSocket 协议。Connection: Upgrade
: 确认连接升级。Sec-WebSocket-Accept
: 服务器通过对Sec-WebSocket-Key
进行 SHA-1 哈希并 Base64 编码生成的值。
一旦握手成功,HTTP 连接将升级为 WebSocket 连接,并且可以开始进行数据传输。
2. 数据传输
在 WebSocket 连接建立后,客户端和服务器可以通过这个连接进行全双工(双向)数据传输。数据通过帧(frame)进行传输。
发送消息
客户端和服务器都可以发送文本帧或二进制帧。例如,发送一个文本消息:
(这里我们先使用 javascrip)
// 客户端
const socket = new WebSocket('ws://example.com/chat');
socket.onopen = function(event) {
socket.send('Hello Server!');
};
// 服务器(使用 Node.js WebSocket 库)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('message', function incoming(message) {
console.log('received: %s', message);
ws.send('Hello Client!');
});
});
接收消息
客户端和服务器都可以接收消息:
// 客户端
socket.onmessage = function(event) {
console.log('Message from server: ', event.data);
};
// 服务器
ws.on('message', function incoming(message) {
console.log('Message from client: ', message);
});
3. 关闭连接
WebSocket 连接可以由客户端或服务器任意一方关闭。关闭连接的过程包括发送一个关闭帧。
WebSocket 的报文
WebSocket 是一种全双工、双向通信协议,允许客户端和服务器之间进行实时数据交换。WebSocket 的报文结构在设计上非常高效,具有较小的开销。
WebSocket 帧结构
WebSocket 数据通过帧(frame)进行传输,每个帧由以下几个部分组成:
- FIN, RSV1, RSV2, RSV3 和 Opcode(第1个字节)
- Mask 和 Payload Length(第2个字节)
- 扩展的 Payload Length(可选)(第3-10个字节)
- Masking Key(可选)(第4-14个字节)
- Payload Data(有效负载数据)(第5-14个字节之后)
1. FIN, RSV1, RSV2, RSV3 和 Opcode
- FIN (1 bit):表示这是消息的最后一个帧。如果是最后一个帧,FIN 为 1;否则为 0。
- RSV1, RSV2, RSV3 (1 bit each):保留位,通常为 0,除非定义了扩展。
- Opcode (4 bits):表示帧的类型。常见的 Opcode 值:
0x0
:继续帧(Continuation frame)0x1
:文本帧(Text frame)0x2
:二进制帧(Binary frame)0x8
:连接关闭(Connection Close)0x9
:Ping0xA
:Pong
2. Mask 和 Payload Length
- Mask(1 bit):表示是否对负载数据进行掩码处理。客户端发送的帧必须设置 Mask 为 1,服务器发送的帧必须设置为 0。
- Payload Length (7 bits):表示负载数据的长度。如果长度为 126,表示使用后两个字节;如果长度为 127,表示使用后八个字节。
3. 扩展的 Payload Length(可选)
- 7 bits:如果 Payload Length 为 126,表示负载数据长度为 16 位无符号整数。
- 8 bits:如果 Payload Length 为 127,表示负载数据长度为 64 位无符号整数。
4. Masking Key(可选)
- 4 bytes:如果 Mask 为 1,表示掩码密钥,用于对负载数据进行掩码处理。
5. Payload Data(有效负载数据)
- x bytes:实际传输的数据。如果 Mask 为 1,需要使用 Masking Key 解码数据。
好的,让我们详细讲解一个 WebSocket 帧的示例,假设客户端发送一条文本消息 "Hello"
。我们将逐步拆解这个帧的各个组成部分。
示例:发送文本消息 “Hello”
1. 构建帧头部
假设我们要发送的消息是 "Hello"
,它的字节表示是:0x48 0x65 0x6C 0x6C 0x6F
。
第1字节:FIN, RSV1, RSV2, RSV3, Opcode
- FIN (1 bit): 1 (因为这是消息的最后一个帧)
- RSV1, RSV2, RSV3 (1 bit each): 0
- Opcode (4 bits): 0x1 (表示这是一个文本帧)
FIN RSV1 RSV2 RSV3 Opcode
1 0 0 0 0001
这个字节的二进制表示是 1000 0001
,即 0x81
。
第2字节:Mask, Payload Length
- Mask (1 bit): 1 (因为这是客户端发送的消息)
- Payload Length (7 bits): 5 (“Hello” 的长度为 5)
Mask Payload Length
1 000 0101
这个字节的二进制表示是 1000 0101
,即 0x85
。
第3-6字节:Masking Key
假设 Masking Key 是 0x37 0xFA 0x21 0x3D
,这个是客户端随机生成的用于掩码处理。
2. 构建帧负载数据
对负载数据进行掩码处理:
- 原始负载数据:
0x48 0x65 0x6C 0x6C 0x6F
- Masking Key:
0x37 0xFA 0x21 0x3D
掩码处理是通过每个负载字节与对应的 Masking Key 字节进行异或(XOR)操作:
0x48 ^ 0x37 = 0x7F
0x65 ^ 0xFA = 0x9F
0x6C ^ 0x21 = 0x4D
0x6C ^ 0x3D = 0x51
0x6F ^ 0x37 = 0x58
掩码后的负载数据是: 0x7F 0x9F 0x4D 0x51 0x58
3. 完整的 WebSocket 帧
将所有部分组合在一起,形成完整的 WebSocket 帧:
0x81 // FIN=1, RSV1=0, RSV2=0, RSV3=0, Opcode=1
0x85 // Mask=1, Payload Length=5
0x37 0xFA 0x21 0x3D // Masking Key
0x7F 0x9F 0x4D 0x51 0x58 // 掩码后的负载数据 "Hello"
因此,完整的帧表示为:
0x81 0x85 0x37 0xFA 0x21 0x3D 0x7F 0x9F 0x4D 0x51 0x58
WebSocket 的保活原理
保活机制(Keep-Alive)旨在确保连接在长时间不活动后仍然保持打开状态,以防止连接由于网络设备的超时策略而被意外关闭。
保活机制主要通过发送心跳消息(通常是 Ping/Pong 帧)来实现。
心跳机制
1. Ping/Pong 帧
WebSocket 协议内置了 Ping 和 Pong 帧,用于保持连接的活跃状态:
- Ping 帧:由客户端或服务器发送,用于检查对方是否仍然在线。
- Pong 帧:是对 Ping 帧的响应,表明接收到 Ping 帧的一方仍然在线。
2. 工作原理
- 发送 Ping 帧:客户端或服务器定期发送 Ping 帧,通常包含一个小的负载数据(可以为空)。
- 接收 Pong 帧:接收到 Ping 帧的一方应该立即回复一个 Pong 帧,负载数据与接收到的 Ping 帧相同。
实现心跳机制
心跳机制的实现通常依赖于以下两方面:客户端实现和服务器实现。以下是一些常见的实现方式:
1. 客户端实现
在客户端中,可以使用 JavaScript 的 setInterval
方法定期发送 Ping 帧。例如:
const socket = new WebSocket('ws://example.com/socket');
socket.onopen = function() {
// 每隔30秒发送一个Ping帧
setInterval(function() {
socket.send(JSON.stringify({ type: 'ping' }));
}, 30000);
};
socket.onmessage = function(event) {
const message = JSON.parse(event.data);
if (message.type === 'pong') {
console.log('Received pong');
}
};
2. 服务器实现
在服务器中,可以使用 WebSocket 库提供的功能来实现心跳机制。例如,在 Node.js 中使用 ws
库:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function(ws) {
ws.isAlive = true;
ws.on('pong', function() {
ws.isAlive = true;
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
ws.on('close', function() {
clearInterval(interval);
});
});
解释:在这个示例中,服务器每隔 30 秒向所有连接的客户端发送 Ping 帧,并检查是否接收到 Pong 帧。如果没有接收到 Pong 帧,则认为连接已断开并关闭该连接。
其他保活机制
除了 Ping/Pong 帧,WebSocket 连接的保活还可能依赖于以下机制:
1. TCP Keep-Alive
一些 WebSocket 实现可能依赖于底层的 TCP Keep-Alive 机制来确保连接的活跃状态。TCP Keep-Alive 是一种在 TCP 层实现的保活机制,通过发送空的 TCP 数据包来保持连接。
2. 应用层心跳
在应用层,也可以实现自定义的心跳机制。例如,定期发送应用特定的心跳消息,并检查是否接收到预期的响应。
WebSocket API
使用 gorilla/websocket
库来建立 WebSocket 连接,并在连接上进行数据的读写。
组合 API
type WsServer struct {
*websocket.Conn
}
WebSocket 升级
通过 websocket.Upgrader 实例将 HTTP 连接升级为 WebSocket 连接。
upgrader := &websocket.Upgrader{}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
w.Write([]byte("upgrade error"))
return
}
conn := &WsServer{Conn:c}
})
读取 WebSocket 消息
使用一个 goroutine 来读取 WebSocket 消息,并根据消息类型进行处理。
go func() {
for {
typ, msg, err := conn.ReadMessage()
if err != nil {
return
}
switch typ {
case websocket.CloseMessage:
conn.Close()
return
default:
t.Logf("msg:%s", msg)
}
}
}()
发送 WebSocket 消息
另一个 goroutine 定期发送消息到客户端。
go func() {
ticker := time.NewTicker(time.Second * 3)
for now := range ticker.C {
err := conn.WriteMessage(websocket.TextMessage, []byte("Hello"+now.String()))
if err != nil {
return
}
}
}()
启动服务
// ws://localhost:8081/ws
http.ListenAndServe(":8081", nil)
以上实现了一个极其简单的 WebSocket。
多客户端协调
思路一
假如,我们的服务端都在同一个节点上,我们只需要在服务器内部做一个简单的转发机制就可以啦。
最简单的做法就是,每次连接上一个客户端,我们就将其保存在内存里面。而后,如果从一个客户端接收到了消息,就转发给别的客户端。
type Hub struct {
// 封装的 map key 为房间号,value 为房间内的所有连接
conns *syncx.Map[string, *websocket.Conn]
}
func (h *Hub) AddConn(name string, conn *websocket.Conn) {
h.conns.Store(name, conn)
go func() {
// 接收消息
typ, msg, err := conn.ReadMessage()
if err != nil {
return
}
switch typ {
case websocket.CloseMessage:
h.conns.Delete(name)
conn.Close()
return
default:
// 广播消息
log.Println("from client:", typ, string(msg), name)
h.conns.Range(func(key string, value *websocket.Conn) bool {
if key == name {
// 不发送给自己
return true
}
log.Println("to client:", key)
err := value.WriteMessage(typ, msg)
if err != nil {
log.Println(err)
}
return true
})
}
}()
}
测试:
func TestForward(t *testing.T) {
upgrader := websocket.Upgrader{}
hub := &Hub{
conns: &syncx.Map[string, *websocket.Conn]{},
}
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
w.Write([]byte("upgrade error"))
return
}
name := r.URL.Query().Get("name")
hub.AddConn(name, c)
})
// ws://localhost:8081/ws?name=ypb
http.ListenAndServe(":8081", nil)
}
思路二
实现 WebSocket 多客户端协调涉及管理多个 WebSocket 连接,并在必要时广播消息或在客户端之间传递消息。这通常通过维护一个连接池来实现。
定义一个结构体来封装 WebSocket 连接,并包含一个连接池来管理所有的连接。
// Ws websocket
type Ws struct {
Conn *websocket.Conn
}
// ConnectionPool connection pool
type ConnectionPool struct {
connections map[*websocket.Conn]bool
lock sync.Mutex
}
// Add new connection
func (p *ConnectionPool) Add(conn *websocket.Conn) {
p.lock.Lock()
defer p.lock.Unlock()
p.connections[conn] = true
}
// Remove connection
func (p *ConnectionPool) Remove(conn *websocket.Conn) {
p.lock.Lock()
defer p.lock.Unlock()
for _, ok := p.connections[conn]; ok; {
delete(p.connections, conn)
conn.Close()
}
}
// Broadcast message
func (p *ConnectionPool) Broadcast(messageType int, message []byte) {
p.lock.Lock()
defer p.lock.Unlock()
for conn := range p.connections {
err := conn.WriteMessage(messageType, message)
if err != nil {
log.Printf("发送消息错误: %v", err)
conn.Close()
delete(p.connections, conn)
}
}
}
实现一个 SebSocket 处理函数。
func wsHandler(w http.ResponseWriter, r *http.Request) {
upgrader := &websocket.Upgrader{}
pool := &ConnectionPool{
connections: make(map[*websocket.Conn]bool),
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, "无法升级到 WebSocket", http.StatusInternalServerError)
return
}
// 添加新的连接到连接池
pool.Add(conn)
defer pool.Remove(conn)
// 读取消息
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
log.Printf("读取消息错误: %v", err)
break
}
log.Printf("接收到消息: %s", message)
// 广播消息到所有连接
pool.Broadcast(messageType, message)
}
}
测试:
func Test_wsHandler(t *testing.T) {
http.HandleFunc("/ws", wsHandler)
go func() {
server := gin.Default()
server.GET("/", func(ctx *gin.Context) {
ctx.String(http.StatusOK, "Hello, WebSocket!")
})
server.Run(":8082")
}()
log.Fatal(http.ListenAndServe(":8081", nil))
}
总结
以上就是本次关于 WebSocket 的讲解,向大家介绍了 WebSocket 的发展历程,数据报文字段(这部分不是重点),并且给大家讲述了如何实现一个简单的 WebSocket 样例。