JavaScript 工作原理 —— 深入理解WebSocket和带SSE的HTTP/2 + 网络协议选型
潘忠显 / 2021-04-05
“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显。
作为“JavaScript 工作原理”系列文章的第 5 篇,本文首先将深入地理解通信协议、讨论其属性和组成部分,然后快速比较 WebSockets 和 HTTP/2,最后分享如何选择网络协议的相关思路。
简介
今时今日,互联网自诞生以来已经发展了很长时间,Web 应用程序具有丰富功能和动态 UI,也已被视为了理所当然。
互联网创建的初衷,其实并不是为支持这种动态的、复杂的 web 应用。最初 web 的概念,是 HTML 页面链接而成的集合,包含了各种信息。基本上,一切是都建立在所谓的 HTTP 请求/响应范式的基础上。客户端加载页面,完全加载直到用户单击并导航到下一页,这中间不会再做任何事情。
从 2005 年开始,AJAX 被逐渐大众所接受,大家开始探索在客户端和服务器之间建立“双向”连接的可能性。尽管如此,所有 HTTP 通信仍由客户端控制,这需要用户交互或定期轮询才能从服务器加载新数据。
“双向化” HTTP
服务器“主动”将数据发送到客户端的技术,已经存在了一段时间,例如 “Push” 和 “Comet”。
长期轮询 (long polling) 是一种常见技巧,它能制造出服务器将数据发送到客户端的错觉。使用长期轮询时,客户端将建立到服务器的 HTTP 连接,该连接将保持打开状态,直到发送响应为止。每当服务器有必须发送的新数据时,它都会将其作为响应进行传输。
让我们看一个非常简单的长期轮询代码示例:
(function poll(){
setTimeout(function(){
$.ajax({
url: 'https://api.example.com/endpoint',
success: function(data) {
// Do something with `data`
// ...
//Setup the next poll recursively
poll();
},
dataType: 'json' // 译者注:如果返回的不是json,请注释掉这里,不然进入不了success
});
}, 10000);
})();
这是一种基础的自我执行 (self-executing) 函数,只需要执行一次,之后就自动运行。它设置了 10 秒的间隔,在每次对服务器的异步 AJAX 调用成功之后之后,回调会再次调用 poll()
,然后再次过10秒又触发回调,直到 AJAX 请求响应失败。
其他使 HTTP “双向化”的技术包括:Flash、XHR 多部分请求以及所谓的 htmlfiles。但这些变通办法都存在着相同的问题:它们带来了 HTTP 的开销,不适用于低延迟的应用程序(比如在浏览器中,多人第一人称射击游戏或任何其他具有实时组件的在线游戏)。
WebSocket 的介绍
WebSocket 规范定义了一个API,用于在Web浏览器和服务器之间建立“套接字”连接。客户端与服务器之间存在持久连接,双方都可以随时发送数据。
客户端通过所谓的 WebSocket 握手,来建立WebSocket连接。此过程开始时,客户端向服务器发送一个常规 HTTP 请求,请求中包含一个 Upgrade
标头,该标头通知服务器:客户端希望建立WebSocket连接。
让我们来看一个客户端打开 WebSocket 连接的例子:
// Create a new WebSocket with an encrypted connection.
var socket = new WebSocket('ws://websocket.example.com');
WebSocket URL使用
ws
协议类型(scheme,URL中最前边的部分)。还有用于安全WebSocket连接的wss
,类似于https
。
该只是向 websocket.example.com 打开 WebSocket 连接过程的开始。
以下是初始请求标头的简化示例:
GET ws://websocket.example.com/ HTTP/1.1
Origin: http://example.com
Connection: Upgrade
Host: websocket.example.com
Upgrade: websocket
如果服务器支持 WebSocket 协议,它将同意升级,并将通过响应中的 Upgrade
标头传达此信息。以下展示了如何在 Node.js 中实现:
// We'll be using the https://github.com/theturtle32/WebSocket-Node
// WebSocket implementation
var WebSocketServer = require('websocket').server;
var http = require('http');
var server = http.createServer(function(request, response) {
// process HTTP request.
});
server.listen(1337, function() { });
// create the server
wsServer = new WebSocketServer({
httpServer: server
});
// WebSocket server
wsServer.on('request', function(request) {
var connection = request.accept(null, request.origin);
// This is the most important callback for us, we'll handle
// all messages from users here.
connection.on('message', function(message) {
// Process WebSocket message
});
connection.on('close', function(connection) {
// Connection closes
});
});
服务器回复的 HTTP 头部类似于这样:
HTTP/1.1 101 Switching Protocols
Date: Wed, 25 Oct 2017 10:07:34 GMT
Connection: Upgrade
Upgrade: WebSocket
一旦连接建立后,将在客户端的 WebSocket 实例上触发 open
事件:
var socket = new WebSocket('ws://websocket.example.com');
// Show a connected message when the WebSocket is opened.
socket.onopen = function(event) {
console.log('WebSocket is connected.');
};
握手过程至此已经完成,初始的 HTTP 连接被 WebSocket 连接取代,TCP 和 WebSocket 都基于 TCP/IP 连接。这时候,任何一方都可以开始发送数据。
使用 WebSocket,可以传输任意数量的数据,而不会产生传统 HTTP 请求带来的开销。
WebSocket 进行传输数据是以消息 (messages) 的形式,消息由一个或多个帧 (frame) 组成,帧中包含要发送的数据(有效负载)。每个帧中都有 4 ~ 12 字节的前缀,该前缀记录关于负载的信息,以确保消息在到达客户端时,可以正确地重构。使用这种基于帧的消息传递系统,有助于减少所传输的非有效载荷数据量,从而显著地减少延迟。
注意:只有在收到所有帧并且重构了原始消息有效负载后,client 才会收到新消息的通知。
WebSocket URL
在此之前,我们简要提到了 WebSocket 引入了一种新的 URL 方案。实际上,他们引入了两个新方案: ws://
和 wss://
。URL 具有特定于方案的语法。 WebSocket URL 的特殊之处在于它们不支持锚点(#sample_anchor
)。与 HTTP 形式的 URL 类似,ws
是未加密的,默认端口为 80,而 wss
需要 TLS 加密,默认端口为 443。
帧协议
通过阅读 RFC 6455 协议提供的资料,让我们更深入地了解分帧协议:
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 ... |
+---------------------------------------------------------------+
作为 RFC 指定版本的 WebSocket,每个数据包 (packet) 的前面只有一个标头 (header),但它相对复杂。以下是对 header 基本组成的说明:
-
fin
(1 bit):指示该帧是否是组成消息的最后一帧。大多数消息只有单个帧的情况下,始终会设置该位。实验表明,Firefox 在 32K 之后产生第二帧。 -
rsv1
,rsv2
,rsv3
(各 1 bit):必须为0,除非协商了定义非零值含义的扩展名。如果在没有协商的扩展定义的情况下,接收到非0值,接收端点必须置连接失败 -
opcode
(4 bits):用于解释当前帧负载数据,目前使用的值有(其它未使用值,保留以备将来使用):0x00
:该帧继续前一个的有效载荷0x01
:该帧包含文本数据0x02
:该帧包含二进制数据0x08
:该帧终止连接0x09
:该帧为 ping0x0a
:该帧为 pong -
mask
(1 bit):指示连接是否进行掩码操作 (mask)。目前,客户端到服务器的每条消息都必须被进行掩码操作。并且规范要求:服务端在收到未被掩码操作的数据时,需要终止连接。 -
payload_len
(7 bits):有效载荷长度, WebSocket 帧长度有以下情况:0~125 表示有效负载的长度;126 表示后面的两个字节表示长度;127 表示后面的 8 个字节表示长度。因此,有效负载的长度会位于 ~7bit、16bit 和 64bit 三个范围中。 -
masking-key
(32 bits):从客户端发送到服务器的所有帧,都被 32-bit 的值进行掩码操作。 -
payload
:实际的数据,大多数情况下可能被掩码操作过的,其长度就是payload_len
指示的那样。
为什么 WebSockets 基于帧而不是基于流?我不知道,我还有更多知识要学。在 HackerNews 上,可以找到关于此主题的讨论。
帧中的数据
如上所述,可以将数据分成多个帧。传输数据的第一帧上有一个操作码 (opcode
),指示正在传输哪种类型的数据。这是必要的,因为在规范开始时,JavaScript 几乎不支持二进制数据。0x01
表示 utf-8 编码的文本数据,0x02
表示二进制数据。大多数人会传输 JSON,在这种情况下,可能选择文本操作码。当您发出二进制数据时,它将被表示为浏览器特定的 Blob。
通过 WebSocket 发送数据的 API 非常简单:
var socket = new WebSocket('ws://websocket.example.com');
socket.onopen = function(event) {
socket.send('Some message'); // Sends data to server.
};
当客户端 WebSocket 接收数据时,将触发一个 message
事件。该事件包括一个名为 data
的属性,可用于访问消息的内容。
// Handle messages sent by the server.
socket.onmessage = function(event) {
var message = event.data;
console.log(message);
};
您可以使用 Chrome DevTools 中的“Network”标签,轻松地浏览 WebSocket 连接中每帧中的数据:
分片
有效负载数据可以分为多个单独的帧。接收端应对它们进行缓冲,直到收到 fin
为1的帧。您可以在传输 “Hello World” 的时,将其拆成11个帧,每个都由 6 字节 header 和 1字节的单个字母组成。控制信息不允许被分帧。但是,该规范希望您在 TCP 包以任意顺序到达时,能够处理交错的 (interleaved) 控制帧。
重组帧的大概逻辑如下:
- 接收第一帧
- 记住操作码
- 将帧有效负载连接在一起,直到将
fin
位被设置为1
- 断言每个包的操作码为
0
分片的主要目的是:允许发送在消息启动时不能确定大小的消息。通过分片,服务器可以选择合理大小的缓冲区,在缓冲区填满时,将分片写入网络。分片的另一个用途是多路复用,在多路复用的场景中,不希望在逻辑通道上出现大消息接管整个输出通道,多路复用需要将消息切分成更小的分片,以更好地共享输出渠道。
心跳
客户端或服务器可以在握手之后的任意时间点,发送 ping 给对端;收到 ping 之后,接收者必须尽快返回 pong。这就是所谓的心跳 (heartbeat),可以通过心跳,来确保对方仍处于连接状态。
ping 或 pong 是常规帧,也是控制帧。 Ping 的 opcode 为 0x9
,Pong 为 0xA
。回复的 pong 的有效载荷数据,需要与收到的 ping 中载荷数据完全相同,ping/pong的最大有效载荷长度为125。如果没有发过 ping 而收到了 pong,此时应该忽略。
心跳可能非常有用。有些服务(如负载平衡器)会终止空闲连接。另外,接收端无法查看远端是否已终止,只有在下一次发送时,才会感知到问题。
错误处理
您可以通过监听 error
事件来处理发生的任何错误。看起来像这样:
var socket = new WebSocket('ws://websocket.example.com');
// Handle any error that occurs.
socket.onerror = function(error) {
console.log('WebSocket Error: ' + error);
};
关闭连接
客户端或服务端可以通过发送 opcode 为 0x8
的控制帧,来关闭连接。对端在接收到这样的帧之后,发送Close帧作为响应。然后,首先发起关闭的一方关闭连接。关闭连接后,再收到的任何其他数据将被丢弃。
这是从客户端关闭 WebSocket 连接的方式:
// Close if the connection is open.
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
同样,客户端可以监听 close
事件,在关闭完成后,执行清理动作:
// Do necessary clean up.
socket.onclose = function(event) {
console.log('Disconnected from WebSocket.');
};
服务器必须监听 close
事件,以便在需要时对其进行处理:
connection.on('close', function(reasonCode, description) {
// The connection is getting closed.
});
WebSockets 与 HTTP/2 的比较
尽管 HTTP/2 提供了很多功能,但它并不能完全取代对现有 推送/流 技术。
要注意: HTTP/2 不是完全替换 HTTP,其动作、状态码以及大部分Header,都与 HTTP 保持相同;HTTP/2 的目的是提高数据的网络传输效率。
现在,如果将 HTTP/2 与 WebSocket 进行比较,我们可以看到很多相似之处:
如上所述,HTTP/2 引入的 Server Push,可以使服务器主动将资源发送到客户端缓存。但是,它不允许将数据下推到客户端应用程序本身:服务器推送仅由浏览器处理,应用程序没有 API 可以获取这些事件的通知。
这使得服务器发送事件(SSE,Server-Sent Events)变得非常有用。SSE是一种机制:一旦建立了客户端-服务器连接,服务器就可以将数据异步推送到客户端;只要有新的 chunk 数据可用,服务器就可以决定发送数据。可以将其视为单向推送-订阅模式 (publish-subscribe pattern);它还提供了一个名为 EventSource 的标准 JavaScript 客户端API,作为 W3C HTML5 标准的一部分,SSE 已经被大多数现代浏览器所实现。不支持 EventSource API 的浏览器也可以轻松实现。
由于 SSE 是基于 HTTP 的,因此它很自然地适配 HTTP/2,并且可以结合使用场景,做最佳选择:HTTP/2 基于多路复用流,做高效的传输层;SSE 在应用层提供 API 以实现推送。
为了完全了解 Streams 和 Multiplexing 的含义,首先让我们看一下 IETF 的定义:
stream 是独立的、双向的帧序列,通过客户端和服务端之间 HTTP/2 的连接做交换。其主要特征之一,是单个 HTTP/2 连接可以包含多个并发打开的流,任一端点都可以打乱多个流中的帧。
SSE 是基于 HTTP 的,这意味着使用 HTTP/2,不仅可以将多个 SSE 流混合到单个 TCP 连接上,而且还可以通过结合多个SSE流(服务器到客户端的推送)和多个客户端请求(客户端到服务器)来完成相同的操作(?)。利用 HTTP/2 和 SSE,我们可以有一个带有简单 API 的纯 HTTP 双向连接,可以使应用程序代码注册监听服务器的推送。相较于 WebSocket,缺乏双向功能是 SSE 的主要缺点,但结合 HTTP/2 可以解决。这给坚持使用基于 HTTP 的信令(不使用 WebSocket),提供了机会。
选择 WebSocket 还是 HTTP/2
作为一种已经被广泛采用的技术,WebSocket 会在 HTTP/2 + SSE 的统治之下幸存下来。甚至在某些特定的场景下,WebSocket 比 HTTP/2 更有优势。因为 WebSocket 当初设计就是更少开销 + 双向能力。
假设您要构建一个庞大的多人在线游戏,该游戏需要在客户端和服务端之间进行大量的双向通信。在这种情况下,WebSocket 的性能会好得多。
通常来讲,当你需要真正低延迟(在客户端和服务端接近实时),选择 WebSockets。这可能需要你重新考虑如何构建服务器端程序,以及将重点转移到事件队列等技术上。
如果你的应用需要展示实时市场新闻、市场数据、聊天应用程序等,可以选择 HTTP/2 + SSE 为您提供有效的双向通信渠道,同时又可以保留在 HTTP 环境中获得收益:
- 当考虑兼容现有 Web 架构时,WebSocket 通常会开发者很痛苦,因为它会将 HTTP 连接升级为与 HTTP 无关的完全不同的协议
- 扩缩容和安全性:Web组件(防火墙,入侵检测,负载平衡器)在构建、维护和配置时都有支持 HTTP,大型/关键应用程序更会更多地考虑弹性、安全性和可伸缩性等因素
另外,您还必须考虑浏览器支持。看一下 WebSocket:
WebSocket 支持的很好。但 HTTP/2的情况有些不同:
- 仅 TLS(还好)
- IE 11中的部分支持,但仅限 Windows 10 及以上
- 仅支持 OSX 10.11+ 的 Safari
- 仅当您可以通过 ALPN 进行协商时,才支持 HTTP/2(服务器需要明确支持这点)
不过,SSE支持的要好一些:
仅IE / Edge不提供支持(译者注:至 2021-04-05 依然不支持)。Opera Mini 不支持 SSE 或 WebSockets,不过我们几乎不需要考虑它。IE/Edge 中有一些不错的 polyfill 以支持 SSE。