边学边译JS工作原理 -- 5. WebSockets 和HTTP2的正确姿势

355 阅读14分钟

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

本章主要内容是网络协议,从事网页游戏,即时聊天相关行业的同学可以看看。其他同学可以作为知识补充
本章推荐指数:3

这是 JavaScript 工作原理的第五章

现在,我们将会深入通信协议的世界,绘制并讨论它们的特点和内部构造。然后简单比较一下 WebSockets 和 HTTP/2。

概述

因特网规范诞生已经很久了,如今越来越复杂,越来越臃肿但是具有动态UI,且极富表现性的APP逐渐称为主流了。 一开始因特网并不支持这么复杂的APP。它只是一些HTML的文件集合,从一个链接到另一个而已,这里的一切大部分都是建立在HTTP规范的request/response 模式之上。用户点击一个页面,客户端就加载这个页面。 大概2005年,AJAX被引入了。很多人开始探索客户端和服务端双向通信的可能性。不过,所有的 HTTP 链接是由客户端控制的,所以必须要用户进行操作或者定期轮询以从服务器加载数据新数据。

让HTTP可以双向通信

支持服务端单向给客户端发送信息的技术已经出现很久了。比如 "Push" 和 "Comet" 技术。

长轮询是服务端‘主动’向客户端发送数据的最常见的 hack 之一。
通过长轮询,客户端打开了一个到服务端的 HTTP ,并且保持它是open的,直到返回响应数据。当服务端有新数据需要发送时,它会把新数据作为响应发送给客户端。 如下:

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的多路请求,以及[htmlfiles]等。 这些方案有同样的问题,HTTP负载太多,这就不太适用低延迟的WEB 应用。比如多玩家的第一人称射击游戏,这种游戏需要一个实时的通信。

WebSocket简介

WebSocket规则定义了一个API,在服务端和浏览器之间建立一个“socket” 连接。“socket” 是一个持久连接,在任意时刻,服务器和浏览器都可以互相给对方发信息。

通过WebSocket的 ‘握手’过程,客户端可以建立一个Websocket连接。 这个流程开始时,客户端发送一个常规的HTTP请求到服务端。但是,这个请求的请求头包含了一个'Upgrade'的头部信息,这个头部信息告知服务器,客户端希望建立一个WebSocket连接。 举个栗子:

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

WebSocket URLs 使用*ws** 规范. 如果使用安全连接,可以使用 wss  就像使用 HTTPS.*

这个栗子只是启动了一个打开WebSocket的流程,这个WebScoket连接到websocket.example.com.

该请求的请求头大概是这样

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 实现
var WebSocketServer = require('websocket').server;
var http = require('http');

var server = http.createServer(function(request, response) {
  // process HTTP request. 
});
server.listen(1337, function() { });

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

