More than code
Thoughts, stories and ideas

JavaScript的工作原理:深入探索 websocket 和HTTP/2与SSE +如何选择正确的路径

一、简介

如今,功能丰富、动态 ui 的复杂 web 应用程序被认为是理所当然。这并不奇怪——互联网自诞生以来已经走过了漫长的道路。

最初,互联网并不是为了支持这种动态和复杂的 web 应用程序而构建的。它被认为是HTML页面的集合,相互链接形成一个包含信息的 “web” 概念。一切都是围绕 HTTP 的所谓 请求/响应 范式构建的。客户端加载一个页面,然后在用户单击并导航到下一个页面之前什么都不会发生。

大约在2005年,AJAX被引入,很多人开始探索在客户端和服务器之间建立双向连接的可能性。尽管如此,所有HTTP 通信都由客户端引导,客户端需要用户交互或定期轮询以从服务器加载新数据。

二、让 HTTP 变成“双向”交互

让服务器能够“主动”向客户机发送数据的技术已经出现了相当长的时间。例如“Push”和“Comet”。

最常见的一种黑客攻击方法是让服务器产生一种需要向客户端发送数据的错觉,这称为长轮询。通过长轮询,客户端打开与服务器的 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'
      });
  }, 10000);
})();

这基本上是一个自执行函数,第一次立即运行时,它设置了 10 秒间隔,在对服务器的每个异步Ajax调用之后,回调将再次调用Ajax。

其他技术涉及 Flash 或 XHR multipart request 和所谓的 htmlfiles 。

但是,所有这些工作区都有一个相同的问题:它们都带有 HTTP 的开销,这使得它们不适合于低延迟应用程序。想想浏览器中的多人第一人称射击游戏或任何其他带有实时组件的在线游戏。

三、WebSockets 的引入

WebSocket 规范定义了在 web 浏览器和服务器之间建立“套接字”连接的 API。简单地说:客户机和服务器之间存在长久连接,双方可以随时开始发送数据。

客户端通过 WebSocket 握手 过程建立 WebSocket 连接。这个过程从客户机向服务器发送一个常规 HTTP 请求开始,这个请求中包含一个升级头,它通知服务器客户机希望建立一个 WebSocket 连接。

客户端建立 WebSocket 连接方式如下:

// Create a new WebSocket with an encrypted connection.
var socket = new WebSocket('ws://websocket.example.com')

WebSocket url使用 ws 方案。还有 wss 用于安全的 WebSocket 连接,相当于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

如果服务器支持 WebSocke t协议,它将同意升级,并通过响应中的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() { });

// 创建server
wsServer = new WebSocketServer({
  httpServer: server
});

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

  // 这里是很重要好的回调,监听处理来自用户的对所有信息
  connection.on('message', function(message) {
      // Process WebSocket message
  });

  connection.on('close', function(connection) {
    // Connection closes
  });
});

建立连接后,服务器进行升级响应:

HTTP/1.1 101 Switching Protocols
Date: Wed, 25 Oct 2017 10:07:34 GMT
Connection: Upgrade
Upgrade: WebSocket

一旦建立连接,open 事件将在客户端 WebSocket 实例上被触发:

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 连接被使用相同底层 TCP/IP 连接的 WebSocket 连接替换。此时,双方都可以开始发送数据。

使用 WebSockets,可以传输任意数量的数据,而不会产生与传统 HTTP 请求相关的开销。数据作为消息通过 WebSocket 传输,每个消息由一个或多个帧组成,其中包含正在发送的数据(有效负载)。为了确保消息在到达客户端时能够正确地进行重构,每一帧都以负载的4-12字节数据为前缀, 使用这种基于帧的消息传递系统有助于减少传输的非有效负载数据量,从而大大的减少延迟。

注意:值得注意的是,只有在接收到所有帧并重构了原始消息负载之后,客户机才会收到关于新消息的通知。

四、WebSocket URLs

之前简要提到过 WebSockets 引入了一个新的URL方案。实际上,他们引入了两个新的方案:ws://wss://

