欢迎来到旨在探索 JavaScript 以及它的核心元素的系列文章的第五篇。在认识、描述这些核心元素的过程当中,咱们也会分享一些当咱们构建 SessionStack 的时候遵照的一些经验规则,这是一个轻量级的 JavaScript 应用,其具有的健壮性和高性能让它在市场中保有一席之地。javascript
若是你错过了前面的文章,你能够在这儿找到它们:html
这一次,咱们将深刻到通讯协议中,去讨论和对比 WebSockets 和 HTTP/2 的属性和构成。咱们将快速比较 WebSockets 和 HTTP/2,并在最后,针对网络协议,分享一些如何选择这2种技术的想法。前端
如今,富交互 web 应用已然司空见惯了。因为 internet 通过了漫长的发展,这一点看起来也不足为奇了。java
最初,internet 的创建不是为了支持这样动态的、复杂的 web 应用程序。它只被认为是一个 HTML 页面的集合,页面间可以连接到其余页面,从而构成了一个 “web” 这样一个信息载体的概念。internet 中每一个事物都是由 HTTP 中的请求/响应(request/response)范式构建而成。一个客户端加载了一个页面后将不会再发生任何事,除非用户点击并跳转到了下一页。react
2005 年左右,AJAX 技术的引入让许多人开始探索客户端和服务器间**双向通讯(bidirectional)**的可能。然而,全部的 HTTP 通讯都是由客户端掌控的,这要求用户交互式地或者周期轮询式地去从服务器拉取新数据。android
可以让服务器“主动地”发送数据给客户端的技术已经出现了一段时间了,例如 “Push” 和 “Comet”。ios
为了制造出服务器主动给客户端发送数据的假象,最经常使用的一个 hack 是长轮询(long polling)。经过长轮询,客户端打开了一个到服务端的 HTTP 链接,该链接会一直保持直到有数据返回。不管何时服务器有了须要被送达的数据,它都会将数据做为一个响应传输到客户端。git
让咱们看看一个很是简单的长轮询代码片断长什么样:github
(function poll(){
setTimeout(function(){
$.ajax({
url: 'https://api.example.com/endpoint',
success: function(data) {
// 使用 `data` 来作一些事
// ...
// 递归地开始下一次轮询
poll();
},
dataType: 'json'
});
}, 10000);
})();
复制代码
这是一个自执行函数,它将自动运行。其设置了一个 10 秒的间隔,当一个异步请求发送完成后,在其回调方法中又会再次调用这个异步请求`。web
其余一些技术还涉及到了 Flash 、 XHR multipart request 以及 htmlfiles 。
全部的这些方案都面临了相同的问题:它们都是创建在 HTTP 上的,这就使得它们不适合那些须要低延迟的应用。例如浏览器中的第一人称射击这样实时性要求高的在线游戏。
WebSocket 规范定义了一个 API 用来创建一个 web 浏览器和服务器之间的 “socket” 通讯。通俗点说,客户端和服务器间将创建一个持续的链接,这让双方都能在任什么时候候发送数据给彼此。
客户端经过一个被称为 WebSocket **握手(handshake)**的过程创建一个 WebSocket 链接。该过程开始于客户端发送了一个普通的 HTTP 请求到服务器。一个 Upgrade
header 包含在了请求头中,它告诉了服务器如今客户端想要创建一个 WebSocket 链接。
让咱们看看在客户端如何打开一个 WebSocket 链接:
// 建立一个具备加密链接的 WebSocket
var socket = new WebSocket('ws://websocket.example.com');
复制代码
WebSocket URL 使用了
ws
scheme。也可使用wss
来服务于安全的 WebSocket 链接,这相似于HTTPS
。
这个 scheme 仅只是启动了一个进程来打开客户端到 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 中这是如何实现的:
// 咱们使用这个 WebSocket 实现: https://github.com/theturtle32/WebSocket-Node
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) {
// 处理 WebSocket 消息
});
connection.on('close', function(connection) {
// 链接关闭时进行的操做
});
});
复制代码
在链接创建之后,服务器经过响应头的 Upgrade
进行回复:
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 被打开后,显示一条已链接消息。
socket.onopen = function(event) {
console.log('WebSocket is connected.');
};
复制代码
如今,握手完成,最初的一个 HTTP 链接被一个使用相同底层 TCP/IP 链接的 WebSocket 链接所取代。自此,任何一方均可以开始发送数据了。
经过 WebSockets,你能够尽情地传输数据,而不会遇到使用传统 HTTP 请求时的瓶颈。使用 WebSocket 传输的数据被称做消息(messages),每一条消息都包含了一个或多个帧(frames),它们承载了你要发送的数据(payload)。为了保证消息在送达客户端之后可以被正确解析,每一帧都会在头部填充关于 payload 的 4-12 个字节。基于帧的消息系统可以减小非 payload 数据的传输数量,从而大幅减小延迟。
注意:须要留意的是,只有当全部帧都到达,而且原始消息 payload 也被解析,客户端才会接受新消息通知。
前文中,咱们简要介绍了 WebSocket 引入了一个新的 URL scheme。实际上,其引入了两个新的 schema(协议标识符):ws://
和 wss://
。
WebSocket URLs 则有一个指定 schema 的语法。WebSocket URLs 较为特别,它们并不支持锚点(anchor),例如 #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
(1 bits):指出了当前帧是消息的最后一帧。绝大多数时候消息都能被一帧容纳,因此这一个 bit 一般都会被设置。实验显示 FireFox 将会在 32K 以后建立第二个帧。rsv1
、rsv2
、rsv3
(每一个都是 1 bits):除非扩展协议为它们定义了非零值的含义,不然三者都应当被设置为 0。若是收到了一个非零值,而且没有任何没有任何扩展协议定义了该非零值的意义,那么接收端将会使此次链接失败。opcode
(4 bits):说明了帧的含义。下面是一些常用的取值: 0x00
:当前帧继续传输上一帧的 payload。
0x01
:当前帧含有文本数据。
0x02
:当前帧含有二进制数据。
0x08
:当前帧终止了链接。 0x09
:当前帧为 ping。 0x0a
:当前帧为 pong。
(如你所见,还有不少取值未被使用,将来它们会被用做表示其余含义。)
mask
(1 bits):指示了链接是否被掩码。就目前来讲,每条从客户端到服务器的消息都必须通过掩码处理,不然,按规定须要终止链接。
payload_len
(7 bits):payload 长度。WebSocket 的帧长度区间为:
若是是 0–125,则直接指示了 payload 长度。若是是 126,则意味着接下来两个字节将指明长度,若是是 127,则意味着接下来 8 个字节将指明长度。因此,一个 payload 的长度将多是 7 bit、16 bit 或者 64 bit 之内。
masking-key
(32 bits):全部由客户端发送给服务器的帧都被一个包含在帧里面的 32 bit 的值进行了掩码处理。
payload
:极大可能被掩码了的实际数据,由 payload_len
标识了长度。
为何 WebSocket 是基于帧(frame-based)的,而不是基于流(stream-based)的?我和你同样都不清楚,我也苛求学到更多,若是你对此有任何看法,能够在文章下面评论留言。固然,也能够加入到 HackerNews 上这个主题的讨论中。
如上文所述,一段数据能够被分片为多个帧。传输数据的第一帧中经过一个 opcode 指出了须要被传输的数据是什么类型。这是很是必要的,由于当规范出台时,JavaScript 还没有对二进制数据提供支持。0x01
指出了数据是 utf-8 编码的文本数据,0x02
指出了数据是二进制数据。大多数人们会在传输 JSON 时选择文本 opcode。当你发送二进制数据时,数据会在浏览器中以一种特殊的 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
的属性能够被用来访问消息内容。
// 处理服务器送来的数据。
socket.onmessage = function(event) {
var message = event.data;
console.log(message);
};
复制代码
经过 Chrome 开发者工具中的 Network Tab,你能够很容易地查看 WebSocket 链接中的每一帧数据。
payload 能够被划分为多个独立的帧。接收端被认为可以缓存这些帧,直到某个帧的 fin
位被设置。因此你能够用 11 个包传输 “Hello World” 字符串,每一个包大小为 6(头部长度)+ 1 字节。对于控制包(control package)来讲,分片则是不被容许的。然而,你被要求可以处理交错的控制帧。这是为了应付 TCP 包是以任意序列到达的情况。
合并各个帧的逻辑大体以下:
fin
被设置分片的主要目的在于当消息传输开始时,容许传输一个未知大小的消息。经过分片技术,服务器能够选择合理的大小的 buffer,并在 buffer 充满时,写入一个分片到网络中。分片技术的次要用例则是多路复用(multiplexing),让某个逻辑信道上的大消息占据整个输出信道是不可取的,所以多路复用须要可以支持将消息划分为若干小的分片,从而更好的共享输出信道。
握手完成以后的任意时刻,客户端或者服务器都可以发送一个 ping 到对面。当 ping 被接收之后,接收方必须尽快回送一个 pong。这就是一次心跳,你能够经过这个机制来确保客户端仍处于链接状态。
一个 ping 或者 pong 只是普通的一个帧,但它们是控制帧(control frame)。Ping 的 opcode 为 0x9
,pong 则为 0xA
。当你收到了一个 ping,你回送的 pong 须要和 ping 具备同样的 payload data(ping 和 pong 容许的最大 payload 长度为 125)。若是你收到了没有和一个 ping 结对的 pong 的话,直接忽略便可。
心跳机制是很是有用的。例如负载均衡这样的一些服务可能会终止掉空闲链接,所以你须要利用心跳机制观测链接情况。另外,收信方是没法知道远端链接是否终止。只有下一次发送消息时才能知道远端是否被终止。
你可以经过监听 event
事件处理任何发生的错误。
就像下面这样:
var socket = new WebSocket('ws://websocket.example.com');
// 处理任何发生的错误。
socket.onerror = function(error) {
console.log('WebSocket Error: ' + error);
};
复制代码
为了关闭链接,客户端或服务端均可以发送一个 opcode 为 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) {
// 链接关闭了
});
复制代码
即使 HTTP/2 有不少优势,但其也没法彻底替代现有的 push/streaming 技术。
对 HTTP/2 的首要认识是知道它不是 HTTP 的彻底替代。HTTP verb、状态码以及大多数头部内容都仍然保持了一致。HTTP/2 着眼于提升数据的传输效率。
如今,若是咱们对比 HTTP/2 和 WebSocket,会发现两者许多类似之处:
HTTP/2 | WebSocket | |
---|---|---|
头部(Headers) | 压缩(HPACK) | 不压缩 |
二进制数据(Binary) | Yes | 二进制或文本数据 |
多路复用(Multiplexing) | Yes | Yes |
优先级技术(Prioritization) | Yes | Yes |
压缩(Compression) | Yes | Yes |
方向(Direction) | Client/Server + Server Push | 双向的 |
全双工(Full-deplex) | Yes | Yes |
正如咱们以前提到的,HTTP/2 引入了 Server Push 来容许服务器主动地发送资源到客户端缓存中。可是,并不容许直接发送数据到客户端应用程序中。服务器推送的内容只能被浏览器处理,而不是客户端应用程序代码,这意味着应用中没有 API 可以感知到推送。
这也让 Server-Sent Events(SSE)变得颇有用。当客户端和服务器的链接创建后,SSE 这个机制可以让服务器异步地推送数据到客户端。以后,服务器随时均可以在准备好后发送数据。这能够被看做是单向的 发布-订阅 模型。SSE 还提供了一个叫作 EventSource 的标准 JavaScript 客户端 API,这个 API 已经被大多数现代浏览器做为 W3C 所制定的HTML5 标准的一部分所实现了。对于那些不支持 EventSource API 的浏览器来讲,这些 API 也能被轻易地 polyfill。
因为 SSE 是基于 HTTP 的,因此它自然亲和 HTTP/2,所以能够组合两者,以吸收各自精华:HTTP/2 经过多路复用流来提升传输层的效率,SSE 则为客户端应用程序提供了接收推送的 API。
为了完整地解释流和多路复用是什么,让咱们先看看 IETF 对此的定义:
“流(stream)” 是一个独立的、双向的帧序列,这些帧在处于 HTTP/2 链接中的客户端和服务器之间交换。其主要特征是一个单个 HTTP/2 链接能够包含多个同时打开的流,任意一端均可以交错地使用这些流中的帧。
要记住 SSE 是基于 HTTP 的。这意味着经过使用 HTTP/2,不只可以将 SSE 流交错地送入到一个 TCP 链接中去,也能完成 SSE 流(服务器向客户端推送)的合并的和客户端请求(客户端到服务器)的合并。得益于 HTTP/2 和 SSE,咱们如今获得了一个具备简洁 API 的 HTTP 双向链接,这让应用代码能监听到服务器推送。曾几什么时候,双向通讯能力的缺失成为了 SSE 相对于 WebSocket 的主要缺陷。但 HTTP/2 让这再也不成为问题。这使得开发者可以回归到基于 HTTP 的通讯方式,而再也不使用 WebSocket。
在 HTTP/2 + SSE 的大浪潮中,WebSocket 仍将保有一席之地,由于它已经被普遍使用,在一些很是特殊的使用场景下,相较于 HTTP/2,其优点在于可以以更少的开销(如头部信息)来构建应用的双向通讯能力。
假若你想要构建一个端到端之间须要传输大量消息的大型多人在线游戏,WebSocket 将很是很是适合。
通常而言,当你须要真正的低延迟,但愿客户端和服务器能有接近实时的链接,就使用 WebSocket。这就可能须要你从新审视和构建你的服务端应用,并聚焦到事件队列这样的技术上。
若是你的使用场景是展现实时市场新闻、市场数据、或是聊天应用等等,那么 HTTP/2 + SSE 能让你继续受益于 HTTP 世界时,还能享受到高效的双向通讯通道:
接下来,你能够看下几种技术的浏览器支持情况。首先看到 WebSocket:
WebSocket 兼容性问题如今好多了,是吧?
HTTP/2 则有些尴尬:
SSE 的支持则更好一些:
只有 IE/Edge 没有提供支持(Opera Mini 既不支持 SSE,也不支持 WebSocket,咱们把它排除在外)。但在 IE/Edge 中,有一些正式的 polyfill 可以帮助支持 SSE。
咱们在 SessionStack 中按需使用了 WebSocket 和 HTTP。一旦你将 SessionStack 集成到你的应用中,它就开始记录全部的 DOM 改变、用户交互、JavaScript 异常、堆栈跟踪、失败的网络请求以及 debug 信息,容许你经过视频来复现问题,从而了解到用户到底作了什么。SessionStack 是彻底实时的而且不会对你的应用形成任何的性能影响。
这意味着,当用户在使用浏览器时,你能够实时地观察用户的行为。在这个场景下,因为不须要双向通讯(只是服务器将数据流发送到浏览器),因此咱们选择了 HTTP。WebSocket 在这个场景下则显得大材小用了,难于维护和扩展。
然而集成到你应用中的 SessionStack 库倒是使用的 WebSocket(若是支持的话,不然会退回到 HTTP)。其批量发送数数据到咱们服务器,这也是一个单向通讯。这个场景下,咱们仍选择 WebSocket 是由于其为产品蓝图中的一些须要双向通讯的特性提供了支持。
尝试使用 SessionStack 来了解和重现你 web 应用中存在的技术或者体验问题,咱们为你提供了一个免费计划让你 快速开始。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、React、前端、后端、产品、设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。