影视聚合站 科技 文章内容

WebSocket 基础与应用系列(二)—— Engine.IO 原理了解

发布时间:2022-03-10 21:05:33来源:腾讯IMWeb前端团队

本系列第一篇《》,没看过的同学可以看看,看过的同学也可以回顾一把。

WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocketAPI中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

Socket.IO在Socket.IOserver(Node.js)和Socket.IOclient(browser,Node.js,oranotherprogramminglanguage)之间,基于WebSocket(不支持WebSocket的情况下,退化成HTTPlong-polling)建立一条全双工实时通信通道.

Engine.IO是一个Socket.IO的抽象实现,作为Socket.IO的服务器和浏览器之间交换的数据的传输层。它不会取代Socket.IO,它只是抽象出固有的复杂性,支持多种浏览器,设备和网络的实时数据交换。Engine.IO使用了Websocket和HTTPlong-polling方式封装了一套socket协议。为了兼容不支持Websocket的低版本浏览器,使用长轮询(polling)替代WebSocket。

Engine.IO负责在服务器和客户端之间建立底层连接。包括以下功能:

多种传输通道及升级机制

断连检测

现在主要有2种传输通道实现

HTTPlong-polling

WebSocket

HTTPlong-pollingtransport(也简称"polling")由连续的HTTPrequests组成:

long-runningGETrequests,forreceivingdatafromtheserver

short-runningPOSTrequests,forsendingdatatotheserver

基于HTTPlong-pollingtransport的特性,连续的emits可能合并在一个HTTPRequest中发送。

TheWebSocket传输通道包含一条WebSocket连接,WebSocket提供了服务端和客户端之间双向通信及低时延的通信通道。

基于传输通道特性,每个emit会以一个WebSocket数据帧发送,有时候会分为2个不同的数据帧发送。

Engine.IO连接建立的时候,Server端会发送一些消息到客户端:

