Skip to content

WebSocket Protocol #43

@xwcoder

Description

@xwcoder

[toc]

Intro

WebSocket 是基于 TCP 的应用层协议。

A bit of history

WebSocket最初在HTML5规范中被引用为TCPConnection,作为基于TCP的套接字API的占位符。2008年6月,Michael Carter进行了一系列讨论,
最终形成了称为WebSocket的协议。

“WebSocket”这个名字是Ian Hickson和Michael Carter之后在 #whatwg IRC聊天室创造的[9],随后由Ian Hickson撰写并列入HTML5规范,
并在Michael Carter的Cometdaily博客上宣布[10]。 2009年12月,Google Chrome 4是第一个提供标准支持的浏览器,默认情况下启用了WebSocket。
WebSocket协议的开发随后于2010年2月从W3C和WHATWG小组转移到IETF,并在Ian Hickson的指导下进行了两次修订。

该协议被多个浏览器默认支持并启用后,RFC于2011年12月在Ian Fette下完成。
wikipedia

WebSocket 最初由 w3c 和whatwg 开发,最初被称为 TCPConnection, 后改名为 WebSocket. 2010 年 2 月协议开发工作转移到 ietf,
并于 2011 年 12 月完成开发,协议编号 rfc6455.

目标

"The Road to WebSockets" 详细介绍了 WebSocket 产生的背景。

WebSocket 的目标是在 web 平台约束下尽可能将原始 TCP (raw TCP) 暴露给 web,从而为 web 提供一种全双工的、实时、快速的通信方式,这点从
最初 TCPConnection 的名字能感受到。

URI

rfc6455 定义了两个 URI schemes.

ws-URI = "ws:" "//" host [ ":" port ] path [ "?" query ]
wss-URI = "wss:" "//" host [ ":" port ] path [ "?" query ]

ws 的默认端口是 80, wss 的默认端口号是 443.

Opening Handshake

WebSocket 的目的是要提供一个相对简单的协议,可以与 http 和 http 基础设施共存,从而可以复用 http 的基础设置,如代理、防火墙等。

WebSocket 使用 http GET 请求进行握手,之后就可以直接通过 tcp 通信。

请求

GET /chat HTTP/1.1
Host: server.example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat, superchat

客户端发送 http GET 请求进行握手请求。和握手相关的请求头:

  • Connection: UpgradeUpgrade: websocket 标识客户端想要进行协议升级,升级的协议是 websocket.
  • Sec-WebSocket-Version 指定需要的 websocket 版本。目前最新版本为 13.
  • Sec-WebSocket-Key 是 16 字节的随机数,然后经过 base64 编码。每次请求都是随机值。
    0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0a 0x0b 0x0c 0x0d 0x0e 0x0f 0x10 => AQIDBAUGBwgJCgsMDQ4PEC==
  • Sec-WebSocket-Protocol 是可选的,用于指定 client 端可以接受的子协议。
  • Sec-WebSocket-Extensions 是可选的, 用于指定 client 端可以使用的扩展。如压缩算法等。

目前 Chrome 支持两种扩展,即发送请求时会指定两种扩展:

sec-websocket-extensions: permessage-deflate; client_max_window_bits

permessage-deflate
: permessage-deflate 是一种 WebSocket 协议扩展,用于在客户端和服务器之间减少传输数据的大小,从而提高传输效率。
permessage-deflate 利用 Deflate 压缩算法对消息进行压缩。与 x-webkit-deflate-frame 不同,permessage-deflate 允许在消息级别进行压缩。

: Deflate 是一种广泛使用的无损数据压缩算法,结合了 LZ77 算法和哈夫曼编码。

client_max_window_bits
: 用于协商客户端和服务器之间可以使用的最大窗口大小。类似 tcp 协议中的发送窗口。

x-webkit-deflate-frame
: WebKit 引擎(主要用于 Safari 和旧版 Chrome 浏览器)使用的 WebSocket 扩展。
与 permessage-deflate 不同,x-webkit-deflate-frame 提供了帧级别的压缩,这意味着每个 WebSocket 帧都可以独立地进行压缩和解压缩。

VS Code WebSocket server不支持 client_max_window_bits,支持 permessage-deflatex-webkit-deflate-frame

// vs/server/node/RemoteExtensionHostAgentServer

