本文引用自“ 豆米博客”的《JS实时通信三把斧》系列文章,有优化和改动。
有关Web端即时通讯技术的文章我已整理过很多篇,阅读过的读者可能都很熟悉,早期的Web端即时通讯方案,受限于Web客户端的技术限制,想实现真正的“即时”通信,难度相当大。
传统的Web端即时通讯技术从短轮询到长连询,再到Comet技术,在如此原始的HTML标准之下,为了实现所谓的“即时”通信,技术上可谓绞尽脑汁,极尽所能。
自从HTML5标准发布之后,WebSocket这类技术横空出世,实现Web端即时通讯技术的便利性大大提前,以往想都不敢想的真正全双工实时通信,如此早已成为可能。
本文将专门介绍WebSocket、socket.io、SSE这几种现代的Web端即时通讯技术,从适用场景到技术原理,通俗又不失深度的文字,特别适合对Web端即时通讯技术有一定了解,且想深入学习WebSocket等现代Web端“实时”通信技术,却又不想花时间去深读枯燥的IETF技术手册的读者。
学习交流:
- 即时通讯/推送技术开发交流5群:215477170 [推荐]
- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》
- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK
(本文同步发布于:http://www.52im.net/thread-3695-1-1.html)
“豆米”:现居杭州,热爱前端,热爱互联网,豆米是“洋芋(土豆-豆)”和“米喳(米)”的简称。
作者博客:https://blog.5udou.cn/
作者Github:https://github.com/linxiaowu66/
如果你对Web端即时通讯技术的前世今生不曾了解,建议先读以下文章:
如果你对本文将要介绍的技术已有了解,建议进行专项学习,以便深入掌握:
在这里不打算详细介绍整个WebSocket协议的内容,根据我本人以前协议的学习思路,我挑重点使用问答方式来介绍该协议,这样读起来就不那么枯燥。
协议运行在OSI的哪层?
应用层,WebSocket协议是一个独立的基于TCP的协议。 它与HTTP唯一的关系是它的握手是由HTTP服务器解释为一个Upgrade请求。
协议运行的标准端口号是多少?
默认情况下,WebSocket协议使用端口80用于常规的WebSocket连接、端口443用于WebSocket连接的在传输层安全(TLS)RFC2818之上的隧道化口。
协议的工作流程可以参考下图:
其中帧的一些重要字段需要解释一下:
关于Sec-WebSocket-Key和Sec-WebSocket-Accept的计算是这样的:
所有兼容RFC 6455 的WebSocket 服务器都使用相同的算法计算客户端挑战的答案:将Sec-WebSocket-Key 的内容与标准定义的唯一GUID字符(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)串拼接起来,计算出SHA1散列值,结果是一个base-64编码的字符串,把这个字符串发给客户端即可。
用代码就是实现如下:
const key = crypto.createHash('sha1')
.update(req.headers['sec-websocket-key'] + constants.GUID, 'binary')
.digest('base64')
至于为什么需要这么一个步骤,可以参考《理论联系实际:从零理解WebSocket的通信原理、协议格式、安全性》一文。
引用如下:
Sec-WebSocket-Key/Sec-WebSocket-Accept在主要作用在于提供基础的防护,减少恶意连接、意外连接。
作用大致归纳如下:
强调:Sec-WebSocket-Key/Sec-WebSocket-Accept 的换算,只能带来基本的保障,但连接是否安全、数据是否安全、客户端/服务端是否合法的 ws客户端、ws服务端,其实并没有实际性的保证。
帧格式定义的格式如下:
各个字段的解释如下:
更多细节请参考RFC6455-数据帧,这里不作赘述。
针对上面的各个字段的介绍,有一个Mask的需要说一下。
掩码键(Masking-key)是由客户端挑选出来的32位的随机数。掩码操作不会影响数据载荷的长度。
掩码、反掩码操作都采用如下算法。
首先,假设:
算法描述为: original-octet-i 与 masking-key-octet-j 异或后,得到 transformed-octet-i。
即: j = i MOD 4 transformed-octet-i = original-octet-i XOR masking-key-octet-j
用代码实现:
const mask = (source, mask, output, offset, length) => {
for(vari = 0; i < length; i++) {
output[offset + i] = source[i ] ^ mask[i & 3];
}
};
解掩码是反过来的操作:
const unmask = (buffer, mask) => {
// Required until [url=https://github.com/nodejs/node/issues/9006]https://github.com/nodejs/node/issues/9006[/url] is resolved.
const length = buffer.length;
for(vari = 0; i < length; i++) {
buffer[i ] ^= mask[i & 3];
}
};
同样的为什么需要掩码操作,也可以参考之前的那篇文章:《理论联系实际:从零理解WebSocket的通信原理、协议格式、安全性》,完整的我就不列举了。
需要注意的重点,我引用一下:
WebSocket协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,运算也不复杂。除了加密通道本身,似乎没有太多有效的保护通信安全的办法。
那么为什么还要引入掩码计算呢,除了增加计算机器的运算量外似乎并没有太多的收益(这也是不少同学疑惑的点)。
答案还是两个字: 安全。但并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。
介绍完上一节WebSocket协议,我们把视线转移到现代Web端即时通讯技术的第二个利器:socket.io。
估计有读者就会问,WebSocket和socket.io有啥区别啊?
在了解socket.io之前,我们先聊聊传统Web端即时通讯“长连接”技术的实现背景。
在现实的Web端产品中,并不是所有的Web客户端都支持长连接的,或者换句话说,在WebSocket协议出来之前,是三种方式去实现WebSocket类似的功能的。
这三种方式是:
那么如果单纯地使用WebSocket的话,那些不支持的客户端怎么办呢?难道直接放弃掉?
当然不是。Guillermo Rauch大神写了socket.io这个库,对WebSocket进行封装,从而让长连接满足所有的场景,不过当然得配合使用对应的客户端代码。
socket.io将会使用特性检测的方式来决定以websocket/ajax长轮询/flash等方式建立连接。
那么socket.io是如何做到这些的呢?
我们带着以下几个问题去学习:
如果有童鞋对上述问题已经清楚,想必就没有往下读的必要了。
通过前面章节,读者们都知道了WebSocket的功能,那么socket.io相对于WebSocket,在此基础上封装了一些什么新东西呢?
socket.io其实是有一套封装了websocket的协议,叫做engine.io协议,在此协议上实现了一套底层双向通信的引擎Engine.io。
而socket.io则是建立在engine.io上的一个应用层框架而已。所以我们研究的重点便是engine.io协议。
在socket.io的README中提到了其实现的一些新特性(回答了问题一):
注意:Socket.IO不是WebSocket的实现,虽然 Socket.IO确实在可能的情况下会去使用WebSocket作为一个transport,但是它添加了很多元数据到每一个报文中:报文的类型以及namespace和ack Id。这也是为什么标准WebSocket客户端不能够成功连接上 Socket.IO 服务器,同样一个 Socket.IO 客户端也连接不上标准WebSocket服务器的原因。
完整的engine.io协议的握手过程如下图:
当前engine.io协议的版本是3,我们根据上图来大致介绍一下engine.io协议。
5.4.1)engine.io协议请求字段:
我们看到的是请求的url和WebSocket不大一样,解释一下:
除了上述的3个字段,协议还描述了下面几个字段:
另外engine.io默认的path是 /engine.io,socket.io在初始化的时候设置为了 /socket.io,所以大家看到的path就都是 /socket.io 了:
function Server(srv, opts){
if(!(this instanceof Server)) return new Server(srv, opts);
if('object'== typeof srv && srv instanceof Object && !srv.listen) {
opts = srv;
srv = null;
}
opts = opts || {};
this.nsps = {};
this.parentNsps = new Map();
this.path(opts.path || '/socket.io');
5.4.2)数据包编码要求:
engine.io协议的数据包编码有自己的一套格式,在协议介绍上engine.io-protocol,定义了两种编码类型: packet和payload。
一个编码过的packet是下面这种格式:
[]
然后协议定义了下面几种packet type(采用数字进行标识):
那payload也有对应的格式要求:
注意:payload的编码要求不适用于WebSocket的通信。
针对上面的编码要求,我们随便举个例子.
之前在第一条polling请求的时候,服务端编码发送了这个数据:
97:0{"sid":"Peed250dk55pprwgAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":60000}2:40
根据上面的知识,我们知道第一次服务端会发送一个open的数据包。
所以组装出来的packet是:
0
然后服务端会告知客户端去尝试升级到websocket,并且告知对应的sid。
于是整合后便是:
0{"sid":"Peed250dk55pprwgAAAA","upgrades":"websocket","pingInterval":25000,"pingTimeout":60000}
接着根据payload的编码格式,因为是string,且长度是97个字节。
所以是:
97:0{"sid":"Peed250dk55pprwgAAAA","upgrades":"websocket","pingInterval":25000,"pingTimeout":60000}
接着第二部分数据是message包类型,并且数据是0,所以是40,长度为2字节,所以是2:40,最后就拼成刚才大家看到的结果。
注意:
ping/pong的间隔时间是服务端告知客户端的:"pingInterval":25000,"pingTimeout":60000,也就是说心跳时间默认是25秒,并且等待pong响应的时间默认是60s。
协议定义了transport升级到websocket需要经历一个必须的过程。
如下图:
WebSocket的测试开始于发送probe,如果服务器也响应probe的话,客户端就必须发送一个upgrade包。
为了确保不会丢包,只有在当前transport的所有buffer被刷新并且transport被认为paused的时候才可以发送upgrade包。服务端收到upgrade包的时候,服务端必须假设这是一个新的通道并发送所有已存的缓存到这个通道上
在Chrome上的效果如下:
熟悉了engine.io协议之后,我们看看代码是怎么实现主流程的。
客户端的engine.io的主要实现流程我们在上面文字介绍了。
结合代码engine.io,画了这么一个客户端流程图:
服务端的代码和客户端非常相似,其实现流程图如下:
本文前两节分析了WebSocket和socket.io,现在我们来看看SSE。
很多人也许好奇,有了WebSocket这种实时通信,为什么还需要SSE呢?
答案其实很简单:那就是SSE其实是单向通信,而WebSocket是双向通信。
比如:在股票行情、新闻推送的这种只需要服务器发送消息给客户端场景中,使用SSE可能更加合适。
另外:SSE是使用HTTP传输的,这意味着我们不需要一个特殊的协议或者额外的实现就可以使用。而WebSocket要求全双工连接和一个新的WebSocket服务器去处理。加上SSE在设计的时候就有一些WebSocket没有的特性,比如自动重连接、event IDs、以及发送随机事件的能力,所以各有各的特长,我们需要根据实际应用场景,去选择不同的应用方案。
SSE的简单模型是:一个客户端去从服务器端订阅一条“流”,之后服务端可以发送消息给客户端直到服务端或者客户端关闭该“流”,所以SSE全称叫“server-sent-event”。
相比以前的轮询,SSE可以为B2C带来更高的效率。
有一张图片画出了二者的区别:
SSE必须编码成utf-8的格式,消息的每个字段使用"\n"来做分割,并且需要下面4个规范定义好的字段。
这4个字段是:
下图是通过wireshark抓包得到的数据包的原始格式:
SSE的通信过程比较简单,底层的一些实现都被浏览器给封装好了,包括数据的处理。
大致流程如下:
在浏览器中截图如下:
携带的数据是JSON格式的,浏览器都帮你整合成为一个Object:
在wireshark中,其通信流程如下。
发送请求:
得到响应:
在开始推送信息流之前,服务器还会发送一个客户端会忽略掉的包,这个具体原因不清楚:
断开连接后的重传:
浏览器端的使用:
const es = new EventSource('/sse')
服务端的使用:
const sseStream = new SseStream(req)
sseStream.pipe(res)
sseStream.write({
id: sendCount,
event: 'server-time',
retry: 20000, // 告诉客户端,如果断开连接后,20秒后再重试连接
data: {ts: newDate().toTimeString(), count: sendCount++}
})
更多API使用和demo介绍分别参考:SSE API、demo代码。
兼容性:
▲ 上图来自 https://caniuse.com/?search=Server-Sent-Events
缺点:
[1] WebSocket API文档
[2] SSE API文档
[3] 新手入门贴:史上最全Web端即时通讯技术原理详解
[4] Web端即时通讯技术盘点:短轮询、Comet、Websocket、SSE
[5] SSE技术详解:一种全新的HTML5服务器推送事件技术
[6] Comet技术详解:基于HTTP长连接的Web端实时通信技术
[7] 新手快速入门:WebSocket简明教程
[8] WebSocket详解(三):深入WebSocket通信协议细节
[9] WebSocket详解(四):刨根问底HTTP与WebSocket的关系(上篇)
[10] WebSocket详解(五):刨根问底HTTP与WebSocket的关系(下篇)
[11] 使用WebSocket和SSE技术实现Web端消息推送
[12] 详解Web端通信方式的演进:从Ajax、JSONP 到 SSE、Websocket
[13] MobileIMSDK-Web的网络层框架为何使用的是Socket.io而不是Netty?
[14] 理论联系实际:从零理解WebSocket的通信原理、协议格式、安全性
[15] WebSocket从入门到精通,半小时就够!
[16] WebSocket硬核入门:200行代码,教你徒手撸一个WebSocket服务器
[17] 网页端IM通信技术快速入门:短轮询、长轮询、SSE、WebSocket
本文已同步发布于“即时通讯技术圈”公众号。
同步发布链接是:http://www.52im.net/thread-3695-1-1.html