各个字段的解释如下:
- 1)FIN: 1bit,用来表明这是一个消息的最后的消息片断,当然第一个消息片断也可能是最后的一个消息片断;
- 2)RSV1,RSV2,RSV3: 分别都是1位,如果双方之间没有约定自定义协议,那么这几位的值都必须为0,否则必须断掉WebSocket连接。在ws中就用到了RSV1来表示是否消息压缩了的;
- 3)opcode:4 bit,表示被传输帧的类型:
- - %x0 表示连续消息片断;
- - %x1 表示文本消息片断;
- - %x2 表未二进制消息片断;
- - %x3-7 为将来的非控制消息片断保留的操作码;
- - %x8 表示连接关闭;
- - %x9 表示心跳检查的ping;
- - %xA 表示心跳检查的pong;
- - %xB-F 为将来的控制消息片断的保留操作码。
- 4)Mask: 1 bit。定义传输的数据是否有加掩码,如果设置为1,掩码键必须放在masking-key区域,客户端发送给服务端的所有消息,此位都是1;
- 5)Payload length:传输数据的长度,以字节的形式表示:7位、7 16位、或者7 64位。如果这个值以字节表示是0-125这个范围,那这个值就表示传输数据的长度;如果这个值是126,则随后的两个字节表示的是一个16进制无符号数,用来表示传输数据的长度;如果这个值是127,则随后的是8个字节表示的一个64位无符合数,这个数用来表示传输数据的长度。多字节长度的数量是以网络字节的顺序表示。负载数据的长度为扩展数据及应用数据之和,扩展数据的长度可能为0,因而此时负载数据的长度就为应用数据的长度;
- 6)Masking-key:0或4个字节,客户端发送给服务端的数据,都是通过内嵌的一个32位值作为掩码的;掩码键只有在掩码位设置为1的时候存在;
- 7)Extension data: x位,如果客户端与服务端之间没有特殊约定,那么扩展数据的长度始终为0,任何的扩展都必须指定扩展数据的长度,或者长度的计算方式,以及在握手时如何确定正确的握手方式。如果存在扩展数据,则扩展数据就会包括在负载数据的长度之内;
- 8)Application data: y位,任意的应用数据,放在扩展数据之后,应用数据的长度=负载数据的长度-扩展数据的长度;
- 9)Payload data: (x y)位,负载数据为扩展数据及应用数据长度之和;
更多细节请参考RFC6455-数据帧,这里不作赘述。
针对上面的各个字段的介绍,有一个Mask的需要说一下。
掩码键(Masking-key)是由客户端挑选出来的32位的随机数。掩码操作不会影响数据载荷的长度。
掩码、反掩码操作都采用如下算法。
首先,假设:
- 1)original-octet-i:为原始数据的第i字节;
- 2)transformed-octet-i:为转换后的数据的第i字节;
- 3)j:为i mod 4的结果;
- 4)masking-key-octet-j:为mask key第j字节。
算法描述为: 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的通信原理、协议格式、安全性》,完整的我就不列举了。
需要注意的重点,我引用一下:
5、Socket.io5.1 本节引言WebSocket协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,运算也不复杂。除了加密通道本身,似乎没有太多有效的保护通信安全的办法。
那么为什么还要引入掩码计算呢,除了增加计算机器的运算量外似乎并没有太多的收益(这也是不少同学疑惑的点)。
答案还是两个字: 安全。但并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。
介绍完上一节WebSocket协议,我们把视线转移到现代Web端即时通讯技术的第二个利器:socket.io。
估计有读者就会问,WebSocket和socket.io有啥区别啊?
在了解socket.io之前,我们先聊聊传统Web端即时通讯“长连接”技术的实现背景。
5.2 传统Web长连接的技术实现背景在现实的Web端产品中,并不是所有的Web客户端都支持长连接的,或者换句话说,在WebSocket协议出来之前,是三种方式去实现WebSocket类似的功能的。
这三种方式是:
- 1)Flash:使用Flash是一种简单的方法。不过很明显的缺点就是Flash并不会安装在所有客户端上,比如iPhone/iPad。
- 2)Long-Polling:也就是众所周之的“长轮询”,在过去,这是一种有效的技术,但并没有对消息发送进行优化。虽然我不会把AJAX长轮询当做一种hack技术,但它确实不是一个最优方法;
- 3)Comet:在过去,这被称为Web端的“服务器推”技术,相对于传统的 Web 应用, 开发 Comet 应用具有一定的挑战性,具体请见《Comet技术详解:基于HTTP长连接的Web端实时通信技术》。
那么如果单纯地使用WebSocket的话,那些不支持的客户端怎么办呢?难道直接放弃掉?
当然不是。Guillermo Rauch大神写了socket.io这个库,对WebSocket进行封装,从而让长连接满足所有的场景,不过当然得配合使用对应的客户端代码。
socket.io将会使用特性检测的方式来决定以websocket/ajax长轮询/flash等方式建立连接。
那么socket.io是如何做到这些的呢?
我们带着以下几个问题去学习:
- 1)socket.io到底有什么新特性?
- 2)socket.io是怎么实现特性检测的?
- 3)socket.io有哪些坑呢?
- 4)socket.io的实际应用是怎样的,需要注意些什么?
如果有童鞋对上述问题已经清楚,想必就没有往下读的必要了。
5.3 socket.io的介绍通过前面章节,读者们都知道了WebSocket的功能,那么socket.io相对于WebSocket,在此基础上封装了一些什么新东西呢?
socket.io其实是有一套封装了websocket的协议,叫做engine.io协议,在此协议上实现了一套底层双向通信的引擎Engine.io。
而socket.io则是建立在engine.io上的一个应用层框架而已。所以我们研究的重点便是engine.io协议。
在socket.io的README中提到了其实现的一些新特性(回答了问题一):
- 1)可靠性:连接依然可以建立即使应用环境存在: 代理或者负载均衡器 个人防火墙或者反病毒软件;
- 2)支持自动连接: 除非特别指定,否则一个断开的客户端会一直重连服务器直到服务器恢复可用状态;
- 3)断开连接检测:在Engine.io层实现了一个心跳机制,这样允许客户端和服务器知道什么时候其中的一方不能响应。该功能是通过设置在服务端和客户端的定时器实现的,在连接握手的时候,服务器会主动告知客户端心跳的间隔时间以及超时时间;
- 4)二进制的支持:任何序列化的数据结构都可以用来发送;
- 5)跨浏览器的支持:该库甚至支持到IE8;
- 6)支持复用:为了在应用程序中将创建的关注点隔离开来,Socket.io允许你创建多个namespace,这些namespace拥有单独的通信通道,但将共享相同的底层连接;
- 7)支持Room:在每一个namespace下,你可以定义任意数量的通道,我们称之为"房间",你可以加入或者离开房间,甚至广播消息到指定的房间。
注意:Socket.IO不是WebSocket的实现,虽然 Socket.IO确实在可能的情况下会去使用WebSocket作为一个transport,但是它添加了很多元数据到每一个报文中:报文的类型以及namespace和ack Id。这也是为什么标准WebSocket客户端不能够成功连接上 Socket.IO 服务器,同样一个 Socket.IO 客户端也连接不上标准WebSocket服务器的原因。
5.4 engine.io协议介绍完整的engine.io协议的握手过程如下图:
当前engine.io协议的版本是3,我们根据上图来大致介绍一下engine.io协议。
5.4.1)engine.io协议请求字段:
我们看到的是请求的url和WebSocket不大一样,解释一下:
- 1)EIO=3: 表示的是使用的是Engine.io协议版本3;
- 2)transport=polling/websocket: 表示使用的长连接方式是轮询还是WebSocket;
- 3)t=xxxxx: 代码中使用yeast根据时间戳生成一个唯一的字符串;
- 4)sid=xxxx: 客户端和服务器建立连接之后获取到的session id,客户端拿到之后必须在每次请求中追加这个字段。
除了上述的3个字段,协议还描述了下面几个字段:
- 1)j: 如果transport是polling,但是要求有一个JSONP的响应,那么j就应该设置为JSONP响应的索引值;
- 2)b64: 如果客户端不支持XHR,那么客户端应该设置b64=1传给服务器,告知服务器所有的二进制数据应该以base64编码后再发送。
另外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是下面这种格式:
<packettype id>[<data>]
然后协议定义了下面几种packet type(采用数字进行标识):
- 1)0(open): 当开始一个新的transport的时候,服务端会发送该类型的packet;
- 2)1(close): 请求关闭这个transport但是不要自己关闭关闭连接;
- 3)2(ping): 由客户端发送的ping包,服务端必须回应一个包含相同数据的pong包;
- 4)3(pong): 响应ping包,服务端发送;
- 5)4(message): 实际消息,在客户端和服务端都可以监听message事件获取消息内容;
- 6)5(upgrade): 在engine.io切换transport之前,它会用来测试服务端和客户端是否在该transport上通信。如果测试成功,客户端会发送一个upgrade包去让服务器刷新它的缓存并切换到新的transport;
- 7)6(noop): 主要用来强制一个轮询循环当收到一个WebSocket连接的时候。
那payload也有对应的格式要求:
- 1)如果当只有发送string并且不支持XHR的时候,其编码格式是::[:[...]];
- 2)当不支持XHR2并且发送二进制数据,但是使用base64编码字符串的时候,其编码格式是::b[...];
- 3)当支持XHR2的时候,所有的数据都被编码成二进制,格式是:<0 for string data, 1 for binary data>[...];
- 4)如果发送的内容混杂着UTF-8的字符和二进制数据,字符串的每个字符被写成一个字符编码,用1个字节表示。
注意: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,最后就拼成刚才大家看到的结果。
注意:
5.5 升级协议的必备过程ping/pong的间隔时间是服务端告知客户端的:"pingInterval":25000,"pingTimeout":60000,也就是说心跳时间默认是25秒,并且等待pong响应的时间默认是60s。
协议定义了transport升级到websocket需要经历一个必须的过程。
如下图:
WebSocket的测试开始于发送probe,如果服务器也响应probe的话,客户端就必须发送一个upgrade包。
为了确保不会丢包,只有在当前transport的所有buffer被刷新并且transport被认为paused的时候才可以发送upgrade包。服务端收到upgrade包的时候,服务端必须假设这是一个新的通道并发送所有已存的缓存到这个通道上
在Chrome上的效果如下: