WebSocket
浏览器 API 与协议,第 17 章
WebSocket 实现了客户端与服务器之间双向、面向消息的文本与二进制数据流。它是浏览器中最接近原始网络套接字的 API。但 WebSocket 连接远不止是一个网络套接字——浏览器将所有复杂性抽象为一个简洁的 API,并提供多项附加服务:
- 连接协商与同源策略强制执行
- 与现有 HTTP 基础设施的互操作性
- 面向消息的通信与高效的消息分帧
- 子协议协商与可扩展性
WebSocket 是浏览器中最通用、最灵活的传输方式之一。其简洁极简的 API 使我们能够以流式方式在客户端和服务器之间分层传递任意应用协议——从简单的 JSON 负载到自定义二进制消息格式——任何一方都可以随时发送数据。
然而,自定义协议的代价就是它们确实是”自定义”的。应用必须自行处理状态管理、压缩、缓存等通常由浏览器提供的服务。设计约束和性能权衡始终存在,使用 WebSocket 也不例外。简而言之,WebSocket 不是 HTTP、XHR 或 SSE 的替代品,为了获得最佳性能,我们必须充分利用每种传输方式的优势。
WebSocket 由多项标准组成:WebSocket API 由 W3C 定义,WebSocket 协议(RFC 6455)及其扩展由 IETF 的 HyBi 工作组定义。
WebSocket API
浏览器提供的 WebSocket API 非常精简。所有连接管理和消息处理的底层细节都由浏览器负责。要发起新连接,我们需要 WebSocket 资源的 URL 和几个应用回调:
var ws = new WebSocket('wss://example.com/socket');
ws.onerror = function (error) { ... }
ws.onclose = function () { ... }
ws.onopen = function () {
ws.send("Connection established. Hello server!");
}
ws.onmessage = function(msg) {
if(msg.data instanceof Blob) {
processBlob(msg.data);
} else {
processText(msg.data);
}
}
- 打开新的安全 WebSocket 连接(wss)
- 可选回调,连接错误时触发
- 可选回调,连接终止时触发
- 可选回调,WebSocket 连接建立时触发
- 客户端向服务器发送消息
- 每次收到服务器新消息时触发的回调函数
- 根据接收到的消息调用二进制或文本处理逻辑
这个 API 不言自明。事实上,它看起来与上一章的 EventSource API 非常相似。这是有意为之,因为 WebSocket 提供了类似且扩展的功能。不过,也有一些重要区别,我们逐一来看。
WebSocket 兼容性
WebSocket 协议经历了多次修订、实现回滚和安全审查。好消息是,RFC 6455 定义的最新版本(v13)现已获得所有现代浏览器的支持。截至 2024 年,WebSocket 已在所有主流浏览器(包括 Chrome、Firefox、Safari、Edge)以及 Android 和 iOS 平台得到全面支持。 最新兼容性状态可参考 Can I use WebSocket。
与 SSE 的 polyfill 策略类似,WebSocket 浏览器 API 可以通过可选的 JavaScript 库进行模拟。然而,模拟 WebSocket 的难点不在于 API,而在于传输层!因此,polyfill 库的选择及其回退传输方式(XHR 轮询、EventSource、iframe 轮询等)将对模拟 WebSocket 会话的性能产生重大影响。
为简化跨浏览器部署,SockJS 等流行库在浏览器中提供了类似 WebSocket 的对象实现,更进一步提供了支持 WebSocket 和多种替代传输的自定义服务器。自定义服务器与客户端的组合实现了”无缝回退”:性能可能下降,但应用 API 保持不变。
Socket.IO 等其他库则更进一步,除了多传输回退功能外,还实现了心跳、超时、自动重连等附加功能。
在选择 polyfill 库或”实时框架”(如 Socket.IO)时,请密切关注客户端和服务器的底层实现和配置:始终优先使用原生 WebSocket 接口以获得最佳性能,并确保回退传输满足您的性能目标。
WS 与 WSS URL 方案
WebSocket 资源 URL 使用自定义方案:纯文本通信用 ws(如 ws://example.com/socket),需要加密通道(TCP+TLS)时用 wss。为什么要用自定义方案,而不是熟悉的 http?
WebSocket 协议的主要用例是为浏览器中运行的应用与服务器之间提供优化的双向通信通道。然而,WebSocket 线协议也可以在浏览器外使用,并可通过非 HTTP 交换进行协商。因此,HyBi 工作组选择采用自定义 URL 方案。
尽管自定义方案支持非 HTTP 协商选项,但实际上目前尚无建立 WebSocket 会话的替代握手机制标准。
接收文本与二进制数据
WebSocket 通信由消息组成,应用代码无需担心缓冲、解析和重建接收到的数据。例如,如果服务器发送 1 MB 的负载,应用的 onmessage 回调只有在整个消息在客户端可用时才会被调用。
此外,WebSocket 协议对应用负载不做任何假设,也不施加限制:文本和二进制数据都可以。在内部,协议只跟踪消息的两个信息:作为变长字段的负载长度,以及用于区分 UTF-8 和二进制传输的负载类型。
当浏览器收到新消息时,文本数据会自动转换为 DOMString 对象,二进制数据转换为 Blob 对象,然后直接传递给应用。唯一的其他选项(作为性能提示和客户端优化)是告诉浏览器将接收到的二进制数据转换为 ArrayBuffer 而非 Blob:
var ws = new WebSocket('wss://example.com/socket');
ws.binaryType = "arraybuffer";
ws.onmessage = function(msg) {
if(msg.data instanceof ArrayBuffer) {
processArrayBuffer(msg.data);
} else {
processText(msg.data);
}
}
- 收到二进制消息时强制转换为 ArrayBuffer
用户代理可以将此作为处理传入二进制数据的提示:如果属性设置为 “blob”,可以安全地将其写入磁盘;如果设置为 “arraybuffer”,将数据保留在内存中可能更有效。当然,鼓励用户代理使用更精细的启发式方法来决定是否将传入数据保留在内存中…
Blob 对象表示不可变的原始数据的类文件对象。如果您不需要修改数据,也不需要将其分割成更小的块,那么它是最优格式——例如,您可以将整个 Blob 对象传递给图像标签。另一方面,如果您需要对二进制数据进行额外处理,那么 ArrayBuffer 可能更合适。
用 JavaScript 解码二进制数据
ArrayBuffer 是一个通用的固定长度二进制数据缓冲区。然而,ArrayBuffer 可用于创建一个或多个 ArrayBufferView 对象,每个对象都可以以特定格式呈现缓冲区内容。例如,假设我们有以下类似 C 的二进制数据结构:
struct someStruct {
char username[16];
unsigned short id;
float scores[32];
};
给定此类型的 ArrayBuffer 对象,我们可以创建同一缓冲区的多个视图,每个视图都有自己的偏移量和数据类型:
var buffer = msg.data;
var usernameView = new Uint8Array(buffer, 0, 16);
var idView = new Uint16Array(buffer, 16, 1);
var scoresView = new Float32Array(buffer, 18, 32);
console.log("ID: " + idView[0] + " username: " + usernameView[0]);
for (var j = 0; j < 32; j++) { console.log(scoresView[j]) }
每个视图接收父缓冲区、起始字节偏移量和要处理的元素数量——偏移量根据前面字段的大小计算。因此,ArrayBuffer 和 WebSocket 为我们的应用提供了在浏览器中流式传输和处理二进制数据的所有必要工具。
发送文本与二进制数据
WebSocket 连接建立后,客户端可以随时发送和接收 UTF-8 和二进制消息。WebSocket 提供双向通信通道,允许通过同一 TCP 连接双向传递消息:
var ws = new WebSocket('wss://example.com/socket');
ws.onopen = function () {
socket.send("Hello server!");
socket.send(JSON.stringify({'msg': 'payload'}));
var buffer = new ArrayBuffer(128);
socket.send(buffer);
var intview = new Uint32Array(buffer);
socket.send(intview);
var blob = new Blob([buffer]);
socket.send(blob);
}
- 发送 UTF-8 编码的文本消息
- 发送 UTF-8 编码的 JSON 负载
- 发送 ArrayBuffer 内容作为二进制负载
- 发送 ArrayBufferView 内容作为二进制负载
- 发送 Blob 内容作为二进制负载
WebSocket API 接受 DOMString 对象(在线路上编码为 UTF-8),或 ArrayBuffer、ArrayBufferView 或 Blob 对象用于二进制传输。但请注意,后者只是 API 便利:在线路上,WebSocket 帧通过单个位标记为二进制或文本。因此,如果应用或服务器需要关于负载的其他内容类型信息,必须使用额外机制来通信此数据。
send() 方法是异步的:提供的数据由客户端排队,函数立即返回。因此,特别是在传输大负载时,不要将快速返回误认为数据已发送的信号!要监控浏览器排队的数据量,应用可以查询套接字上的 bufferedAmount 属性:
var ws = new WebSocket('wss://example.com/socket');
ws.onopen = function () {
subscribeToApplicationUpdates(function(evt) {
if (ws.bufferedAmount == 0)
ws.send(evt.data);
});
};
- 订阅应用更新(如游戏状态变化)
- 检查客户端缓冲数据量
- 如果缓冲区为空则发送下一个更新
前面的示例尝试向服务器发送应用更新,但仅在前面的消息已从客户端缓冲区排空时。为什么要进行这样的检查?所有 WebSocket 消息都按照客户端排队的确切顺序传递。因此,大量排队的消息积压,甚至单个大消息,都会延迟其后排队消息的传递——队头阻塞!
为解决这个问题,应用可以将大消息分割成较小的块,仔细监控 bufferedAmount 值以避免队头阻塞,甚至为待处理消息实现自己的优先级队列,而不是盲目地将它们全部排队到套接字上。
许多应用生成多类消息:高优先级更新(如控制流量)和低优先级更新(如后台传输)。为优化传递,应用应密切关注每种消息类型在套接字上排队的方式和时间!
子协议协商
WebSocket 协议不对每条消息的格式做假设:单个位跟踪消息包含文本还是二进制数据,以便客户端和服务器可以高效解码,但除此之外消息内容是不透明的。
此外,与通过每个请求和响应的 HTTP 头通信额外元数据的 HTTP 或 XHR 请求不同,WebSocket 消息没有这样的等效机制。因此,如果需要关于消息的额外元数据,客户端和服务器必须同意实现自己的子协议来通信此数据:
- 客户端和服务器可以预先同意固定的消息格式——例如,所有通信都通过 JSON 编码消息或自定义二进制格式完成,必要的消息元数据将是编码结构的一部分。
- 如果客户端和服务器需要传输不同类型的数据,它们可以同意一致的消息头,可用于通信解码剩余负载的指令。
- 可以使用文本和二进制消息的混合来通信负载和元数据信息——例如,文本消息可以通信等效于 HTTP 头的信息,后跟带有应用负载的二进制消息。
此列表只是可能策略的一小部分。WebSocket 消息的灵活性和低开销以额外的应用逻辑为代价。然而,消息序列化和元数据管理只是问题的一部分!一旦确定了消息的序列化格式,我们如何确保客户端和服务器相互理解,如何保持它们同步?
幸运的是,WebSocket 提供了一个简单方便的子协议协商 API 来解决第二个问题。客户端可以在初始连接握手时向服务器通告它支持的协议:
var ws = new WebSocket('wss://example.com/socket',
['appProtocol', 'appProtocol-v2']);
ws.onopen = function () {
if (ws.protocol == 'appProtocol-v2') {
...
} else {
...
}
}
- WebSocket 握手期间通告的子协议数组
- 检查服务器选择的子协议
如前面的示例所示,WebSocket 构造函数接受一个可选的子协议名称数组,允许客户端通告它理解或愿意用于此连接的协议列表。指定的列表被发送到服务器,服务器被允许选择客户端通告的协议之一。
如果子协议协商成功,则在客户端触发 onopen 回调,应用可以查询 WebSocket 对象上的 protocol 属性以确定选择的协议。另一方面,如果服务器不支持客户端通告的任何协议,则 WebSocket 握手不完整:调用 onerror 回调,连接被终止。
子协议名称由应用定义,并在初始 HTTP 握手期间按指定发送到服务器。除此之外,指定的子协议对核心 WebSocket API 没有影响。
WebSocket 协议
由 HyBi 工作组开发的 WebSocket 线协议(RFC 6455)由两个高级组件组成:用于协商连接参数的初始 HTTP 握手,以及允许低开销、基于消息的文本和二进制数据传递的二进制消息分帧机制。
“WebSocket 协议试图在现有 HTTP 基础设施的背景下解决现有双向 HTTP 技术的目标;因此,它被设计为通过 HTTP 端口 80 和 443 工作… 然而,该设计并不将 WebSocket 限制为 HTTP,未来的实现可以使用专用端口上的更简单握手,而无需重新发明整个协议。” —— WebSocket 协议,RFC 6455
WebSocket 协议是一个功能完整的独立协议,可以在浏览器外使用。话虽如此,其主要应用是作为基于浏览器的应用的双向传输。
二进制分帧层
客户端和服务器 WebSocket 应用通过面向消息的 API 通信:发送者提供任意 UTF-8 或二进制负载,接收者在整个消息可用时收到传递通知。为实现此功能,WebSocket 使用自定义二进制分帧格式,将每个应用消息分割为一个或多个帧,传输到目的地,重新组装,最后在整个消息收到后通知接收者。
图 17-1. WebSocket 帧:2–14 字节 + 负载
- 帧:通信的最小单位,每个包含变长帧头和可能携带应用消息全部或部分的负载。
- 消息:映射到逻辑应用消息的完整帧序列。
将应用消息分割为多个帧的决定由客户端和服务器分帧代码的底层实现做出。因此,应用完全不知道单个 WebSocket 帧或分帧的执行方式。话虽如此,了解每个 WebSocket 帧在线路上的表示方式仍然有用:
- 每帧的第一位(FIN)指示该帧是否是消息的最终片段。消息可能只由单个帧组成。
- 操作码(4 位)指示传输帧的类型:文本(1)或二进制(2)用于传输应用数据,或控制帧如连接关闭(8)、ping(9)和 pong(10)用于连接活性检查。
- 掩码位指示负载是否被掩码(仅适用于从客户端发送到服务器的消息)。
- 负载长度表示为变长字段:
- 如果为 0–125,则该值即为负载长度。
- 如果为 126,则后续 2 字节表示指示帧长度的 16 位无符号整数。
- 如果为 127,则后续 8 字节表示指示帧长度的 64 位无符号整数。
- 掩码键包含用于掩码负载的 32 位值。
- 负载包含应用数据,如果客户端和服务器在连接建立时协商了扩展,则还包含自定义扩展数据。
所有客户端发起的帧的负载都使用帧头中指定的值进行掩码:这防止在客户端上执行的恶意脚本对可能不理解 WebSocket 协议的中介执行缓存中毒攻击。有关此攻击的完整细节,请参阅 2011 年 W2SP 上呈现的”Talking to Yourself for Fun and Profit”。
因此,每个服务器发送的 WebSocket 帧产生 2–10 字节的分帧开销。客户端还必须发送掩码键,这在头上额外增加 4 字节,导致 6–14 字节的开销。没有其他元数据可用,如头字段或关于负载的其他信息:所有 WebSocket 通信都通过交换将负载视为不透明应用数据 blob 的帧来执行。
WebSocket 多路复用与队头阻塞
WebSocket 容易受到队头阻塞的影响:消息可以分割为一个或多个帧,但不同消息的帧不能交错,因为没有等效于 HTTP/2 分帧机制中的”流 ID”(参见流、消息和帧)。
因此,大消息(即使分割为多个 WebSocket 帧)会阻塞与其他消息关联的帧的传递。如果您的应用传递延迟敏感数据,请小心每条消息的负载大小,并考虑将大消息分割为多个应用消息!
核心 WebSocket 规范中缺乏多路复用也意味着每个 WebSocket 连接需要专用 TCP 连接,这可能成为 HTTP/1.x 部署的潜在问题,因为浏览器对每个来源维护的连接数量有限制(参见耗尽客户端和服务器资源)。
好消息是,HTTP/2 和 HTTP/3 的出现改变了这一局面。 虽然 WebSocket 最初设计为通过 HTTP/1.1 升级,但现代部署越来越多地利用 HTTP/2 的连接管理和多路复用能力。RFC 8441(2018 年发布)标准化了通过 HTTP/2 启动 WebSocket 的方法,允许 WebSocket 流与 HTTP/2 流共享同一 TCP 连接,显著提高了连接效率。
对于 HTTP/2,内置的流多路复用允许多个 WebSocket 连接在单个会话中传输,通过将 WebSocket 帧封装在 HTTP/2 分帧机制中。这解决了队头阻塞问题,因为 HTTP/2 的流多路复用允许交错传输不同流的数据。
协议扩展
WebSocket 规范允许协议扩展:WebSocket 协议的线格式和语义可以通过新的操作码和数据字段进行扩展。虽然有点不寻常,但这是一个非常强大的功能,因为它允许客户端和服务器在基础 WebSocket 分帧层之上实现附加功能,而无需应用代码的任何干预或配合。
WebSocket 协议扩展的一些例子是什么?负责 WebSocket 规范开发的 HyBi 工作组列出了两个正在开发的官方扩展:
“WebSocket 多路复用扩展”
- 此扩展提供了一种让独立逻辑 WebSocket 连接共享底层传输连接的方法。
“WebSocket 压缩扩展”
- 用于创建为 WebSocket 协议添加压缩功能的 WebSocket 扩展的框架。
正如我们前面提到的,每个 WebSocket 连接需要专用 TCP 连接,这是低效的。多路复用扩展通过为每个 WebSocket 帧扩展额外的”通道 ID”来解决此问题,允许多个虚拟 WebSocket 通道共享单个 TCP 连接。
同样,基础 WebSocket 规范没有为传输数据的压缩提供机制或规定:每个帧携带应用提供的负载数据。因此,虽然这对优化的二进制数据结构可能不是问题,但除非应用实现自己的压缩和解压缩逻辑,否则这可能导致高字节传输开销。实际上,压缩扩展启用了 HTTP 提供的传输编码协商的等效功能。
为启用一个或多个扩展,客户端必须在初始升级握手中通告它们,服务器必须选择并确认将用于协商连接生命周期的扩展。对于一个实践示例,让我们仔细看看升级序列。
WebSocket 多路复用与压缩现状
截至 2024 年,情况已大幅改善。 WebSocket 多路复用已通过 HTTP/2 的 WebSocket 扩展(RFC 8441)得到支持,现代浏览器(Chrome、Firefox、Safari、Edge)均已实现此功能。这意味着多个 WebSocket 连接可以在单个 HTTP/2 连接上多路复用,显著提高了连接效率。
关于压缩,permessage-deflate 扩展(RFC 7692)现已获得广泛支持。该扩展提供基于消息的压缩,比早期的 per-frame 压缩更高效。Chrome、Firefox、Safari 和 Edge 均支持此扩展,可以显著减少文本数据的传输大小。
然而,应用仍应密切关注传输数据的内容类型,并在适用时应用自己的压缩。即,至少在原生 WebSocket 压缩支持在所有流行浏览器中广泛可用之前。这对于移动应用尤其重要,其中每个不必要的字节都会给用户带来高昂成本。
HTTP 升级协商
WebSocket 协议提供了许多强大功能:面向消息的通信、自己的二进制分帧层、子协议协商、可选的协议扩展等。因此,在交换任何消息之前,客户端和服务器必须协商适当的参数以建立连接。
利用 HTTP 执行握手有几个优势。首先,它使 WebSocket 与现有 HTTP 基础设施兼容:WebSocket 服务器可以在端口 80 和 443 上运行,这些通常是客户端唯一开放的端口。其次,它允许我们重用和扩展 HTTP 升级流程,使用自定义 WebSocket 头进行协商:
- Sec-WebSocket-Version:客户端发送以指示要使用的 WebSocket 协议版本(RFC 6455 为 “13”)。如果服务器不支持客户端版本,则必须回复支持的版本列表。
- Sec-WebSocket-Key:客户端发送的自动生成密钥,作为对服务器的”挑战”,证明服务器支持请求的协议版本。
- Sec-WebSocket-Accept:服务器响应,包含 Sec-WebSocket-Key 的签名值,证明它理解请求的协议版本。
- Sec-WebSocket-Protocol:用于协商应用子协议:客户端通告支持的协议列表;服务器必须回复单个协议名称。
- Sec-WebSocket-Extensions:用于协商此连接要使用的 WebSocket 扩展:客户端通告支持的扩展,服务器通过返回相同头来确认一个或多个扩展。
有了这些,我们现在拥有了执行 HTTP 升级和协商客户端与服务器之间新 WebSocket 连接的所有必要部分:
GET /socket HTTP/1.1
Host: thirdparty.com
Origin: http://example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: appProtocol, appProtocol-v2
Sec-WebSocket-Extensions: permessage-deflate, x-custom-extension
- 请求升级到 WebSocket 协议
- 客户端使用的 WebSocket 协议版本
- 自动生成密钥以验证服务器协议支持
- 应用指定的可选子协议列表
- 客户端支持的可选协议扩展列表
与浏览器中任何其他客户端发起的连接一样,WebSocket 请求受同源策略约束:浏览器自动将 Origin 头附加到升级握手,远程服务器可以使用 CORS 接受或拒绝跨源请求(参见跨源资源共享 CORS)。为完成握手,服务器必须返回成功的”Switching Protocols”响应并确认客户端通告的选定选项:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Access-Control-Allow-Origin: http://example.com
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: appProtocol-v2
Sec-WebSocket-Extensions: permessage-deflate
- 101 响应代码确认 WebSocket 升级
- CORS 头表示选择加入跨源连接
- 签名密钥值证明协议支持
- 服务器选择的应用子协议
- 服务器选择的 WebSocket 扩展列表
所有兼容 RFC 6455 的 WebSocket 服务器使用相同算法计算对客户端挑战的回答:Sec-WebSocket-Key 的内容与标准中定义的唯一 GUID 字符串连接,计算 SHA1 哈希,结果字符串进行 base-64 编码并发送回客户端。
至少,成功的 WebSocket 握手必须包含协议版本和客户端发送的自动生成挑战值,后跟服务器的 101 HTTP 响应代码(Switching Protocols)和哈希挑战响应以确认选择的协议版本:
- 客户端必须发送 Sec-WebSocket-Version 和 Sec-WebSocket-Key。
- 服务器必须通过返回 Sec-WebSocket-Accept 确认协议。
- 客户端可以通过 Sec-WebSocket-Protocol 发送应用子协议列表。
- 服务器必须通过 Sec-WebSocket-Protocol 选择通告的子协议之一并返回。如果服务器不支持任何子协议,则连接中止。
- 客户端可以在 Sec-WebSocket-Extensions 中发送协议扩展列表。
- 服务器可以通过 Sec-WebSocket-Extensions 确认一个或多个选择的扩展。如果没有提供扩展,则连接在没有扩展的情况下继续。
最后,一旦前面的握手完成且成功,连接现在可以用作交换 WebSocket 消息的双向通信通道。从此,客户端和服务器之间没有其他显式 HTTP 通信,WebSocket 协议接管。
代理、中介与 WebSocket
实际上,出于安全和策略原因,许多用户只有受限的开放端口集——特别是端口 80(HTTP)和端口 443(HTTPS)。因此,WebSocket 协商通过 HTTP 升级流程执行,以确保与现有网络策略和基础设施的最佳兼容性。
然而,正如我们前面在代理、中介、TLS 和 Web 新协议中提到的,许多现有 HTTP 中介可能不理解新的 WebSocket 协议,这可能导致各种故障情况:盲目连接升级、WebSocket 帧的意外缓冲、不理解协议的内容修改、将 WebSocket 流量误分类为受损 HTTP 连接等。
WebSocket Key 和 Accept 握手解决了其中一些问题:它是对可能盲目”升级”连接而不实际理解 WebSocket 协议的服务器和中介的安全策略。然而,虽然此预防措施解决了显式代理的一些部署问题,但对于可能在不通知的情况下分析和修改线路上数据的”透明代理”仍然不足。
解决方法?建立安全端到端隧道——即使用 WSS!通过在执行 HTTP 升级握手之前协商 TLS 会话,客户端和服务器建立加密隧道,解决了前面列出的所有问题。这对于移动客户端尤其重要,其流量通常通过可能与 WebSocket 不兼容的各种代理服务。
使用 TLS 并不能阻止中介对空闲 TCP 连接超时。然而,在实践中,它显著提高了协商 WebSocket 会话的成功率,通常还有助于延长连接超时间隔。
WebSocket 应用场景与性能
WebSocket API 为客户端和服务器之间的双向、面向消息的文本和二进制数据流提供了简单的接口:向构造函数传入 WebSocket URL,设置几个 JavaScript 回调函数,我们就启动并运行了——其余由浏览器处理。再加上提供二进制分帧、可扩展性和子协议协商的 WebSocket 协议,WebSocket 成为在浏览器中传递自定义应用协议的理想选择。
然而,正如任何关于性能的讨论一样,虽然 WebSocket 协议的实现复杂性对应用隐藏,但它对如何以及何时使用 WebSocket 仍有重要的性能影响。WebSocket 不是 XHR 或 SSE 的替代品,为了获得最佳性能,我们必须充分利用每种传输的优势!
有关每种传输的性能特征回顾,请参阅 XHR 应用场景与性能和 SSE 应用场景与性能。
请求与响应流
WebSocket 是唯一允许通过同一 TCP 连接进行双向通信的传输方式:客户端和服务器可以随意交换消息。因此,WebSocket 提供文本和二进制应用数据双向的低延迟传递。
- XHR 针对”事务性”请求-响应通信优化:客户端向服务器发送完整、格式良好的 HTTP 请求,服务器响应完整响应。直到 Streams API 可用之前,没有请求流支持,也没有可靠的跨浏览器响应流 API。
- SSE 实现高效的、低延迟的服务器到客户端文本数据流:客户端发起 SSE 连接,服务器使用事件源协议向客户端流式传输更新。初始握手后,客户端不能向服务器发送任何数据。
传播与排队延迟
将传输从 XHR 切换到 SSE 或 WebSocket 不会减少客户端和服务器之间的往返时间!无论传输如何,数据包的传播延迟相同。然而,除了传播延迟,还有排队延迟:消息在可以路由到另一方之前在客户端或服务器上必须等待的时间。
在 XHR 轮询的情况下,排队延迟是客户端轮询间隔的函数:消息可能在服务器上可用,但在下一个客户端 XHR 请求之前无法发送(参见 XHR 轮询性能建模)。相比之下,SSE 和 WebSocket 都使用持久连接,允许服务器(在 WebSocket 情况下还包括客户端)在消息可用时立即分派消息。
因此,SSE 和 WebSocket 的”低延迟传递”特指消除消息排队延迟。我们尚未想出如何让 WebSocket 数据包比光速传播得更快!
消息开销
WebSocket 连接建立后,客户端和服务器通过 WebSocket 协议交换数据:应用消息被分割为一个或多个帧,每个帧增加 2 到 14 字节的开销。此外,由于分帧通过自定义二进制格式完成,UTF-8 和二进制应用数据都可以通过相同机制高效编码。这与 XHR 和 SSE 相比如何?
- SSE 每条消息最少增加 5 字节,但仅限于 UTF-8 内容(参见事件流协议)。
- HTTP/1.x 请求(XHR 或其他)将携带额外的 500–800 字节 HTTP 元数据,加上 cookie(参见测量和控制协议开销)。
- HTTP/2 和 HTTP/3 压缩 HTTP 元数据,显著减少开销(参见头压缩)。事实上,如果头在请求之间没有变化,开销可以低至 8 字节!
请记住,这些开销数字不包括 IP、TCP 和 TLS 分帧的开销,无论应用协议如何,每条消息增加 60–100 字节的组合开销(参见优化 TLS 记录大小)。
数据效率与压缩
每个 XHR 请求可以通过常规 HTTP 协商协商最佳传输编码格式(如文本数据的 gzip)。同样,由于 SSE 仅限于 UTF-8 传输,可以通过对整个会话应用 gzip 高效压缩事件流数据。
使用 WebSocket,情况更复杂:WebSocket 可以传输文本和二进制数据,因此压缩整个会话没有意义。二进制负载可能已经压缩!因此,WebSocket 必须实现自己的压缩机制并选择性地应用于每条消息。
好消息是 RFC 7692 标准化的 permessage-deflate 扩展现已获得广泛浏览器支持。 该扩展提供基于消息的压缩,比早期的 per-frame 压缩更高效。截至 2024 年,所有主流现代浏览器(Chrome、Firefox、Safari、Edge)都支持此扩展。
然而,应用仍应仔细优化其二进制负载(参见用 JavaScript 解码二进制数据)并为基于文本的消息实现自己的压缩逻辑,直到原生 WebSocket 压缩支持在所有流行浏览器中广泛可用。这对于移动应用尤其重要,其中每个不必要的字节都会给用户带来高昂成本。
自定义应用协议
浏览器针对 HTTP 数据传输进行了优化:它理解协议,并提供广泛的服务阵列,如认证、缓存、压缩等。因此,XHR 请求免费继承所有这些功能。
相比之下,流式传输允许我们在客户端和服务器之间传递自定义协议,但代价是绕过浏览器提供的许多服务:初始 HTTP 握手可能能够执行一些连接参数协商,但一旦会话建立,客户端和服务器之间流式传输的所有进一步数据对浏览器都是不透明的。因此,传递自定义协议的灵活性也有其缺点,应用可能必须实现自己的逻辑来填补缺失的空白:缓存、状态管理、消息元数据传递等!
初始 HTTP 升级握手确实允许服务器利用现有 HTTP cookie 机制验证用户。如果验证失败,服务器可以拒绝 WebSocket 升级。
利用浏览器和中介缓存
使用常规 HTTP 有显著优势。问自己一个简单问题:客户端是否从缓存接收的数据中受益?或者如果中介可以缓存它,是否能优化资产的传递?
例如,WebSocket 支持二进制传输,允许应用以无开销的方式流式传输任意图像格式——不错的胜利!然而,图像在自定义协议中传递的事实意味着它不会被浏览器缓存或任何中介(如 CDN)缓存。因此,您可能产生对客户端的不必要传输和对源服务器的更高流量。相同的逻辑适用于所有其他数据格式:视频、文本等。
因此,确保为工作选择正确的传输!解决这些问题的一个简单但有效的策略可能是使用 WebSocket 会话传递不可缓存的数据,如实时更新和应用”控制”消息,这可以触发 XHR 请求通过 HTTP 协议获取其他资产。
WebSocket 基础设施部署
HTTP 针对短而突发的传输进行了优化。因此,许多服务器、代理和其他中介通常配置为对空闲 HTTP 连接进行积极的超时,这当然是我们不希望看到的长期 WebSocket 会话。为解决此问题,有三个方面需要考虑:
- 自己网络内的路由器、负载均衡器和代理
- 外部网络中的透明和显式代理(如 ISP 和运营商代理)
- 客户端网络内的路由器、防火墙和代理
我们无法控制客户端网络的策略。事实上,某些网络可能完全阻止 WebSocket 流量,这就是为什么您可能需要回退策略。同样,我们无法控制外部网络上的代理。然而,TLS 可能在这里有帮助!通过安全端到端连接隧道传输,WebSocket 流量可以绕过所有中间代理。
使用 TLS 并不能阻止中介对空闲 TCP 连接超时。然而,在实践中,它显著提高了协商 WebSocket 会话的成功率,通常还有助于延长连接超时间隔。
最后,还有我们自己部署和管理的基础设施,这也通常需要关注和调整。虽然很容易责怪客户端或外部网络,但问题往往就在身边。服务路径中的每个负载均衡器、路由器、代理和 Web 服务器都必须调整为允许长期连接。
例如,Nginx 1.3.13+ 可以代理 WebSocket 流量,但默认使用激进的 60 秒超时!要增加限制,我们必须显式定义更长的超时:
location /websocket {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600;
proxy_send_timeout 3600;
}
- 设置读取之间 60 分钟超时
- 设置写入之间 60 分钟超时
同样,在一个或多个 Nginx 服务器前拥有负载均衡器(如 HAProxy)并不罕见。毫不奇怪,我们在这里也需要应用类似的显式配置——例如,对于 HAProxy:
defaults http
timeout connect 30s
timeout client 30s
timeout server 30s
timeout tunnel 1h
- 隧道的 60 分钟不活动超时
前面示例的陷阱是额外的”tunnel”超时。在 HAProxy 中,connect、client 和 server 超时仅应用于初始 HTTP 升级握手,但一旦升级完成,超时由 tunnel 值控制。
Nginx 和 HAProxy 只是运行在我们数据中心的数百种不同服务器、代理和负载均衡器中的两个。我们无法在这些页面中列举所有配置可能性。前面的示例只是说明大多数基础设施需要自定义配置来处理长期会话。因此,在实现应用 keepalive 之前,先仔细检查您的基础设施。
长期和空闲会话在所有中间服务器上占用内存和套接字资源。因此,短超时通常作为安全、资源和运营预防措施是合理的。部署 WebSocket、SSE 和 HTTP/2(每种都依赖长期会话)带来了各自的新运营挑战类别。
性能检查清单
部署高性能 WebSocket 服务需要在客户端和服务器上进行仔细的调优和考虑。议程上的简短标准列表:
- 使用安全 WebSocket(基于 TLS 的 WSS)进行可靠的部署。
- 密切关注 polyfill 性能(如需要)。
- 利用子协议协商确定应用协议。
- 优化二进制负载以最小化传输大小。
- 考虑压缩 UTF-8 内容以最小化传输大小。
- 为接收的二进制负载设置正确的二进制类型。
- 监控客户端上的缓冲数据量。
- 分割大应用消息以避免队头阻塞。
- 在适用时利用其他传输方式。
最后但同样重要的是,针对移动设备优化!实时推送可能是移动手持设备上昂贵的性能反模式,电池寿命始终宝贵。这并不是说 WebSocket 不应该在移动设备上使用。相反,它可以是一种高效的传输方式,但请确保考虑其要求:
- 节省电池电量
- 消除周期性和低效数据传输
- Nagle 和高效服务器推送
- 消除不必要的应用 Keepalive