const websocketExtensionOptions = Array.isArray(req.headers['sec-websocket-extensions']) ? req.headers['sec-websocket-extensions'] : [req.headers['sec-websocket-extensions']];
for (const websocketExtensionOption of websocketExtensionOptions) {
	if (/\b((server_max_window_bits)|(server_no_context_takeover)|(client_no_context_takeover))\b/.test(websocketExtensionOption)) {
		// sorry, the server does not support zlib parameter tweaks
		continue;
	}
	if (/\b(permessage-deflate)\b/.test(websocketExtensionOption)) {
		permessageDeflate = true;
		responseHeaders.push(`Sec-WebSocket-Extensions: permessage-deflate`);
		break;
	}
	if (/\b(x-webkit-deflate-frame)\b/.test(websocketExtensionOption)) {
		permessageDeflate = true;
		responseHeaders.push(`Sec-WebSocket-Extensions: x-webkit-deflate-frame`);
		break;
	}
}

所以对于 Chrome的请求, 其响应如下:

sec-websocket-extensions: permessage-deflate

响应

服务端需要对握手请求进行校验,如果不符合如下规则,需要返回 http 错误响应,如 400 Bad Request.

  1. Host 为合法的server.
  2. Upgrade 值为 "websocket",大小写不敏感。
  3. Connection 值为 "Upgrade",大小写不敏感。
  4. Sec-WebSocket-Key base64编码的值,解码后长度为 16 字节。
  5. Sec-WebSocket-Version 合法的 version 值。

如果校验通过并且接受此握手,需要向客户端返回状态码 101 的响应。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protoco: chat
  • Upgrade 值为 "websocket",大小写不敏感。
  • Connection 值为 "Upgrade",大小写不敏感。
  • Sec-WebSocket-Accept 值为 Sec-WebSocket-Key 拼接 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,
    然后使用 SHA-1 获取摘要,最后再进行 base64 编码。
  • Sec-WebSocket-Protocol 是可选的,服务端选择的子协议。
  • Sec-WebSocket-Extensions 是可选的, 服务端选择使用的扩展。

客户端收到响应后校验 Sec-WebSocket-Accept 是否与预期一致,如果一致则握手成功,连接建立。

Sec-WebSocket-Key 和 Sec-WebSocket-Accept 的作用

Sec-WebSocket-KeySec-WebSocket-Accept 用来确保基本的可靠性和安全性。

  • Sec-WebSocket-Key 可以防止缓存或者代理服务器对握手请求进行缓存。同时可以避免意外连接。
  • Sec-WebSocket-Accept 验证握手是合法的。

Subprotocols

握手阶段可以使用 Sec-WebSocket-Protocol 协商子协议。

Subprotocols 是 WebSocket 之上的应用层协议,用于指定两端使用的消息格式或通信协议,
可以是通用的消息格式或者组织自定义的格式,如 soap, mqtt 等。

举例

Slack 使用 WebSocket 连接来实现实时消息传递。在连接建立时,Slack 客户端和服务器会协商一个特定的 Subprotocol(如 slack-rpc),
以确保双方能够正确解析和处理消息。

const socket = new WebSocket('wss://slack.com/api/rtm.start', ['slack-rpc']);

Subprotocols 的设计初衷是为了提供更灵活、可扩展的通信方式,使得 WebSocket 连接能够适应不同的应用场景和需求。

使用代理的情况

如果客户端明确使用 proxy,那么客户端使用 http CONNECT method 指示 proxy 开启到目标服务器的隧道。

Frame Format

连接建立后,客户端和服务端就可以互相发送 message, 一条 message 由 1 个或多个 WebSocket frame 组成。

WebSocket 是基于 frame 的。

帧格式

 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 bit
: 1 表示此 frame 为 message 的最后一个分片, 0 表示不是最后一个分片。

RSV1, RSV2, RSV3: 1 bit each
: 必须为 0, 除非协商的扩展定义了非零值的含义。
如果接受到非零值,但是没有协商的扩展定义此值的含义,则连接错误。

Opcode: 4 bits
: 定义如何解析数据载荷(payload data), 即帧类型。可选值如下:
: - 0x0 Continuation Frame, 表示这是一个连续帧(continuation frame), 当 message 进行分片传输时,
除第 1 帧(文本帧或二进制帧)外,后续的帧都使用continuation frame.

  • 0x1 Text Frame, 表示这是一个文本帧,用于传输 utf-8 编码的文本数据。
  • 0x2 Binary Frame, 表示这是一个二进制帧,用于传输二进制数据。
  • 0x3-0x7 保留值,未来用于定义非控制帧。
  • 0x8 Close Frame, 表示这是一个关闭帧,用于请求关闭 WebSocket 连接。
  • 0x9 Ping Frame, 表示这是一个 Ping 帧,用于检测连接的活跃性。
    可以定期发送 Ping 帧,接收方收到后立即回复一个 Pong 帧,以确认连接的活跃性。
  • 0xA Pong Frame, 表示这是一个 Pong 帧,用于响应 Ping 帧。
  • 0xB-0xF 保留值,未来用于定义控制帧。

Mask: 1 bit
: 1 表示此帧的数据进行了掩码操作(mask)。

  • 客户端发送的帧必须掩码。
  • 服务端发送的帧不能掩码。
    : 当客户端收到一个进行了掩码的帧后必须关闭连接。当服务端收到一个没有进行掩码的帧后必须关闭连接。

Payload length: 7 bits, 7+16 bits, or 7+64 bits
: 表示 Payload data 的长度,单位为字节。
: - 如果前7 bits 的值为 0-125, 则其为Payload data 的长度。

  • 如果前7 bits 的值为 126, 则后面的两个字节被解析为 16 位无符号整数作为Payload data 的长度。
  • 如果前7 bits 的值为 127, 则后面的8个字节被解析为 64 位无符号整数(最高位必须为 0)作为Payload data 的长度。
  • 多个字节时使用网络字节序(network byte order), 一般为大端序(big-endian).
  • 必须使用最小字节数来编码长度,如 124 字节长度不能被编码为 126,0,124.

Masking-key: 0 or 4 bytes
: 当 Mask bit 为 1 时,则为用于掩码操作的 4 字节 key 值;
Mask bit 为 0 时,长度为 0, 即没有此值。

Payload data: (x+y) bytes
: 有效载荷,可能包括两部分内容:Extension dataApplication data.

Extension data: x bytes
: 扩展可以协商 Extension data 的长度,或者如何计算 Extension data 的长度。

Application data: y bytes
: 数据长度。

掩码 - Mask

客户端发送的帧必须掩码。服务端发送的帧不能掩码。
当客户端收到一个进行了掩码的帧后必须关闭连接。当服务端收到一个没有进行掩码的帧后必须关闭连接。

掩码操作不影响 Payload data 的长度,即原始数据的长度和掩码后数据的长度相同。

掩码算法如下:

Octet i of the transformed data ("transformed-octet-i") is the XOR of
octet i of the original data ("original-octet-i") with octet at index
i modulo 4 of the masking key ("masking-key-octet-j"):

j                   = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
  • transformed-octet-i: 转换后数据的第 i 字节。
  • original-octet-i: 原始数据的第 i 字节。
  • j: i MOD 4 的结果。取模。
  • masking-key-octet-j: Masking-key 的第 j 字节。
  • transformed-octet-i = original-octet-i XOR masking-key-octet-j:
    original-octet-imasking-key-octet-j 进行 XOR 操作得到转换后的字节。

Masking-key 是由客户端选择的 32 位随机数, 需要满足一定要求:

  • Masking-key 是不可预测的。服务器或者代理不能轻易的根据当前帧的 Masking-key 预测后续帧的 Masking-key.

对数据进行 masking 操作有如下作用:

  • 防止缓存污染。防止代理服务器对数据进行错误的缓存。

Fragmentation - 分片

协议允许一条 message 切分为多个帧进行传送。这样设计的目的主要有两个:

  • 当需要发送大 message 时,无需将 message 全部缓存到内存中来计算数据长度。这样也可以提高实时性。
  • 可以多路复用(multiplexing)。防止生产缓慢的大 message 阻塞通道。
    一个大 message 的多个 frame 的发送间隙可以发送其他 frame,包括 control frame.
    协议本身没有定义类似 message id 之类的语意来支持多路复用,
    可以使用 extension 实现,或者由业务来自己定义业务实现。

Control Frames

使用 opcodes 定义帧类型。目前定义了 3 种控制帧:0x8 (Close), 0x9 (Ping), and 0xA (Pong). 0xB-0xF 保留用于未来定义其他控制帧。

控制帧的 playload length 必须小于等于 125 字节,并且控制帧不能分片。

Close

opcodes0x8.

关闭帧可以包含 body (Application data) 用于说明关闭的原因。如果有 body, 前 2 个字节表示状态码(status code),
按网络字节序(大端序) 解析为无符号整数,status code 定义在Section 7.4. body 的其他部分为 utf-8 编码的文本数据。

客户端发送的 close frame 也需要进行掩码操作。

发送 close frame 后不能再发送数据帧。

如果收到 close frame, 并且之前没有发送过 close frame, 那么需要尽快发送 close frame 进行回应(通常发送收到的状态码进行回应)。
也可以延迟发送回应,比如将当前 message 发送完毕再回应,但是不能保证对方还能处理数据。

如果发送并且收到了close frame,则认为 WebSocket 连接已经关闭,必须关闭底层的 tcp 连接。服务端必须立即关闭 tcp 连接,
客户端应该等待服务端关闭连接,如果客户端已经发送并且收到了 WebSocket close frame,那么它也可以在任何时候关闭 tcp 连接,
比如经过一段时间仍未收到服务端的 tcp 关闭请求。

如果客户端和服务端同时发送了close frame,两端都将会发送和收到close frame,则认为 WebSocket 连接已关闭,那么继续执行关闭 tcp 连接。

