Jason Pan

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浏览器和服务器之间建立“套接字”连接。客户端与服务器之间存在持久连接,双方都可以随时发送数据。

img

客户端通过所谓的 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 基本组成的说明:

为什么 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 连接中每帧中的数据:

img

分片

有效负载数据可以分为多个单独的帧。接收端应对它们进行缓冲,直到收到 fin 为1的帧。您可以在传输 “Hello World” 的时,将其拆成11个帧,每个都由 6 字节 header 和 1字节的单个字母组成。控制信息不允许被分帧。但是,该规范希望您在 TCP 包以任意顺序到达时,能够处理交错的 (interleaved) 控制帧。

重组帧的大概逻辑如下:

分片的主要目的是:允许发送在消息启动时不能确定大小的消息。通过分片,服务器可以选择合理大小的缓冲区,在缓冲区填满时,将分片写入网络。分片的另一个用途是多路复用,在多路复用的场景中,不希望在逻辑通道上出现大消息接管整个输出通道,多路复用需要将消息切分成更小的分片,以更好地共享输出渠道。

心跳

客户端或服务器可以在握手之后的任意时间点,发送 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 进行比较,我们可以看到很多相似之处:

img

如上所述,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 连接可以包含多个并发打开的流,任一端点都可以打乱多个流中的帧。

img

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 环境中获得收益:

另外,您还必须考虑浏览器支持。看一下 WebSocket:

img

WebSocket 支持的很好。但 HTTP/2的情况有些不同:

img

不过,SSE支持的要好一些:

img

仅IE / Edge不提供支持(译者注:至 2021-04-05 依然不支持)。Opera Mini 不支持 SSE 或 WebSockets,不过我们几乎不需要考虑它。IE/Edge 中有一些不错的 polyfill 以支持 SSE。

参考资料