JavaScript 工做原理之五-深刻理解 WebSockets 和带有 SSE 机制的HTTP/2 以及正确的使用姿式(译)

原文请查阅这里,略有改动,本文采用知识共享署名 4.0 国际许可协议共享,BY Trolandjavascript

本系列持续更新中,Github 地址请查阅这里html

这是 JavaScript 工做原理的第五章。java

如今,咱们将会深刻通讯协议的世界,绘制并讨论它们的特色和内部构造。咱们将会给出一份 WebSockets 和 HTTP/2 的快速比较 。在文末,咱们将会分享如何正确地选择网络协议的一些看法。git

简介

如今,复杂的网页程序拥有丰富的功能,这得多亏网页的动态交互能力。而这并不使人感到惊讶-由于自互联网诞生,它经历了一段至关长的时间。github

起初,互联网并非用来支持如此动态和复杂的网页程序的。它原本设想是由大量的 HTML 页面组成的,每一个页面连接到其它的页面,这样就造成了包含信息的网页的概念。一切都是极大地围绕着所谓的 HTTP 请求/响应模式来创建的。客户端加载一个网页,直到用户点击页面并导航到下一个网页。web

大约在 2005 年,引入了 AJAX,而后不少人开始探索客户端和服务端双向通讯的可能性。然而,全部的 HTTP 连接是由客户端控制的,意即必须由用户进行操做或者按期轮询以从服务器加载数据。ajax

让 HTTP 支持双向通讯

支持服务器主动向客户端推送数据的技术已经出现了好一段时间了。好比 "Push" 和 "Comet" 技术。json

长轮询是服务端主动向客户端发送数据的最多见的 hack 之一。经过长轮询,客户端打开了一个到服务端的 HTTP 链接直到返回响应数据。当服务端有新数据须要发送时,它会把新数据做为响应发送给客户端。api

让咱们看一下简单的长轮询代码片断:浏览器

(function poll(){
   setTimeout(function(){
      $.ajax({ 
        url: 'https://api.example.com/endpoint', 
        success: function(data) {
          // 处理 `data`
          // ...

          //递归调用下一个轮询
          poll();
        }, 
        dataType: 'json'
      });
  }, 10000);
})();
复制代码

这基本上是一个自执行函数,第一次会自动运行。它每隔 10 秒钟异步请求服务器而且当每次发起对服务器的异步请求以后,会在回调函数里面再次调用 ajax 函数。

其它技术涉及到 Flash 和 XHR 多方请求以及所谓的 htmlfiles

全部这些方案都有一个共同的问题:都带有 HTTP 开销,这样就会使得它们没法知足要求低延迟的程序。试想一下浏览器中的第一人称射击游戏或者其它要求实时组件功能的在线游戏。

WebSockets 的出现

WebSocket 规范定义了一个 API 用以在网页浏览器和服务器创建一个 "socket" 链接。通俗地讲:在客户端和服务器保有一个持久的链接,两边能够在任意时间开始发送数据。

客户端经过 WebSocket 握手的过程来建立 WebSocket 链接。在这一过程当中,首先客户端向服务器发起一个常规的 HTTP 请求。请求中会包含一个 Upgrade 的请求头,通知服务器客户端想要创建一个 WebSocket 链接。

让咱们看下如何在客户端建立 WebSocket 链接:

// 建立新的加密 WebSocket 链接
var socket = new WebSocket('ws://websocket.example.com');
复制代码

WebSocket 地址使用了 ws 方案。wss 是一个等同于 HTTPS 的安全的 WebSocket 链接。

该方案是打开到 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 的实现:

// 咱们将会使用 https://github.com/theturtle32/WebSocket-Node 来实现 WebSocket
var WebSocketServer = require('websocket').server;
var http = require('http');

var server = http.createServer(function(request, response) {
  // 处理 HTTP 请求
});
server.listen(1337, function() { });

// 建立服务器
wsServer = new WebSocketServer({
  httpServer: server
});

// WebSocket 服务器
wsServer.on('request', function(request) {
  var connection = request.accept(null, request.origin);

  // 这是最重要的回调,在这里处理全部用户返回的信息
  connection.on('message', function(message) {
      // 处理 WebSocket 信息
  });

  connection.on('close', function(connection) {
    // 关闭链接
  });
});
复制代码

链接创建以后,服务器使用升级来做为回复:

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');