Ping

opcodes0x9.

Ping frame 可以包含 Application data.

如果没有收到过 clsoe frame,那么收到 Ping frame 后必须发送 Pong frame 进行响应。Pong frame 应该尽快发送。

可以在连接建立之后关闭之前的任何时刻发送 Ping frame。

Pong

opcodes0xA.

Pong 帧需要携带与其对应的 Ping 帧相同的 Application data.

可以选择只响应最近收到的 Ping 帧。

可以主动发送Pong 帧,起到单向心跳的作用。不期待对 Pong 帧进行响应。

Data Frames

目前定义了两种数据帧: 0x1 (Text), 0x2 (Binary). 0x3-0x7 保留用于未来定义其他非控制帧。

Text
: "Payload data" 是 utf-8 编码的文本数据。

Binary
: 二进制数据。

Sending and Receiving Data

Sending Data

发送数据时必须执行如下步骤:

  1. 必须确保 WebSocket 连接是 OPEN 状态。任何时候状态改变,必须终止继续执行。
  2. 必须将数据封装为数据帧。如果数据过大, 或者在希望发送数据时数据并不完整,可以分片发送。
  3. 第一个数据帧的 opcode 必须设置正确的值用于标识数据是文本还是二进制。
  4. 最后一个数据帧的 FIN 必须设置为 1.
  5. 客户端发送数据时必须对数据进行掩码。
  6. 如果协商了使用扩展,需要应用扩展定义的操作。

Receiving Data

  • 数据必须按 WebSocket 帧进行解析
  • Extensions 可以改变数据的语意,即如何读取数据。
  • 服务端必须移除数据帧的客户端掩码。即解码获得原始数据。

Closing the Connection

关闭 WebSocket 连接

在大多数正常情况,tcp 连接应该由服务端关闭,以便服务端保持 TIME_WAIT 状态,
这会避免客户端在2MSL(maximum segment lifetimes, 最大段生存期)内重新打开连接而没有相应服务的情况
(TIME_WAIT 状态的连接在收到更高 seq 号的 SYN 帧时会立即重新打开)。

异常情况下客户端可以关闭 tcp 连接,如一段时间没有收到来自服务端的关闭请求。

状态码

如前所述,close frame 可以携带状态码和关闭原因。如果close frame没有状态码,则可以认为状态码是 1005.
如果 WebSocket 连接关闭但是没有收到close frame (比如 tcp 连接异常关闭), 则可以认为状态码是 1006.

Fail the WebSocket Connection

WebSocket 连接允许失败(如出现某种不能恢复的错误)。为此,客户端必须关闭 WebSocket 连接,并且可以以适当方式报告给用户。
同样的,服务端必须关闭 WebSocket 连接,并且应该记录问题(log the problem)。

如果失败之前 WebSocket 连接已经建立,应该在继续关闭 WebSocket 连接之前发送一个带有适当状态码的关闭帧。
如果认为由于导致 WebSocket 连接失败的错误使另一方不太可能能够接收和处理关闭帧,那么可以省略发送关闭帧。
在被指示 WebSocket 连接失败之后,不得继续尝试处理来自远端的数据(包括关闭帧)。
除了上述情况或应用层指定的情况外,客户端不应该关闭连接。

异常关闭

客户端发起的关闭

某些算法,特别是在握手阶段要求 WebSocket 连接失败(比如握手请求头校验不通过)。

任何时候 tcp 连接异常丢失,客户端都需要令 WebSocket 连接失败(Fail the WebSocket Connection)。

除了上述情况或应用层指定的情况外,客户端不应该关闭连接。

服务端发起的关闭

某些算法要求或建议服务端在握手时中止 WebSocket 连接(比如握手请求头校验不通过)。为此,服务端必须简单地关闭 WebSocket 连接。

异常关闭恢复 - Recovering from Abnormal Closure

造成异常关闭的原因可能有很多。异常关闭可能由某些短暂错误引起,这种情况下 重连(reconnecting) 可能建立正常的连接并恢复正常操作。
异常关闭也可能由某些非短暂错误引起,这种情况下,如果每个客户端都异常关闭并且立即且持续重连,那么服务端会遭受DoS(Denial-of-service, 决绝服务)攻击。

为避免这种情况,客户端重连需要使用某种退避算法(backoff)。

客户端应该随机延迟一段时间后再进行第一次重连。具体随机延迟多长时间由客户端决定,通常0到5秒是比较合理的。

如果第一次重连失败,那么后续使用类似truncated binary exponential backoff (截断二进制指数退避)1 的退避算法延长重连时间。

Footnotes

  1. 见: network/Data Link Layer/Multiple Access Protocols/Carrier Sense Multiple Access (CSMA) - 载波监听多点接入

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions