WebSocket 协议分析

温馨Tips:阅读本文大约需要 10 分钟。

基本介绍

维基百科定义

WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信 ,位于 OSI 模型的应用层。WebSocket 协议在 2011 年由 IETF 标准化为 RFC 6455,后由 RFC 7936 补充规范。Web IDL 中的 WebSocket API 由 W3C 标准化。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

名词解释

  • IETF : 全称 I nternet E ngineering T ask Force(互联网工程任务组),是一个开放的标准组织,负责开发和推广自愿互联网标准,特别是构成 TCP/IP 协议族的标准。
  • Web IDL: 是一种接口描述语言格式,用于描述要在 Web 浏览器中实现的应用程序编程接口。

浏览器支持

使用场景

凡是对 "消息" 推送有较高及时性的场景,都可以使用 WebSocket,例如:客服服务、邮件通知等服务。

历史

在浏览器未实现 WebSocket 的时候,想要实现服务端推送,通常会使用 轮询长轮询 技术实现。

轮询

无脑定时发送请求去判断有没有新的"消息"。

长轮询

流程

  1. 浏览器发送请求发送到服务器。
  2. 服务器在有"消息"之前不会关闭连接。
  3. 当有新"消息"时 或 挂起超时,服务器将对其请求作出响应。
  4. 浏览器立即发出一个新的请求。

下面是 QQ 邮箱网页版长轮询请求截图

WebSocket 优点

  • 较少的控制开销
  • 更强的实时性
  • 保持连接状态(无需基于类似 cookie 机制去维持会话状态)
  • 更好的二进制支持
  • 更好的压缩效果

知识普及

二进制

基本概念

  • bit : 位,二进制的最小单位,1 位表示 1 个 01
  • byte : 字节,每 8 位叫做 1 字节(Node buffer 类型:Uint8Array,即 buffer 中每个元素代表一个 8 位无符号数字,范围 0~255)
  • KB: 1024 个字节
  • MB: 1024 kb
  • 有符号 :二进制的第一位用作符号位(通常是 0 代表正数,1 代表负数),这样实际可表示的数值范围绝对值会减少一半(Int8Array表示 8 位有符号二进制数组,每个元素范围为:-128~127)
  • 无符号 :将有符号的第一位也用作数值表示,Uint8Array 就是一个无符号二进制数组

运算

示例

   1 1 0 0 1 1 0 0
 & 1 0 0 1 1 0 1 1
------------------------------------------------------------
   1 0 0 0 1 0 0 0

   1 1 0 0 1 1 0 0
 | 1 0 0 1 1 0 1 1
------------------------------------------------------------
   1 1 0 1 1 1 1 1

   1 1 0 0 1 1 0 0
 ^ 1 0 0 1 1 0 1 1
------------------------------------------------------------
   0 1 0 1 0 1 1 1

Note :用相同的数异或二次等于本身:0b1010 == (0b1010 ^ 0b1100 ^ 0b1100) == 10

 ~ 1 1 0 0 1 1 0 0
------------------------------------------------------------
   0 0 1 1 0 0 1 1
bash 复制代码
   1 1 0 0 1 1 0 0 << 1
---------------------------------------------------------------------------
   1 0 0 1 1 0 0 0

Note:全部位向左移动一位,高位(左侧)的一个 1 被丢弃,低位(右侧)补充一个 0

   1 1 0 0 1 1 0 0 >> 1
---------------------------------------------------------------------------
   0 1 1 0 0 1 1 0

大小端

  • 大端小端是不同的字节顺序存储方式,统称为字节序
  • 大端模式,高位在前,低位在后(和我们的阅读习惯一致)
  • 小端模式, 与大端模式相反

Stream

流提供了两个主要优点:

  • 内存效率: 无需加载大量的数据到内存中即可进行处理。
  • 时间效率: 当获得数据之后即可立即开始处理数据,这样所需的时间更少,而不必等到整个数据有效负载可用才开始。

与 buffer 处理文件的内存占用对比

分别使用 buffer 及 stream 两种方式,对一个约为 61M 的文件做 md5 hash 摘要计算,来对比内存占用情况:

Buffer
ini 复制代码
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { performance } = require('perf_hooks');
const v8 = require('v8');

function bufferImpl() {
  v8.writeHeapSnapshot('buffer.1.heapsnapshot');

  const start = performance.now();
  const filename = path.join(__dirname, 'test.zip');
  const buffer = fs.readFileSync(filename);
  const hash = crypto.createHash('md5').update(buffer).digest('hex');

  v8.writeHeapSnapshot('buffer.2.heapsnapshot');

  console.log('Buffer Impl:');

  console.log('  Hash:', hash);
  console.log('  Cost:', performance.now() - start);
}
Stream
ini 复制代码
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { performance } = require('perf_hooks');
const v8 = require('v8');

function streamImpl() {
  v8.writeHeapSnapshot('stream.1.heapsnapshot');

  const start = performance.now();
  const filename = path.join(__dirname, 'test.zip');
  const stream = fs.createReadStream(filename);
  const md5 = crypto.createHash('md5').setEncoding('hex');
  const hash = stream.pipe(md5);

  v8.writeHeapSnapshot('stream.2.heapsnapshot');

  console.log('Stream Impl:');

  process.stdout.write('  Hash: ');
  hash.pipe(process.stdout);
  hash.on('end', () => {
    console.log('\n  Cost:', performance.now() - start);
  });
}

Node Stream API

stream.Writable

常用方法

  • .write(chunk) 写入数据片段
stream.Readable

常用事件:

  • data 接收到数据片段
  • error 错误
  • end 读取结束

常用方法

  • .pipe(writeable) 通俗来讲,就是把 "读" 管道"写" 管道头尾相接,这样数据就可以从一个管道直接 "流" 到另一个管道,方便操作。

最大传输单元(MTU)

泛指通讯协议中的最大传输单元。一般用来说明 TCP/IP 四层协议中数据链路层的最大传输单元,不同类型的网络 MTU 也会不同,我们普遍使用的以太网的 MTU 是 1500,即最大只能传输 1500 字节的数据帧。可以通过 ifconfig命令查看电脑各个网卡的 MTU。

最大分片大小(MSS)

指 TCP 建立连接后双方约定的可传输的最大 TCP 报文长度,是 TCP 用来限制应用层可发送的最大字节数。如果底层的MTU是1500byte,则 MSS = 1500 - 20(IP Header) - 20 (TCP Header) = 1460 byte。

图片来源:互联网协议介绍 · 应用层

Nagle 算法

作用:减少网络拥塞

规则

  1. 如果包长度达到 MSS,则允许发送
  2. 如果包含 FIN,则允许发送
  3. 如果设置了TCP_NODELAY,则允许发送
  4. 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于 MSS )均被确认,则允许发送
  5. 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。

大概意思就是:数据包不是立马发送的,而且要在一定延迟下(200ms)等待下一个包,如果加起来小于 MSS,这将这些包一起发送出去。

TCP 粘包和拆包

受 MSS 及 Nagle 算法等因素影响,TCP 在发送时会遇到粘包和拆包的情况:

协议分析

1. 通过一次 HTTP 握手,完成协议升级 (rfc6455#section-1.2)

首先客户端发送如下 Headers:

makefile 复制代码
GET /chat HTTP/1.1
Host: server.example.com
Origin: http://example.com

Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务端响应如下 Headers:

makefile 复制代码
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

其中,服务端响应的 Sec-WebSocket-Accept 字段的值生成算法伪代码如下:

  1. let str = Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
  2. base64(sha1( str ))

备注 : 字符串 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 是协议指定的唯一 GUID

当完成这次握手动作,客户端与服务端就建立了一个长连接,后续两端的消息收发等操作靠解析数据帧来完成。

2. 数据帧解析 (rfc6455#section-5.2)

lua 复制代码
 
  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
  +-+-+-+-+-------+-+-------------+-------------------------------+
  |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
  |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
  |N|V|V|V|       |S|             |   (if payload len==126/127)   |
  | |1|2|3|       |K|             |                               |
  +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
  |     Extended payload length continued, if payload len == 127  |
  + - - - - - - - - - - - - - - - +-------------------------------+
  |                               |Masking-key, if MASK set to 1  |
  +-------------------------------+-------------------------------+
  | Masking-key (continued)       |          Payload Data         |
  +-------------------------------- - - - - - - - - - - - - - - - +
  :                     Payload Data continued ...                :
  + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
  |                     Payload Data continued ...                |
  +---------------------------------------------------------------+
  • FIN: 占 1 位,表示这是消息的最后一个片段,(如果为 1,表示当前已经结束了,如果为0,表示消息分片还没结束)
  • RSV1, RSV2, RSV3: 各占1位,表示扩展协议 (extensions)
  • opcode: 占4位,表示操作码,各操作码含义如下:
diff 复制代码
-   %x0 : 表示消息帧的延续(一般来说配合 FIN = 0 使用)
-   %x1 : 表示传输的内容是文本
-   %x2 : 表示传输的内容是二进制
-   %x3-7 : 给未来保留的非控制帧的操作吗
-   %x8 : 表示连接关闭
-   %x9 : 表示一个 ping
-   %xA : 表示一个 pong
-   %xB-F : 给未来保留的控制帧的操作码
  • MASK: 占1位,表示是否使用掩码
  • Payload len: 占7位:表示数据的字节数,该长度值的含义如下:
diff 复制代码
-   如果值的范围是:0~125,则数据的真实长度就是该值
-   如果值为:126,则以无符号整数的方式读取接下来的 16 位,读取结果为数据的真实长度
-   如果值为:127,则以无符号整数的方式读取接下来的 64 位,读取结果为数据的真实长度

备注 :Payload len 实际占有的位数可能有:7 (0~125)、16 (126)、64 (127)

  • Masking-key: 如果使用了掩码( MASK = 1), 则占 32 位,否则,不占空间
  • Payload Data:数据,包含2部分:
diff 复制代码
-   扩展数据(握手时协商了扩展的时候才有,并要明确给出扩展数据的字节数)
-   应用数据

3. 一些细节

3.1 关于掩码(rfc6455#section-5.3

掩码用于加密或解密载荷数据,加密和解码的计算方式都如下所示 (nodejs 代码):

css 复制代码
/**
 * 处理控制帧
 * @param data 数据
 * @param maskingKey 掩码key(4个字节)
 */
function mask(data: Buffer, maskingKey: Buffer) {
  for (let i = 0; i < data.length; i++) {
    data[i] = data[i] ^ maskingKey[i % 4]
  }
}

协议规定 :客户端向服务端发送数据是,必须使用 掩码;而服务端向客户端发送数据时,则不能使用 掩码(rfc6455#section-5.1)

3.2 关于 ping 和 pong 操作 (rfc6455#section-5.5.2)

ping 和 pong 通常用于检测两端是否处于连接正常的状态(心跳检测)

协议规定: 一端向向一端发送 ping 操作帧时,另一端必须发送一个 pong 操作帧作为响应

4. 实现 demo 示例

请参阅:github.com/peakchen90/...

一些思考

HTTP 握手时为啥要使用 \r\n 作为换行符?

参考:www.rfc-editor.org/rfc/rfc2616...

发送二进制文件怎么携带发送者的用户信息?

可以实现一个自定义二进制协议。如下所示,二进制的第一个 8 位用来存放用户昵称的字节长度 L(最大支持 256 个字节,使用 UTF-8 编码字符大约能放 64 个中文),其后仅接着占用 L 个字节存放真实昵称使用 UTF-8 编码后的二进制序列,最后部分为真实的二进制文件内容。

ini 复制代码
// 0 0 0 0 0 0 0 0 : 昵称长度
// ...             : 昵称位置
// ...             : 二进制数据位置
const nickBuffer = Buffer.from(sender['nickname']);
const headerBuffer = Buffer.allocUnsafe(1 + nickBuffer.length);
headerBuffer.writeUInt8(nickBuffer.length, 0);
headerBuffer.set(nickBuffer, 1);

wss.broadcast([headerBuffer, message], true);

WebSocket 如何处理大文件?

大文件的内存占用对于服务端来说是致命的。客户端可以使用 Blob, 服务端只做消息转发的功能,不要在服务端组合分片内容完成后再下发。

IP 协议职责是?TCP 协议的职责是?

IP 用于找到主机,TCP 用于找到主机的程序(根据 port)

参考链接

最后

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

相关推荐
EasyNTS3 分钟前
H.265流媒体播放器EasyPlayer.js H.264/H.265播放器chrome无法访问更私有的地址是什么原因
javascript·h.265·h.264
AiFlutter6 分钟前
Flutter-Padding组件
前端·javascript·flutter
吾即是光21 分钟前
Xss挑战(跨脚本攻击)
前端·xss
渗透测试老鸟-九青21 分钟前
通过组合Self-XSS + CSRF得到存储型XSS
服务器·前端·javascript·数据库·ecmascript·xss·csrf
xcLeigh39 分钟前
HTML5实现俄罗斯方块小游戏
前端·html·html5
发现你走远了1 小时前
『VUE』27. 透传属性与inheritAttrs(详细图文注释)
前端·javascript·vue.js
VertexGeek1 小时前
Rust学习(五):泛型、trait
前端·学习·rust
好开心331 小时前
javaScript交互补充(元素的三大系列)
开发语言·前端·javascript·ecmascript
小孔_H2 小时前
Vue3 虚拟列表组件库 virtual-list-vue3 的使用
前端·javascript·学习·list
jokerest1232 小时前
web——upload-labs——第五关——大小写绕过绕过
前端·后端