// WebSocket 链接打开的时候,打印出 WebSocket 已链接的信息
socket.onopen = function(event) {
  console.log('WebSocket is connected.');
};
复制代码

如今,握手结束了,最初的 HTTP 链接被替换为 WebSocket 链接,该链接底层使用一样的 TCP/IP 链接。如今两边均可以开始发送数据了。

经过 WebSocket,你能够随意发送数据而不用担忧传统 HTTP 请求所带来的相关开销。数据是以消息的形式经过 WebSocket 进行传输的,每条信息是由包含你所传输的数据(有效载荷)的一个或多个帧所组成的。为了保证当消息到达客户端的时候被正确地从新组装出来,每一帧都会前置关于有效载荷的 4-12 字节的数据。使用这种基于帧的信息系统能够帮助减小非有效载荷数据的传输,从而显著地减小信息延迟。

**注意:**这里须要注意的是只有当全部的消息帧都被接收到并且原始的信息有效载荷被从新组装的时候,客户端才会接收到新消息的通知。

WebSocket 地址

前面咱们简要地谈到 WebSockets 引进了一个新的地址协议。实际上,WebSocket 引进了两种新协议:ws://wss://

URL 地址含有指定方案的语法。WebSocket 地址特别之处在于,它不支持锚(sample_anchor)。

WebSocket 和 HTTP 风格的地址使用相同的地址规则。ws 是未加密且默认是 80 端口,而 wss 要求 TSL 加密且默认 443 端口。

帧协议

让咱们深刻了解下帧协议。这是 RFC 提供的:

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 ...                |
     +---------------------------------------------------------------+
复制代码

因为 WebSocket 版本是由 RFC 所规定的,因此每一个包前面只有一个头部信息。然而,这个头部信息至关的复杂。这是其组成模块的说明:

  • fin(1 位):指示是不是组成信息的最后一帧。大多数时候,信息只有一帧因此该位一般有值。测试代表火狐的第二帧数据在 32K 以后。

  • rsv1rsv2rsv3(每一个一位):必须是 0 除非使用协商扩展来定义非 0 值的含义。若是收到一个非 0 值且没有协商扩展来定义非零值的含义,接收端会中断链接。

  • opcode(4 位):表示第几帧。目前可用的值:

    0x00:该帧接续前面一帧的有效载荷。

    0x01:该帧包含文本数据。

    0x02:该帧包含二进制数据。

    0x08:该帧中断链接。

    0x09:该帧是一个 ping。

    0x0a:该帧是一个pong。

    (正如你所看到的,有至关一部分值未被使用;它们是保留以备将来使用的)。

  • mask(1 位):指示该链接是否被遮罩。正其所表示的意义,每一条从客户端发往服务器的信息都必须被遮罩,而后若是信息未遮罩,根据规范会中断该链接。

  • payload_len(7 位):有效载荷的长度。WebSocket 帧有如下几类长度:

    0-125 表示有效载荷的长度。126 意味着接下来两个字节表示有效载荷长度,127 意味着接下来的 8 个字节表示有效载荷长度。因此有效载荷的长度大概有 7 位,16 位和 64 位这三类。

  • masking-key (32 位):全部从客户端发往服务器的帧都由帧内的一个 32 位值所遮罩。

  • payload:通常状况下都会被遮罩的实际数据。其长度取决于 payload_len 的长度。

为何 WebSocket 是基于帧而不是基于流的呢?我和你同样一脸懵逼,我也想多学点,若是你有任何想法,欢迎在下面的评论区添加评论和资源。另外,HackerNews 上面有关于这方面的讨论。

帧数据

正如以前提到的,数据能够被拆分为多个帧。第一帧所传输的数据里面含有一个操做码表示数据的传输顺序。这是必须的,由于当规范完成的时候,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'); // 向服务器发送数据
};
复制代码

当 WebSocket 正在接收数据的时候(客户端),会触发 message 事件。该事件会带有一个 data 属性,里面包含了消息的内容。

// 处理服务器返回的消息
socket.onmessage = function(event) {
  var message = event.data;
  console.log(message);
};
复制代码

你能够很容易地利用 Chrome 开发者工具的网络选项卡来检查 WebSocket 链接中的每一帧的数据。

数据分片