{"sid":"FSDjX-WRwSA4zTZMALqx","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000}sid:是session的ID,在所有的子序列HTTPRequest中都会在参数带上这个sid.

upgrades:upgradesarray包含了服务端可以支持的更好的transport.

pingInterval和pingTimeout:用于心跳机制.

默认的情况下,客户端先建立HTTPlong-polling通信通道。

为什么呢?

WebSocket无疑是最好的双向通道,但是由于公司的代理、个人的防火墙、杀毒软件等,它并不是在什么情况下都能成功建立。

从用户的角度来看,如果WebSocket连接建立失败,那么用户至少要等10S才能开始真正的数据传输,这无疑伤害了用户的体验。

总的来说,Engine.IO首先关注可靠性和用户体验,其次才是服务器性能。

升级的时候,客户端会做如下动作:

保证要发送的队列中是空的

把当前的传输通道设为只读

使用另外的transport建立新的连接

如果新传输通道建立成功,关掉第一条传输通道

可以在浏览器抓包看到如下网络连接:

握手协议(containsthesessionID—here,zBjrh...AAAK—thatisusedinsubsequentrequests)

发送数据(HTTPlong-polling)

接收数据(HTTPlong-polling)

升级协议(WebSocket)

接收数据(HTTPlong-polling,closedoncetheWebSocketconnectionin4.issuccessfullyestablished)

当以下情况出现时,Engine.IO的连接会判断为关闭。

一次HTTPrequest(eitherGETorPOST)失败(比如服务器挂了)

WebSocket连接关闭(比如用户关闭了浏览器的tab)

在服务端或者客户端调用socket.disconnect()

还有一个心跳机制用来检测服务端和客户端的连接是否正常在运行。

服务端会以pingInterval的间隔发送PING数据包,客户端收到后在pingTimeout时间之内需要发送PONG数据包给服务端,如果服务端在pingTimeout时间内没有收到,那么就认为这条连接关闭了。相反,客户端如果在pingInterval+pingTimeout时间内没有收到PING数据包,客户端也判断连接关闭。

服务端触发断连事件的原因有:

Reason

Description

servernamespacedisconnect

Thesocketwasforcefullydisconnectedwithsocket.disconnect

clientnamespacedisconnect

Theclienthasmanuallydisconnectedthesocketusingsocket.disconnect()

servershuttingdown

Theserveris,well,shuttingdown

pingtimeout

TheclientdidnotsendaPONGpacketinthepingTimeoutdelay

transportclose

Theconnectionwasclosed(example:theuserhaslostconnection,orthenetworkwaschangedfromWiFito4G)

transporterror

Theconnectionhasencounteredanerror

客户端触发断连事件的原因有:

Reason

Description

ioserverdisconnect

Theserverhasforcefullydisconnectedthesocketwithsocket.disconnect()

ioclientdisconnect

Thesocketwasmanuallydisconnectedusingsocket.disconnect()

pingtimeout

TheserverdidnotsendaPINGwithinthepingInterval+pingTimeoutrange

transportclose

Theconnectionwasclosed(example:theuserhaslostconnection,orthenetworkwaschangedfromWiFito4G)

transporterror

Theconnectionhasencounteredanerror(example:theserverwaskilledduringaHTTPlong-pollingcycle)

传输通道通过Engine.IOURL进行连接建立

连接建立之后,服务端会发一个JSON格式的握手数据

sid:会话id(string)

upgrades:允许升级的传输通道(ArrayofString)

pingTimeout:服务端配置的ping超时时间,发送给客户端,客户端用来检测服务端是否还正常响应(Number)

pingInterval:服务端配置的心跳间隔,客户端用来检测服务端是否还正常响应(Number)

客户端收到服务端定时的pingpacket之后,需要回复客户端pongpacket

客户端和服务端之间可以传输messagepackets

Pollingtransports可以发送closepacket来关闭socket

Requestn°1(openpacket)

GET/engine.io/?EIO=4&transport=polling&t=N8hyd6w"sid":"N-YWtQT1K9uQsb15AAAD","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000}Details:

0=>"open"packettype{"sid":...=>thehandshakedataNote:query参数中的t是用来防止浏览器缓存请求.

Requestn°2(messagein)

服务端执行socket.send('hey'):

GET/engine.io/?EIO=4&transport=polling&t=N8hyd7H&sid=lv_VI97HAXpY6yYWAAAC

4=>"message"packettypehey=>theactualmessageNote:query中的sid是握手协议中sid.

Requestn°3(messageout)

客户端执行:socket.send('hello');socket.send('world');

POST/engine.io/?EIO=4&transport=polling&t=N8hzxke&sid=lv_VI97HAXpY6yYWAAAC>Content-Type:text/plain;charset=UTF-84hello\x1e4world

4=>"message"packettypehello=>the1stmessage\x1e=>separator4=>"message"messagetypeworld=>the2ndmessageRequestn°4(WebSocketupgrade)

GET/engine.io/?EIO=4&transport=websocket&sid=lv_VI97HAXpY6yYWAAAC

<2probe=>proberequest>3probe=>proberesponse<5=>"upgrade"packettype>4hello=>message(notconcatenated)>4world>2=>"ping"packettype<3=>"pong"packettype>1=>"close"packettype只有WebSocket连接的会话在这个例子中,客户端只开启了WebSocket传输通道(withoutHTTPpolling).

GET/engine.io/?EIO=4&transport=websocket

<0{"sid":"lv_VI97HAXpY6yYWAAAC","pingInterval":25000,"pingTimeout":5000}=>handshake<4hey>4hello=>message(notconcatenated)>4world<2=>"ping"packettype>3=>"pong"packettype>1=>"close"packettype3.2URLsEngine.IOurl包含了以下内容

/engine.io/[?]engine.io路径名只能由基于Engine.io协议之上的的更高级别框架更改,如Socket.io.

querystring是可选的,有6个保留的key:

transport:指定的transport,默认为polling,websocket.

j:如果需要JSONP响应,j必须与JSONP响应索引一起设置。

sid:如果客户端已经收到sessionid,那么每次请求的querystring中都必须带上sid

EIO:协议的版本

t:用来防止浏览器缓存

有两种不同类型的编码

packet

payload

一个编码的数据包可以是UTF-8字符串或者二进制数据。字符串的数据包编码格式如下:

typeid>[]example:

4hello对于二进制数据,不包括数据包类型(packettype),因为只有“message”数据包类型可以包括二进制数据。

0open

新传输通道建立的时候,从服务端发送Sentfromtheserverwhenanewtransportisopened(recheck)

1close

请求关闭此传输,但不关闭连接本身。

2ping

由服务器发送。客户应该用pong数据包应答。

example

serversends:2clientsends:33pong

由客户端发送以响应ping数据包。

4message

实际传输的消息

example1

serversends:4HelloWorldclientreceivesandcallscallbacksocket.on('message',function(data){console.log(data);});example2

clientsends:4HelloWorldserverreceivesandcallscallbacksocket.on('message',function(data){console.log(data);});5upgrade

在engine.io切换传输通道之前,它测试服务器和客户端是否可以通过该传输进行通信。如果此测试成功,客户端将发送一个升级包,请求服务器刷新旧传输上的缓存,并切换到新传输通道。

6noop

一个noop包。主要用于建立websocket连接之后关闭长轮询。

example

clientconnectsthroughnewtransportclientsends2probeserverreceivesandsends3probeclientreceivesandsends5serverflushesandclosesoldtransportandswitchestonew.3.3.2PayloadPayload是捆绑在一起的一系列encodedpackets。Payload编码格式如下:

\x1e\x1e数据包分割符使用recordseparator('\x1e').更多可参考:

当有效负载中包含二进制数据时,它将作为base64编码字符串发送。为了解码的目的,将标识符b置于包含二进制数据的分组编码之前。可以发送任意数量的字符串和base64编码字符串的组合。下面是base64编码消息的示例:

\x1ebinb64>[...]Payload用于不支持帧的传输通道,例如轮询协议。

不包含二进制的例子:

[{"type":"message","data":"hello"},{"type":"message","data":"€"}]编码后:

4hello\x1e4€包含二进制的例子:

[{"type":"message","data":"€"},{"type":"message","data":buffer<01020304>}]编码后:

4€\x1ebAQIDBA==分解:

4=>"message"packettype€\x1e=>recordseparatorb=>indicatesabase64packetAQIDBA===>buffercontentencodedinbase643.4传输通道engine.ioserver必须支持三种传输通道:

websocket

server-sentevents(SSE)

polling

jsonp

xhr

轮询传输包括客户端向服务器发送周期性GET请求以获取数据,以及将带有有效负载的请求从客户端发送到服务器以发送数据。

服务器必须支持CORS响应。

服务器实现必须使用有效的JavaScript进行响应。在响应中需要使用URL中query中的j参数。j是一个整数。

JSONP数据包的格式。

`___eio[``]("``");`为了确保payload得到正确处理,需要对payload进行转义,使得响应体是一个合法的JavaScript。

服务器返回的JSONP数据帧的例子

___eio[4]("packetdata");Postingdata

客户端通过隐藏的iframe发送数据。数据以URI编码格式发送给服务器,如下所示

d=除了常规的qs转义之外,为了防止浏览器处理的不一致,\n在被POSTd之前将被转义为\n。

客户端使用EventSource对象接收数据,使用XMLHttpRequest对象发送数据。

上面的对payloads的编码方式并不用于WebSocket通道,WebSocket通道本身已有轻量级的数据帧机制。

发送消息的时候,对数据包进行单独编码,然后依次调用send()进行发送。

连接总是以轮询(XHR或JSONP)开始。WebSocket通过发送探针在侧面进行测试(2probe)。如果探测由服务器响应(3probe),则客户端会发送一个升级包(5)。

为了确保没有消息丢失,只有在刷新现有传输的所有缓冲区并认为传输已暂停后,才会发送升级数据包。

当服务器收到升级包时,它必须假定这是新的传输通道,并将所有现有缓冲区(如果有的话)发送给它。

客户端发送的探测器是一个ping+probe作为数据发送。(2probe)服务端发送的探测器是一个pong+probe作为数据发送。(3probe)

客户端必须使用握手中发送的pingTimeout和pingInterval来确定服务器是否无响应。

服务器发送一个ping数据包。如果在pingTimeout内未收到任何数据包类型,服务器将认为套接字已断开连接。如果收到了pong数据包,服务器将在等待pingInterval之后再次发送ping数据包。

由于这两个值在服务器和客户端之间共享,当客户端在pingTimeout+pingInterval内没有接收到任何数据时,客户端也能探测到服务器是否变得无响应。

Engine.IO是Socket.IO的底层传输通道实现。

Engine.IO、Socket.IO在上层均有自己的协议,因此服务端和客户端必须搭配才能使用。也就是说Socket.IO的客户端必须搭配Socket.IO的服务端才能正常交互数据。

在浏览器中message中的能抓到的数据包,属于WebSocket协议中的message类型数据,WebSocket的PING,PONG是和message类型是并列的,因此浏览器中的devTools并不能抓到,而Engine.IO的心跳机制的实现(下图中的2和3),是message数据之上的协议定义,是Engine.IO用WebSocket的message类型消息发送的。

服务端代码

constengine=require('engine.io');constserver=engine.listen(3000,{cors:{origin:"*"}});server.on('listen',()=>{console.log('listeningon3000')})server.on('connection',socket=>{console.log('newconnection')socket.send('utf8string');socket.send(Buffer.from('helloworld'));//binarydata});客户端代码

const{Socket}=require('engine.io-client');constsocket=newSocket('ws://localhost:3000');socket.on('open',()=>{socket.emit('messagefromclient')socket.on('message',(data)=>{console.log('receivemessage:'+data);socket.send('ackfromclient.');});socket.on('close',(e)=>{console.log('socketclose',e)});});浏览器请求抓包

1、Polling传输通道握手

Request:

Response:

2、发起长轮询请求服务端数据

Request:

Response:

3、POST方式发送数据到服务端

Request:

Requestpayload:

Response:

4、服务端告诉客户端传输通道已升级,回复一个6

Request:

Response:

5、WebSocket通道建立之后,切换为WebSocket传输数据

Connect:

Message:

也可以在客户端指定传输通道为websocket,那么就不会先建立Polling传输通道,直接用WebSocket传输通道进行握手。

constsocket=newSocket('ws://localhost:3000',{transports:['websocket'[});

紧追技术前沿,深挖专业领域

扫码关注我们吧!