JS实时通信三把斧系列之二: socket.io

林光

介绍完上一篇文章websocket,我们把视线转移到第二个RTC利器:socket.io。估计有童鞋就会问,websocket和socket.io有啥区别啊?

在了解socket.io之前,我们先聊聊websocket(长连接)的实现背景。

1、长连接的实现背景

在现实产品中,并不是所有的客户端都支持长连接的,或者换句话说,在websocket协议出来之前,是有两种方式去实现websocket类似的功能的。

  1. Flash: 使用Flash是一种简单的方法。不过很明显的缺点就是Flash并不会安装在所有客户端上,比如iPhone/iPad。
  2. AJAX Long-Polling: AJAX长轮询已经被用来模拟websocket有一段时间了。这是一种有效的技术,但并没有对消息发送进行优化。虽然我不会把AJAX长轮询当做一种hack技术,但它确实不是一个最优方法

那么如果单纯地使用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的实际应用是怎样的,需要注意些什么?

如果有童鞋对上述问题已经清楚,想必就没有往下读的必要了。

2、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下,你可以定义任意数量的通道,我们称之为"房间",你可以加入或者离开房间,甚至广播消息到指定的房间。

Note Socket.IO不是websocket的实现,虽然 Socket.IO确实在可能的情况下会去使用Websocket作为一个transport,但是它添加了很多元数据到每一个报文中:报文的类型以及namespace和ack Id。这也是为什么websocket客户端不能够成功连接上 Socket.IO 服务器,同样一个 Socket.IO 客户端也连接不上Websocket服务器的原因。

3、engine.io协议的介绍

完整的engine.io协议的握手过程如下图:

当前engine.io协议的版本是3,我们根据上图来大致介绍engine.io协议

3.1、协议请求字段

我们看到的是请求的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');

3.2、数据包编码要求

engine.io协议的数据包编码有自己的一套格式,在协议介绍上engine.io-protocol,定义了两种编码类型:
1. packet
2. payload

3.2.1、packet

一个编码过的packet是下面这种格式:

<packet type 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连接的时候

3.2.2、payload

那payload也有对应的格式要求:

  1. 如果当只有发送string并且不支持XHR的时候,其编码格式是:<length1>:<packet1>[<length2>:<packet2>[...]]

  2. 当不支持XHR2并且发送二进制数据,但是使用base64编码字符串的时候,其编码格式是:<length of base64 representation of the data + 1 (for packet type)>:b<packet1 type><packet1 data in b64>[...]

  3. 当支持XHR2的时候,所有的数据都被编码成二进制,格式是:<0 for string data, 1 for binary data><Any number of numbers between 0 and 9><The number 255><packet1 (first type, then data)>[...]

  4. 如果发送的内容混杂着UTF-8的字符和二进制数据,字符串的每个字符被写成一个字符编码,用1个字节表示。

    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。

3.3、升级协议的必备过程

协议定义了transport升级到websocket需要经历一个必须的过程,如下图:

websocket的测试开始于发送probe,如果服务器也响应probe的话,客户端就必须发送一个upgrade包。

为了确保不会丢包,只有在当前transport的所有buffer被刷新并且transport被认为paused的时候才可以发送upgrade包。服务端收到upgrade包的时候,服务端必须假设这是一个新的通道并发送所有已存的缓存到这个通道上

在Chrome上的效果如下:

4、engine.io的代码实现

熟悉了engine.io协议之后,我们看看代码是怎么实现主流程的。

客户端的engine.io的主要实现流程我们在上面文字介绍了,结合代码engine.io,画了这么一个客户端流程图:

服务端的代码和客户端非常相似,其实现流程图如下:

5、socket.io的应用以及坑

5.1、搭配nginx使用

在实际应用中,socket.io服务器都会部署在nginx后面,所以我们需要配置nginx几个配置:

  1. 需要添加下面两行配置:
proxy_set_header Upgrade $http_upgrade;  
proxy_set_header Connection "Upgrade";  
  1. 如果有多个实例启动的话,需要保证某个ip连接到某个实例之后,一直保持和该实例的连接,而不是被负载均衡随机分配实例,还需要配置下面一行:
upstream {  
  ip_hash; // 主要这行,该行还必须在ip:port之前,否则会有警告出现
  ip:port;
  ip:port;
  ....
}

二者有任何一个没有配置,客户端都会出现下图中类似的错误:

5.2、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目前是没有这样实现的。关于这个问题如果也影响了大家的实现的话,这个只能改动源码,具体改哪里,童鞋们自行去思考吧。

5.3、根ns

另外需要注意的是,使用socket.io的话,是有默认的namespace(/),所以无论客户端连接的ns是哪一个,都会先进入根ns的,并且会记录下这个客户端的。换句话说,如果你有2个客户端连接ns1,3个客户端连接ns2的话,那么在/下就会有5个客户端,并且在根ns下监听connection事件也是会进入的。

结语

最后一篇关于RTC的文章是:JS实时通信三把斧系列之三: eventsource