// WebSocket 服务
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) {
      // 处理 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');

// Show a connected message when the WebSocket is opened.
socket.onopen = function(event) {
  console.log('WebSocket is connected.');
};

现在握手结束了,之前初始化的HTTP连接被一个同样使用TCP/IP协议的WebSocket连接替代了。现在,无论哪一方都可以在任意时间点给对方发送数据了。
使用WebSokcet,只要你愿意,你可以传递任意大的数据,不用担心资源像传统的HTTP那样过载。 WebSokcet是通过message来进行传递数据的,每一个message中包含一个或多个的frames,frame中包含着你的数据(既载荷)。为了确定message可以正确的重建,每一个frame到达客户端时,都加了关于载荷的4-12字节的前缀数据。使用这种基于frame的信息系统,在传递时有效较少了无效荷载的数量,因此减少了很多延迟。

Note: 只有所有的frame被接收到,并且源信息已经被重建了,客户端才会收到新消息的通知。

WebSocket URL

我们简单提起了WebSocket的URL规范,WebSocket引入了两种规范:ws:// 和 wss://

URL 有特地的语法,WebSocket URL明确不支持锚点语法(比如:#sample_anchor

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

frame协议

深入了解一下frame协议, 这是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 (1位): 除非使用协商重新定义了0的含义,否则此处必须是0,否则,接收端会中断连接。
  • opcode (4 位): frames表示的内容,当前使用的这些 0x00: 这个frame 前一帧的后续载荷 0x01: 这个frame 包含text数据 0x02: 这个frame 包含二进制数据 0x08: 这个frame 结束连接 0x09: 这个frame 是一个ping 0x0a: 这个frame 是一个pong (如你所见,还有很多值没有使用,这些值在未来会被用起来).
  • mask (1 位): 表示连接是否是遮罩的。客户端到服务端的每一个message 必须是遮罩的 ,如果没有遮罩,根据规范会中断连接
  • payload_len (7 位): 载荷的长度. WebSocket frame 有以下几中长度:
    0–125 表示载荷的长度
    126 意思是接下来两位的值,是有效载荷的长度
    127 意思是接下来的8位,是有效载荷的长度
    所以有效载荷是3类~7位, 16位, 和 64位
  • masking-key (32 位):所有从客户端发送到服务端的frames 被一个包含在frame中的32位值遮罩了。
  • payload: 被遮罩的真实的数据。它的长度是payload_len的长度。 为什么WebSocket 是基于frame的而不是基于stream的?我也不清除,如果你知道的话一起讨论一下把。当然,这里有一个很好的博客.

frame 上的数据

像上面提到的,数据额可以被放置到多个frame里。第一个frame中包含一个操作码,它表示传递的数据类型。这是很必要的,因为JS规范从诞生时就不支持二进制数据

  • 0x01 表示utf-8编码的文本数据
  • 0x02 表示二进制数据。 大多数人喜欢传递JSON,这种场景下大家倾向选择文本操作码。当你传递二进制数据,浏览器会将其指定为Blob. 举个栗子:
var socket = new WebSocket('ws://websocket.example.com');
socket.onopen = function(event) {
  socket.send('Some message'); // Sends data to server.
};

当WebSocket接收到数据(客户端), 会触发一个message 事件。这个事件包含了一个data属性,这个属性可以访问到message的内容。

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

使用Chrome DevTools中的Network页面,你可以查看到WebSocket连接中每一个frame里面的数据。

image.png

frame化

荷载数据可以被切割到多个独立的frame中。接受的信息假设是被缓存起来的,直到fin位被设置。你可以传递一个“Hello World”在11个包里,没一个包有6 (头部长度) + 1 位。frame不允许去控制包的。但是协议希望你可以处理交错的受控的frame。这是因为TCP包是不按顺序达到的。

连接frame的逻辑大概如下:

  • 接受第一个frame
  • 记住操作码
  • fin位有值时,将frame荷载连接到一起
  • 断言每一个包的操作码是0

frame化的主要目的是,当message启动时,允许发送大小未知的数据。通过frame化,服务端或者选择一个更合理的缓存池,当缓存满了,就往网络中写一个frame。frame化的另一个用途是多路复用。一逻辑信道上的大量数据占据整个输出通道是不合理的,所以多路复用需要自由的将数据分割成更小的frame,这样能更好的共享输出信道。

什么是心跳?

握手之后,客户端和服务端在任意时间都可以发送ping给对方。当ping被接受,接收方必须尽快回复一个pong。这就是心跳。你可以用它来确保客户端依然是连接的。

ping和pang都只是一个常规的frame,但是是一个受控的frame。ping的操作码是 0x9,pong的操作码是0xA。当你获得一个ping,回复一个和ping的荷载数据一抹一眼的pong(ping和pong的最大荷载长度是125)。有时候你没发送ping,却收到了一个pong,这时候就无视这个pong。

心跳是很有用的。有很多服务器(像负载均衡)将会中断空闲的连接。另外,接收端是不可能知道远程端是否是关闭的。只有在下一次发送的时候,你才知道连接有问题了。

异常处理

You can handle any errors that occur by listening out for the error event. 通过监听error 事件,处理任何异常。 像这样:

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

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

关闭连接

客户端或者服务端发送一个包含0x8的操作码的受控frame,将会关闭连接。一旦接受这样一个frame,另一端发送一个关闭frame的回应。第一个端就关闭了连接。关闭连接之后,再接受的数据都会被放弃。

看一个初始化关闭客户端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 and HTTP/2

HTTP/2提供了很多功能,但是不能完全替代存在已久的push/streaming技术。 首先要注意的是,HTTP/2 不能替代所有的HTTP。词汇,状态码,大部分的头部信息依然是保留的。HTTP/2只是提升了传输效率。 比较一下HTTP/2 和WebSocket:

image.png

如上所见,HTTP/2 引入了 服务端推送,可以让服务端主动的给客户端缓存发送资源。但是,它不允许向客户端本身推送数据。服务端推送只能让浏览器处理,不能上升到代码中,这意味着没有api让应用去获取事件的通知。
这样SSE( Server-Sent Events服务端发送事件)就很有用了。一旦客户端连接建立,SSE机制允许服务端异步的推送数据到客户端。只要一个数据片段是可用的,服务器就会去发送数据。这可以当成是一个单向的发布订阅模式。它也可以提供一名为个EventSource的标准的JS 客户端 API,EventSource作为H5的一部分,如今被大多数浏览器实现了,一些不支持的浏览器也可以使用polyfill来实现。

SSE基于HTTP,天然的适配HTTP/2,所以可以合并两者的优点:HTTP/2基于多路stream可以处理有效地传递层,SSE则提供了API给应用以便支持服务端推送。

为了完全理解流和多路复用是什么,我们首先看一下IETF定义:『流』即是在一个 HTTP/2 连接中,在客户端和服务端间进行交换传输的一个独立的双向帧序列。它的主要特点之一即单个的 HTTP/2 连接可以包含多个并发打开的流,在每一终端交错传输来自多个流的frame。

image.png

我们需要记住SSE是基于HTTP的。在HTTP/2的加持下,不仅可以多个SSE 流在一个TCP连接中交织,也可以合并多个SSE流(服务端往客户端推送)和客户端请求。现在,我们有了一个纯粹的HTTP双向连接,它有一些简单的API使得客户端代码可以注册监听服务端信息的推动。 相比SSE跟WebSocket,缺乏双向能力,经常被视为缺陷。HTPP/2弥补了这一缺陷。这就让我们有机会坚持使用基于HTTP通信,而不是WebSocket,

在WebSocket 和HTTP/2中如何抉择

WebSocket当然会在HTTP/2 + SSE的主宰下存在,主要是因为整个技术已经被接受了,在一些特使的场景下,它还是具有比HTTP/2更大的优势,它可以用更小的负载(比如headers)去建立双向通信。

假如你想创建一个大型的多人在线游戏,就需要大量的数据在客户端和服务端传递,这种场景下,WebSocket表现更好。

无论何时,当你需要你一个真正的 低延迟应用,或者接近实时通信的应用,可以使用WebSocket。谨记,需要重新考虑构建你的服务端应用的方式,把注意力转移到事件队列上。

如果你需要显示实时的市场新闻,市场数据,聊天应用等等,HTTP/2 + SSE 将会给你提供一个有效地双向通信通道,并且得到了HTTP带来的好处:

  • 考虑到兼容问题时,WebSockets 就是问题来源,尤其是你要把HTTP连接的接口升级到一个与HTTP完全不想管的协议上。
  • 伸缩性和安全性:Web 组件(防火墙,入侵检测,负载均衡)的构建,维护和HTTP配置都是要考虑的。大型/重要的应用更依赖于弹性的,安全的,可伸缩的环境。

同样,要考虑浏览器对WebSocket的支持情况:

image.png HTTP/2的情况是这样的:

image.png

  • 仅支持TLS-only
  • 只在 Windows 10的IE 11 上部分支持
  • 只在OSX 10.11+ 的Safari中支持
  • 协商使用ALPN时,才支持(你的服务端需要明确支持)

SSE 支持的更好一点:

image.png 只有 IE/Edge(新Edge采用了chromium内核,是支持的) 不支持。(Opera迷你既不支持SSE也不支持WebSockets)。我们需要一些精巧的polyfill让IE/Edge支持SSE