在web应用中,WebSocket是很常用的技术。通过浏览器的WebSocket构造函数就可以建立一个WebSocket连接。但当需要应用在具体项目中时,几乎都会进行心跳检测。
设置心跳检测,一是让通讯双方确认对方依旧活跃,二是浏览器端及时检测当前网络线路可用性,保证消息推送的及时性。
你可能会想,WebSocket那么简陋的吗,居然不能自己判断连接状态?在了解前先来回顾一下计算机网络知识。
相关的网络知识
TCP/IP协议族四层结构:
-
应用层:决定了向用户提供应用服务时通信的活动。HTTP、FTP、WebSocket都是应用层协议
-
(TCP)传输控制层:控制网络中两台主机的数据传输:将应用层数据(有必要时对应用层报文分段,例如一个完整的HTTP报文进行分段)发送到目标主机的特定端口的应用程序。给每个数据标记源端口、目标端口、分段后的序号。
-
(IP)网络层:将IP地址映射为目标主机的MAC地址,然后将TCP数据包(有必要时对数据分片)加入源IP、目标IP等信息后经过链路层扔到网络上让其找到目标主机。
-
链路层:为IP网络层进行发送、接收数据报。将二进制数据包与在网线传输的网络电信号进行相互转换。
TCP是可靠的连接,握手建立连接后,发送方每发送一个TCP报文(对应用层报文分段后形成多个TCP报文),都会期望对方在指定时间里返回已收到的确认消息,如果超时没有回应,会重复发送,确保所有TCP报文可以到达对方,被对方按顺序拼接成应用层需要的完整报文。
WebSocket协议支持在TCP 上层引入 TLS 层,建立加密通信。
WebSocket与HTTP的异同:
-
WebSocket和HTTP一样是应用层协议,在传输层使用了TCP协议,都是可靠的连接。WebSocket在建立连接时,可以使用已有的HTTP的GET请求进行握手:客户端在请求头中将WebSocket协议版本等信息发生到服务器,服务器同意的话,会响应一个101的状态码。就是说一次HTTP请求和响应,即可轻松转换协议到WebSocket。
-
WebSocket可以互相发起请求。当有新消息时,服务器主动通知客户端,无需客户端主动向服务器询问。客户端也可以向后端发送消息。而HTTP中请求只能由客户端发起。
-
WebSocket是HTML5的内容,HTTP则是超文本传输协议,比HTML5诞生更早。
-
在应用层,WebSocket的每个报文(在WebSocket中叫数据帧)会比HTTP报文(必须包含请求行、请求头、请求数据)更轻量。
- WebSocket每个数据帧只有固定的头信息,不会有cookie等或者自定义的头信息。建立通讯后是一对一的,不需要携带验证信息。使用HTTP握手时,的握手请求会自动携带cookie。
- WebSocket在应用层就会将大的数据进行分拆,而HTTP不会。
WebSocket与与WebRTC的异同:
- WebRTC是一种通讯技术,由谷歌发起,被广大浏览器实现。用来建立浏览器和浏览器间的通讯,如视频通话等。而WebSocket是一种经过抽象的协议,可以实现为通讯技术。用来建立浏览器和服务器间的通讯。
协议中的心跳检测机制
从网上检索的答案,WebSocket大概有两种从协议角度出发的,检测对方存活的方式:
-
WebSocket只是一个应用层协议规范,其传输层是TCP,而TCP为长连接提供KeepAlive机制,可以定时发送心跳报文确认对方的存活,但一般是服务器端使用。因为是TCP传输控制层的机制,具体的实现要看操作系统,也就是说应用层接收到的连接状态是操作系统通知的,不同操作系统的资源调度是不一样的,例如何时发送探测报文(不包含有效数据的TCP报文)检测对方的存活,频率是多久,在不同的系统配置下存在差异。可能是2小时进行一次心跳检测,或许更短。如果连续没有收到对方的应答包,才会通知应用层已经断开连接。这就带来了不确定性。同时也意味着其它依赖该机制的应用层协议也会被影响。也就是说要利用这个过程进行检测,客户端要修改操作系统的TCP配置才行,在浏览器环境显然不行。参考1、参考2
-
WebSocket协议也有自身的保活机制,但需要通讯双方的实现。WebSocket通讯的数据帧会有一个4位的OPCODE,标记当前传输的数据帧类型,例如:0x8表示关闭帧、0x9表示ping帧、0xA表示pong帧、0x1普通文本数据帧等。www.rfc-editor.org
- 关闭数据帧,在任意一方要关闭通道时,发送给对方。例如浏览器的WebSocket实例调用close时,就会发送一个OPCODE为连接关闭的数据帧给服务器端,服务器端接收到后同样需要返回一个关闭数据帧,然后关闭底层的TCP连接。
- ping数据帧,用于发送方询问对方是否存活,也就是心跳检测包。目前只有后端可以控制ping数据帧的发送。但浏览器端的WebSocket实例上没有对应的api可用。
- pong数据帧,当WebSocket通讯一方接收到对方发送的ping数据帧后,需要及时回复一个内容一致,且OPCODE标记为pong的数据帧,告诉对方我还在。但目前回复pong是浏览器的自动行为,意味着不同浏览器会有差异。而且在js中没有相关api可以控制。
综上所述,探测对方存活的方式都是服务器主动进行心跳检测。浏览器并没有提供相关能力。为了能够在浏览器端实时探测后端的存活,或者说连接依旧可用,只能自己实现心跳检测。
浏览器端心跳检测的必要性
首先我们先了解一下,目前的浏览器端的WebSocket何时会自动关闭WebSocket,并触发close事件呢?
- 握手时的WebSocket地址不可用。
- 其它未知错误。
- 正常连接状态下,接收到服务器端的关闭帧就会触发关闭回调。
也就是说建立正常连接后,中途浏览器端断网了,或者服务器没有发送关闭帧就关了连接,总之就是在连接无法再使用的情况下,浏览器没有接收到关闭帧,浏览器则会长时间保持连接状态。此时业务代码不去主动探测的话,是无法感知的。
另外通讯双方保持连接意味着需要长时间占用对方的资源。对于服务器端来说资源是非常宝贵的。长时间不活跃的连接,可能会被服务器应用层框架"优化"释放掉。
前端实现心跳检测
实例化一个WebSocket:
function connectWS() {
const WS = new WebSocket("ws://127.0.0.1:7070/ws/?name=greaclar");
// WebSocket实例上的事件
// 当连接成功打开
WS.addEventListener('open', () => {
console.log('ws连接成功');
});
// 监听后端的推送消息
WS.addEventListener('message', (event) => {
console.log('ws收到消息', event.data);
});
// 监听后端的关闭消息,如果发送意外错误,这里也会触发
WS.addEventListener('close', () => {
console.log('ws连接关闭');
});
// 监听WS的意外错误消息
WS.addEventListener('error', (error) => {
console.log('ws出错', error);
});
return WS;
}
let WS = connectWS();
心跳检测需要用到的实例方法:
// 发送消息,用来发送心跳包
WS.send('hello');
// 关闭连接,当发送心跳包不响应,需要重连时,最好先关闭
WS.close();
定义发送心跳包的逻辑:
准备
- 申请一个变量heartbeatStatus,记录当前心跳检测状态,有三个状态:等待中,已收到应答、超时。
- 监听WS实例的message事件,监听到就将heartbeatStatus改为:已收到应答。
- 监听WS实例的open事件,打开后启动心跳检测。
检测
-
启动一个定时器A。
-
定时器A执行,1.修改当前状态heartbeatStatus为等待中;2.发送心跳包;3.启动一个定时器B。
- 发送心跳包后,后端需要立刻推送一个内容一样的心跳应答包给前端,触发前端WS实例的message事件,继而将heartbeatStatus改为已收到应答。
-
定时器B执行,检测当前heartbeatStatus状态:
-
如果是已收到应答,证明定时器A执行后,服务器可以及时响应数据。继续启动定时器A,然后不断循环。
-
如果是等待中,证明连接出现问题了,走关闭或者检测流程。
-
let WS = connectWS();
let heartbeatStatus = 'waiting';
WS.addEventListener('open', () => {
// 启动成功后开启心跳检测
startHeartbeat()
})
WS.addEventListener('message', (event) => {
const { data } = event;
console.log('心跳应答了,要把状态改为已收到应答', data);
if (data === '"heartbeat"') {
heartbeatStatus = 'received';
}
})
function startHeartbeat() {
setTimeout(() => {
// 将状态改为等待应答,并发送心跳包
heartbeatStatus = 'waiting';
WS.send('heartbeat');
// 启动定时任务来检测刚才服务器有没有应答
waitHeartbeat();
}, 1500)
}
function waitHeartbeat() {
setTimeout(() => {
console.log('检测服务器有没有应答过心跳包,当前状态', heartbeatStatus);
if (heartbeatStatus === 'waiting') {
// 心跳应答超时
WS.close();
} else {
// 启动下一轮心跳检测
startHeartbeat();
}
}, 1500)
}
优化心跳检测
心跳检测异常,但close事件没有触发,大概率是双方之间的网络线路不佳,如果立马进行重连,会挤兑更多的网络资源,重连的失败概率更大,也可能阻塞用户的其它操作。
但也不排除确实是连接的问题,如服务器宕机、意外重启,同时没有告知浏览器需要把旧连接关闭。
所以一发生心跳不应答,个人推荐的做法是,发生延迟后,提醒用户网络异常正在修复中,让用户有个心理准备。然后多发一两个心跳包,连续不应答再提示用户掉线了,是否重连。如果中途正常了,就不需要重连,用户体验更好,对服务器的压力也更小。
// 以上代码需要修改的地方
// 添加一个变量来记录连续不应答次数
let retryCount = 0;
WS.addEventListener('message', (event) => {
const { data } = event;
console.log('心跳应答了,要把状态改为已收到应答', data);
if (data === '"heartbeat"') {
// 复位连续不应答次数
retryCount = 0;
heartbeatStatus = 'received';
}
})
// 在等待应答的函数中添加重试的逻辑
function waitHeartbeat() {
setTimeout(() => {
// 心跳应答正常,启动下一轮心跳检测
if (heartbeatStatus === 'received') {
return startHeartbeat();
}
// 更新超时次数
retryCount ++;
// 心跳应答超时,但没有连续超过三次
if (retryCount < 3) {
alert('ws线路异常,正在检测中。')
return startHeartbeat();
}
// 超时次数超过三次
WS.close();
}, 1500)
}
最后,为了方便大家共同进步,本文已经把相关的逻辑封装为一个类,并且在npm中可下载玩一下,也已经开源到github上。