介绍完上一篇文章websocket
,我们把视线转移到第二个RTC利器:socket.io
。估计有童鞋就会问,websocket
和socket.io
有啥区别啊?
在了解socket.io
之前,我们先聊聊websocket
(长连接)的实现背景。
在现实产品中,并不是所有的客户端都支持长连接的,或者换句话说,在websocket
协议出来之前,是有两种方式去实现websocket
类似的功能的。
Flash
: 使用Flash是一种简单的方法。不过很明显的缺点就是Flash并不会安装在所有客户端上,比如iPhone/iPad。AJAX Long-Polling
:AJAX长轮询已经被用来模拟websocket
有一段时间了。这是一种有效的技术,但并没有对消息发送进行优化。虽然我不会把AJAX
长轮询当做一种hack
技术,但它确实不是一个最优方法那么如果单纯地使用websocket
的话,那些不支持的客户端怎么办呢?难道直接放弃掉?当然不是。Guillermo Rauch
大神写了socket.io
这个库,对websocket
进行封装,从而让长连接满足所有的场景,不过当然得配合使用对应的客户端代码。
socket.io
将会使用特性检测的方式来决定以websocket/ajax长轮询/flash
等方式建立连接,那么socket.io
是如何做到这些的呢?我们带着以下几个问题去学习:
如果有童鞋对上述问题已经清楚,想必就没有往下读的必要了。
socket.io
的介绍读过第一篇文章的童鞋都知道了websocket
的功能,那么socket.io
相对于websocket
,在此基础上封装了一些什么新东西呢?
socket.io
其实是有一套封装了websocket
的协议,叫做engine.io
协议,在此协议上实现了一套底层双向通信的引擎Engine.io
。
而socket.io
则是建立在engine.io
上的一个应用层框架而已。所以我们研究的重点便是engine.io
协议。
在socket.io的README中提到了其实现的一些新特性(问题一):
Note
Socket.IO
不是websocket
的实现,虽然Socket.IO
确实在可能的情况下会去使用Websocket
作为一个transport
,但是它添加了很多元数据到每一个报文中:报文的类型以及namespace和ack Id。这也是为什么websocket
客户端不能够成功连接上 Socket.IO 服务器,同样一个 Socket.IO 客户端也连接不上Websocket服务器的原因。
engine.io
协议的介绍完整的engine.io
协议的握手过程如下图:
当前engine.io
协议的版本是3,我们根据上图来大致介绍engine.io
协议
我们看到的是请求的url
和websocket
不大一样,解释一下:
EIO=3
: 表示的是使用的是Engine.io协议版本3transport=polling/websocket
: 表示使用的长连接方式是轮询还是websockett=xxxxx
: 代码中使用yeast根据时间戳生成一个唯一的字符串sid=xxxx
: 客户端和服务器建立连接之后获取到的session id,客户端拿到之后必须在每次请求中追加这个字段除了上述的3个字段,协议还描述了下面几个字段:
transport
是polling
,但是要求有一个JSONP
的响应,那么j就应该设置为JSONP
响应的索引值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');
engine.io
协议的数据包编码有自己的一套格式,在协议介绍上engine.io-protocol
,定义了两种编码类型:
packet
一个编码过的packet
是下面这种格式:
[]
然后协议定义了下面几种packet type
(采用数字进行标识):
packet
pong
包payload
那payload
也有对应的格式要求:
string
并且不支持XHR
的时候,其编码格式是::[:[...]]
XHR2
并且发送二进制数据,但是使用base64
编码字符串的时候,其编码格式是::b[...]
<0 for string data, 1 for binary data>[...]
TIPS: 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,最后就拼成刚才大家看到的结果。
Tips
ping/pong
的间隔时间是服务端告知客户的:"pingInterval":25000,"pingTimeout":60000
,也就是说心跳时间默认是25
秒,并且等待pong
响应的时间默认是60s
。
协议定义了transport
升级到websocket
需要经历一个必须的过程,如下图:
websocket
的测试开始于发送probe
,如果服务器也响应probe
的话,客户端就必须发送一个upgrade
包。
为了确保不会丢包,只有在当前transport
的所有buffer
被刷新并且transport
被认为paused
的时候才可以发送upgrade
包。服务端收到upgrade
包的时候,服务端必须假设这是一个新的通道并发送所有已存的缓存到这个通道上
engine.io
的代码实现熟悉了engine.io
协议之后,我们看看代码是怎么实现主流程的。
客户端的engine.io
的主要实现流程我们在上面文字介绍了,结合代码engine.io
,画了这么一个客户端流程图:
服务端的代码和客户端非常相似,其实现流程图如下:
socket.io
的应用以及坑nginx
使用在实际应用中,socket.io
服务器都会部署在nginx
后面,所以我们需要配置nginx
几个配置:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
如果有多个实例启动的话,需要保证某个ip连接到某个实例之后,一直保持和该实例的连接,而不是被负载均衡随机分配实例,还需要配置下面一行:
upstream {
ip_hash; // 主要这行,该行还必须在ip:port之前,否则会有警告出现
ip:port;
ip:port;
....
}
io.use
中间件的诡异行为在实际应用中发现如下注释的行为,请参考demo代码io.js
// socket.io这边有个很奇怪的,如果我使用io.use的话,那么只有连接根ns的客户端才会收到这个错误的packet
// 而不会让某个ns的客户端收到,但是这个中间件的函数却是监听所有的socket的。
io.use((socket, next) => {
console.log('middleware has triggered.......')
// if (socket.request.headers.cookie) return next();
next(new Error('Authentication error'));
})
现象: 在页面中点击连接到根ns,测试服务端的根中间件的问题按钮是会收到错误消息,但是点击其他ns却不会收到错误消息。
虽然每个ns下是有提供了ns的中间件,但是我们更希望有一个普遍的中间件去使用,但是很明显socket.io目前是没有这样实现的。关于这个问题如果也影响了大家的实现的话,这个只能改动源码,具体改哪里,童鞋们自行去思考吧。
ns
另外需要注意的是,使用socket.io
的话,是有默认的namespace(/)
,所以无论客户端连接的ns
是哪一个,都会先进入根ns
的,并且会记录下这个客户端的。换句话说,如果你有2个客户端连接ns1
,3个客户端连接ns2
的话,那么在/下就会有5个客户端,并且在根ns
下监听connection
事件也是会进入的。