有效载荷数据能够被分红多个独立的帧。接收端会缓冲这些帧直到 fin 位有值。因此你能够把字符串『Hello World』拆分为 11 个包,每一个包由 6(头长度) + 1 字节组成。数据分片不能用来控制包。然而,规范想要你有能力去处理交错控制帧。这是为了预防 TCP 包无序到达客户端。

链接帧的大概逻辑以下:

  • 接收第一帧
  • 记住操做码
  • 链接帧有效载荷直到 fin 位有值
  • 断言每一个包的操做码都为 0

数据分片的主要目的在于容许开始时传输不明大小的信息。经过数据分片,服务器可能须要设置一个合理的缓冲区大小,而后当缓冲区满,返回一个数据分片。数据分片的第二个用途即多路复用,逻辑通道上的大量数据占据整个输出通道是不合理的,因此利用多路复用技术把信息拆分红更小的数据分片以更好地共享输出通道。

心跳包

握手以后的任意时刻,客户端和服务器能够随意地 ping 对方。当接收到 ping 的时候,接收方必须尽快回复一个 pong。此即心跳包。你能够用它来确保客户端是否保持链接。

ping 或者 pong 虽然只是一个普通帧,但倒是一个控制帧。Ping 包含 0x9 操做码,而 Pong 包含 0xA 操做码。当你接收到 ping 的时候,返回一个和 ping 携带一样有效载荷数据的 pong(ping 和 pong 最大有效载荷长度都为 125)。你可能接收到一个 pong 而不用发送一个 ping。忽略它若是有发生这样的状况。

心跳包很是有用。利用服务(好比负载均衡器)来中断空闲的链接。另外,接收端不可能知道服务端是否已经中断链接。只有在发送下一帧的时候,你才会意识到发生了错误。

错误处理

你能够经过监听 error 事件来处理错误。

像这样:

var socket = new WebSocket('ws://websocket.example.com');

// 处理错误
socket.onerror = function(error) {
  console.log('WebSocket Error: ' + error);
};
复制代码

关闭链接

客户端或服务器能够发送一个包含 0x8 操做码数据的控制帧来关闭链接。当接收到控制帧的时候,另外一个节点会返回一个关闭帧。以后第一个节点会关闭链接。关闭链接以后,以后接收的任何数据都会被遗弃。

这是初始化关闭客户端的 WebSocket 链接的代码:

// 若是链接打开着则关闭
if (socket.readyState === WebSocket.OPEN) {
    socket.close();
}
复制代码

一样地,为了在完成关闭链接后运行任意的清理工做,你能够为 close 事件添加事件监听函数:

// 运行必要的清理工做
socket.onclose = function(event) {
  console.log('Disconnected from WebSocket.');
};
复制代码

服务器不得不监听 close 事件以便在须要的时候处理:

connection.on('close', function(reasonCode, description) {
    // 关闭链接
});
复制代码

WebSockets 和 HTTP/2 对比

虽然 HTTP/2 提供了不少的功能,可是它并不能彻底取代当前的 push/streaming 技术。

关于 HTTP/2 须要注意的最重要的事即它并不能彻底取代 HTTP。词汇,状态码以及大部分的头部信息都会保持和如今同样。HTTP/2 只是提高了线路上的数据传输效率。

如今,若是咱们对比 WebSocket 和 HTTP/2,将会发现不少相似的地方:

正如以上所显示的那样,HTTP/2 引进了 Server Push 技术用来让服务器主动向客户端缓存发送数据。然而,它并不容许直接向客户端程序自己发送数据。服务端推送只能由浏览器处理而不可以在程序代码中进行处理,意即程序代码没有 API 能够用来获取这些事件的通知。

这时候服务端推送事件(SSE)就派上用场了。SSE 是这样的机制一旦客户端-服务器链接创建,它容许服务器异步推送数据给客户端。以后,每当服务器产生新数据的时候,就推送数据给客户端。这能够当作是单向的发布-订阅模型。它也提供了一个被称为 EventSource 的 标准 JavaScript 客户端 API,该 API 做为 W3C 组织发布的 HTML5 标准的一部分已经在大多数的现代浏览器中实现。请注意不支持原生 EventSource API 的浏览器能够经过垫片实现。

因为 SSE 是基于 HTTP 的,因此它自然兼容于 HTTP/2 而且能够混合使用以利用各自的优点: HTTP/2 处理一个基于多路复用流的高效传输层而 SSE 为程序提供了 API 用来支持服务端推送。

为了彻底理解流和多路复用技术,先让咱们来了解一下 IETF 的定义:『流』便是在一个 HTTP/2 链接中,在客户端和服务端间进行交换传输的一个独立的双向帧序列。它的主要特色之一即单个的 HTTP/2 链接能够包含多个并发打开的流,在每一终端交错传输来自多个流的帧。

必须记住的是 SSE 是基于 HTTP 的。这意味着,经过使用 HTTP/2,不只仅能够把多个 SSE 流交叉合并成单一的 TCP 链接,还能够把多个 SSE 流(服务端向客户端推送)和多个客户端请求(客户端到服务端)合并成单一的 TCP 链接。多亏了 HTTP/2 和 SSE,如今咱们有了一个纯粹的 HTTP 双向链接,该链接带有一个简单的 API 容许程序代码注册监听服务端的数据推送。缺少双向通讯能力一直被认为是 SSE 对比 WebSocket 的主要缺点。多亏了 HTTP/2,这再也不是缺点。这就让你有机会坚持使用基于 HTTP 的通讯系统而非 WebSockets。

WebSocket 和 HTTP/2 的使用场景

WebSockets 依然能够在 HTTP/2 + SSE 的统治下存在,主要是因为它是广受好评的技术,在特殊状况下,和 HTTP/2 比较它有一个优势即它天生拥有更少的开销(好比,头部信息)的双向通讯能力。

假设你想要构建一个大型的多人在线游戏,在各个链接终端会产生大量的信息。在这样的状况下,WebSockets 会表现得更加完美。

总之,当你须要在客户端和服务端创建一个真正的低延迟的,接近实时链接的时候使用 WebSockets。记住这可能要求你从新考虑如何构建服务器端程序,同时也须要你关注诸如事件队列的技术。

若是你的使用场景要求显示实时市场新闻,市场数据,聊天程序等等,HTTP/2 + SSE 将会为你提供一个高效的双向通讯通道且你能够获得 HTTP 的全部益处:

  • 当考虑现有架构的兼容性的时候,WebSockets 常常会是一个痛点,由于升级 HTTP 链接到一个彻底和 HTTP 不相关的协议。
  • 可扩展性和安全:网络组件(防火墙,入侵检测,负载均衡器)的创建,维护和配置都是为 HTTP 所考虑的,大型/重要的程序会更喜欢具备弹性,安全和可伸缩性的环境。

一样地,你不得不考虑浏览器兼容性。查看下 WebSocket 兼容状况:

兼容性还不错。

然而,HTTP/2 的状况就不太妙了:

  • 仅支持 TLS(还不算坏)
  • 仅限于 Windows 10 的 IE 11 部分支持
  • 仅支持 OSX 10.11+ Safari 浏览器
  • 仅当你协商应用 ALPN(服务器须要明确支持的东西)才会支持 HTTP/2

SSE 的支持状况要好些:

仅 IE/Edge 不支持。(好吧,Opera Mini 即不支持 SSE 也不支持 WebSockets,所以咱们把它彻底排队在外)。有一些优雅的垫片来让 IE/Edge 支持 SSE。

SessionStack 是如何选择的?

SessionStack  同时使用 WebSockets 和 HTTP,这取决于使用场景。

一旦整合 SessionStack 进网页程序,它会开始记录 DOM 变化,用户交互,JavaScript 异常,堆栈追踪,失败的网络请求以及调试信息,容许你用视频回放网页程序中的问题及发生在用户身上的一切事情。所有都是实时发生的而且要求对网页程序不会产生任何的性能影响。

这意味着你能够实时加入到用户会话,而用户仍然在浏览器中。这样的状况下,咱们会选择使用 HTTP,由于这并不须要双向通讯(服务端把数据传输到浏览器端)。当前状况下,使用 WebSocket 就是过分使用,难以维护和扩展。

然而,整合进网页程序的 SessionStack 库应用了 WebSocket(优先使用,不然回滚到 HTTP)。它会打包而且向咱们的服务器发送数据,这是单向通讯。在这种状况下,之因此选择 WebSocket 是由于计划中的某些产品功能可能须要进行双向通讯。

打个广告 ^.^

今日头条招人啦!发送简历到 likun.liyuk@bytedance.com ,便可走快速内推通道,长期有效!国际化PGC部门的JD以下:c.xiumi.us/board/v5/2H…,也可内推其余部门!

本系列持续更新中,Github 地址请查阅这里

相关文章
相关标签/搜索