url 具有特定方案的语法。WebSocket url 的特殊之处在于它们不支持锚点(#sample_anchor)。

同样的规则适用于 WebSocket 风格的url和 HTTP 风格的 url。ws 是未加密的,默认端口为80,而 wss 需要TLS加密,默认端口为 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 ...                |
     +---------------------------------------------------------------+

在RFC 指定的 WebSocket 版本中,每个包前面只有一个报头。然而,这是一个相当复杂的报头。以下是它的构建模块:

  • FIN(1bit): 表示是消息的最后一帧,如果消息只有一帧那么第一帧也就是最后一帧,Firefox 在 32K 之后创建了第二个帧。

  • RSV1RSV2RSV3(每个1bit):必须是0,除非扩展定义为非零。如果接受到的是非零值但是扩展没有定义,则需要关闭连接。

  • Opcode(4bit):解释 Payload 数据,规定有以下不同的状态,如果是未知的,接收方必须马上关闭连接。状态如下:

    • 0x00: 附加数据帧
    • 0x01:文本数据帧
    • 0x02:二进制数据帧
    • 0x3-7:保留为之后非控制帧使用
    • 0x8:关闭连接帧
    • 0x9:ping
    • 0xA:pong
    • 0xB-F(保留为后面的控制帧使用)
  • Mask(1bit):掩码,定义payload数据是否进行了掩码处理,如果是1表示进行了掩码处理。

  • Masking-key:域的数据即是掩码密钥,用于解码PayloadData。客户端发出的数据帧需要进行掩码处理,所以此位是1。

  • Payload_len:7位,7 + 16位,7+64位,payload数据的长度,如果是0-125,就是真实的payload长度,如果是126,那么接着后面的2个字节对应的16位无符号整数就是payload数据长度;如果是127,那么接着后面的8个字节对应的64位无符号整数就是payload数据的长度。

  • Masking-key:0到4字节,如果MASK位设为1则有4个字节的掩码解密密钥,否则就没有。

  • Payload data:任意长度数据。包含有扩展定义数据和应用数据,如果没有定义扩展则没有此项,仅含有应用数据。

六、帧数据

如上所述,数据可以被分割成多个帧。 传输数据的第一帧有一个操作码,表示正在传输什么类型的数据。 这是必要的,因为 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 接收数据时(在客户端),会触发一个消息事件。此事件包括一个名为data的属性,可用于访问消息的内容。

// Handle messages sent by the server.
socket.onmessage = function(event) {
  var message = event.data;
  console.log(message);
};

在Chrome开发工具:可以很容易地观察 WebSocket 连接中每个帧中的数据:

七、消息分片

有效载荷数据可以分成多个单独的帧。接收端应该对它们进行缓冲,直到设置好 fin 位。因此,可以将字符串“Hello World”发送到11个包中,每个包的长度为6(报头长度)+ 1字节。控件包不允许分片。但是,规范希望能够处理交错的控制帧。这是TCP包以任意顺序到达的情况。

连接帧的逻辑大致如下:

  • 接收第一帧
  • 记住操作码
  • 将帧有效负载连接在一起,直到 fin 位被设置
  • 断言每个包的操作码是零

分片目的是发送长度未知的消息。如果不分片发送,即一帧,就需要缓存整个消息,计算其长度,构建frame并发送;使用分片的话,可使用一个大小合适的buffer,用消息内容填充buffer,填满即发送出去。

八、什么是心跳检测?

主要目的是保障客户端 websocket 与服务端连接状态,该程序有心跳检测自动重连机制,当网络断开或者后端服务问题造成客户端websocket断开,程序会自动尝试重新连接直到再次连接成功。

在使用原生websocket的时候,如果设备网络断开,不会触发任何函数,前端程序无法得知当前连接已经断开。这个时候如果调用 websocket.send 方法,浏览器就会发现消息发不出去,便会立刻或者一定短时间后(不同浏览器或者浏览器版本可能表现不同)触发 onclose 函数。

后端 websocket 服务也可能出现异常,连接断开后前端也并没有收到通知,因此需要前端定时发送心跳消息 ping,后端收到 ping 类型的消息,立马返回 pong 消息,告知前端连接正常。如果一定时间没收到pong消息,就说明连接不正常,前端便会执行重连。

为了解决以上两个问题,以前端作为主动方,定时发送 ping 消息,用于检测网络和前后端连接问题。一旦发现异常,前端持续执行重连逻辑,直到重连成功。

九、错误处理

以通过监听 error 事件来处理所有错误:

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

// Handle any error that occurs.
socket.onerror = function(error) {
  console.log('WebSocket Error: ' + error);
};

十、关闭连接

要关闭连接,客户机或服务器都应该发送包含操作码0x8的数据的控制帧。当接收到这样一个帧时,另一个对等点发送一个关闭帧作为响应,然后第一个对等点关闭连接,关闭连接后接收到的任何其他数据都将被丢弃:

// Close if the connection is open.
if (socket.readyState === WebSocket.OPEN) {
    socket.close();
}

另外,为了在完成关闭之后执行其他清理,可以将事件侦听器附加到关闭事件:

// Do necessary clean up.
socket.onclose = function(event) {
  console.log('Disconnected from WebSocket.');
};

服务器必须监听关闭事件以便在需要时处理它:

connection.on('close', function(reasonCode, description) {
    // The connection is getting closed.
});

十一、WebSockets和HTTP/2 比较

虽然HTTP/2提供了很多功能,但它并没有完全满足对现有推送/流技术的需求。

关于 HTTP/2 的第一个重要的事情是它并不能替代所有的 HTTP 。verb、状态码和大部分头信息将保持与目前版本一致。HTTP/2 是意在提升数据在线路上传输的效率。

比较HTTP/2和WebSocket,可以看到很多相似之处:

正如我们在上面看到的,HTTP/2引入了 Server Push,它使服务器能够主动地将资源发送到客户机缓存。但是,它不允许将数据下推到客户机应用程序本身,服务器推送只由浏览器处理,不会在应用程序代码中弹出,这意味着应用程序没有API来获取这些事件的通知。

这就是服务器发送事件(SSE)变得非常有用的地方。SSE 是一种机制,它允许服务器在建立客户机-服务器连接之后异步地将数据推送到客户机。然后,只要有新的“数据块”可用,服务器就可以决定发送数据。它可以看作是单向发布-订阅模式。它还提供了一个名为 EventSource API 的标准JavaScript,作为W3C HTML5标准的一部分,在大多数现代浏览器中实现。不支持 EventSource API 的浏览器可以轻松地使用 polyfilled 方案来解决。

由于 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 的存在,现在有一个纯粹的 HTTP 双向连接和一个简单的 API 就可以让应用程序代码注册到服务器推送服务上。在比较 SSE 和 WebSocket 时,缺乏双向能力往往被认为是一个主要的缺陷。有了 HTTP/2,不再有这种情况。这样就可以跳过 WebSocket ,而坚持使用基于 HTTP 的信号机制。

十二、如何选择WebSocket和HTTP/2?

WebSockets 会在 HTTP/2 + SSE 的领域中生存下来,主要是因为它是一种已经被很好地应用的技术,并且在非常具体的使用情况下,它比 HTTP/2 更具优势,因为它已经被构建用于具有较少开销(如报头)的双向功能。

假设建立一个大型多人在线游戏,需要来自连接两端的大量消息。在这种情况下,WebSockets 的性能会好很多。

一般情况下,只要需要客户端和服务器之间的真正低延迟,接近实时的连接,就使用 WebSocket ,这可能需要重新考虑如何构建服务器端应用程序,以及将焦点转移到队列事件等技术上。

使用的方案需要显示实时的市场消息,市场数据,聊天应用程序等,依靠 HTTP/2 + SSE 将为你提供高效的双向通信渠道,同时获得留在 HTTP 领域的各种好处:

  • 当考虑到与现有 Web 基础设施的兼容性时,WebSocket 通常会变成一个痛苦的源头,因为它将 HTTP 连接升级到完全不同于 HTTP 的协议。
  • 规模和安全性:Web 组件(防火墙,入侵检测,负载均衡)是以 HTTP 为基础构建,维护和配置的,这是大型/关键应用程序在弹性,安全性和可伸缩性方面更偏向的环境。

十三、参考文章

原文:https://blog.sessionstack.com/how-javascript-works-deep-dive-into-websockets-and-http-2-with-sse-how-to-pick-the-right-path-584e6b8e3bf7

赞(5) 打赏
未经允许不得转载:Saxon's blog » JavaScript的工作原理:深入探索 websocket 和HTTP/2与SSE +如何选择正确的路径

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

React CLI - 快速创建react项目脚手架工具

官 网GitHub

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