一、什么是websocket
WebSocket是一种HTML5开始提供的浏览器与服务器在单个TCP连接上进行全双工通信的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。
(http2.0不是说可以服务端推送吗?http2.0虽然支持服务器推送资源到客户端,但那不是应用程序可以感知的,主要是让浏览器(用户代理)提前缓存静态资源。www.ruanyifeng.com/blog/2018/0…
(在H5的websocket出现之前,为了实现这种互动白板、即时通讯等技术,大家最常用的实现方式有这三种:轮询、长轮询和iframe流)
二、与HTTP的差别
- 支持双向通信,实时性更强。
- 更好的二进制支持。可以发送文本,也可以发送二进制数据。
- 建立在 TCP 协议之上,服务器端的实现比较容易。
- 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
- 较少的控制开销(连接创建后,ws客户端、服务端进行数据交换时,协议控制的数据包头部较少。在不包含头部的情况下,服务端到客户端的包头只有2~10字节(取决于数据包长度),客户端到服务端的的话,需要加上额外的4字节的掩码。而HTTP协议每次通信都需要携带完整的头部。)
- 没有同源限制,客户端可以与任意服务器通信。
- 支持扩展。ws协议定义了扩展,用户可以扩展协议,或者实现自定义的子协议。(比如支持自定义压缩算法等)
- 协议标识符是
ws(如果加密,则为wss),服务器网址就是 URL。
三、例子
服务端
const Server = require('ws').Server;
const ws = new Server({ port: 9999 });
ws.on('connection', connectHandler);
let num = 0;
let lab = 0;
function connectHandler (ws) {
if (lab) {
console.log('客户端连接')
ws.send(JSON.stringify({text:'ws连接已建立'}))
ws.on('error', errorHandler)
ws.on('close', closeHandler)
ws.on('message', messageHandler)
} else {
lab++;
ws.close();
}
}
function messageHandler (msg) {
const msgText = JSON.parse(msg);
if (msgText.packageType && msgText.packageType === 1) {
if (num < 3) {
this.send(JSON.stringify({packageType: 2}));
num++;
}
} else {
console.log(`接收客户端消息: ${msgText.text}`)
this.send(JSON.stringify({text:`这里是服务端对你说的话: ${msgText.text}`}))
};
}
function errorHandler (e) {
console.log('客户端出错')
}
function closeHandler (e) {
console.log('客户端已断开')
}
客户端
import './App.css';
import { useRef, useState } from 'react';
import { Input, Button } from 'antd';
function App() {
const [message, setMessage] = useState('');
const [res, setRes] = useState('');
const [isConnect, setIsConnect] = useState(false);
const ws = useRef('')
const resList = useRef([])
const timeoutObj = useRef(null);
const serverTimeoutObj = useRef(null);
const reConnectObj = useRef(null);
const renderText = () => {
if (resList.current.length > 5) {
resList.current.pop();
}
let resText = '';
for(const i of resList.current) {
resText = resText + "\n" + i;
}
setRes(resText);
}
const reConnect = () => {
reConnectObj.current = setInterval(() => {
if (!ws.current || ws.current.readyState !== 1) {
ws.current?.close();
ws.current = new WebSocket('ws://localhost:9999');
if (ws.current) {
connectHandle();
}
}
}, 5000)
}
const connectHandle = () => {
setIsConnect(true);
ws.current.onopen = () => {
ws.current.send(JSON.stringify({text:'B站关注向晚大魔王'}));
};
ws.current.onmessage = (e) => {
console.log(e);
const resMsg = JSON.parse(e.data);
if (resMsg.packageType && resMsg.packageType === 2) {
clearTimeout(serverTimeoutObj.current);
} else {
resList.current.unshift(resMsg.text);
renderText();
};
};
ws.current.onclose = () => {
resList.current.unshift('ws连接已断开');
setIsConnect(false);
renderText();
}
}
const startWs = () => {
ws.current = new WebSocket('ws://localhost:9999');
reConnect();
console.log(ws.current.readyState)
if (ws.current) {
connectHandle();
}
}
const sendMessage = () => {
if (ws.current && ws.current.readyState === 1) {
ws.current.send(JSON.stringify({text:message}));
}
}
const closeWs = () => {
if (ws.current && ws.current.readyState === 1) {
ws.current.close();
setIsConnect(false);
clearInterval(reConnectObj.current);
}
}
const handClear = () => {
while(resList.current.length) {
resList.current.pop();
}
renderText();
}
const addHeartBeat = () => {
clearInterval(timeoutObj.current);
clearTimeout(serverTimeoutObj.current);
timeoutObj.current = setInterval(() => {
if (ws.current && ws.current.readyState === 1) {
ws.current.send(JSON.stringify({packageType: 1}));
serverTimeoutObj.current = setTimeout(() => {
if (ws.current && ws.current.readyState === 1) {
closeWs();
}
}, 2000)
}
},5000)
}
return (
<div className="App">
<Input value={message} onChange={event => {setMessage(event.target.value)}} />
<div className='button'>
{!isConnect && <Button type="primary" onClick={startWs}>建立连接</Button>}
{isConnect && <Button type="primary" onClick={closeWs}>断开连接</Button>}
<Button type="primary" onClick={sendMessage}>发送</Button>
<Button type='primary' onClick={handClear}>清空</Button>
<Button type='primary' onClick={addHeartBeat}>心跳</Button>
</div>
<div className='res'>
{res}
</div>
</div>
);
}
export default App;
四、例子中客户端的 API
-
WebSocket 构造函数
ws.current = new WebSocket('ws://localhost:9999');
-
webSocket.readyState
readyState属性返回实例对象的当前状态,共有四种。
- CONNECTING:值为0,表示正在连接。
- OPEN:值为1,表示连接成功,可以通信了。
- CLOSING:值为2,表示连接正在关闭。
- CLOSED:值为3,表示连接已经关闭,或者打开连接失败。
ws.current && ws.current.readyState === 1
-
webSocket.onopen
实例对象的onopen属性,用于指定连接成功后的回调函数。
ws.current.onopen = function() {
ws.current.send('B站关注向晚大魔王');
};
-
webSocket.onclose
实例对象的onclose属性,用于指定连接关闭后的回调函数。
ws.current.onclose = () => {
resList.current.unshift('ws连接已断开');
renderText();
}
-
webSocket.onmessage
实例对象的onmessage属性,用于指定收到服务器数据后的回调函数。
ws.current.onmessage = function (e) {
console.log(e);
resList.current.unshift(e.data);
renderText();
};
-
webSocket.send()
实例对象的send()方法用于向服务器发送数据。
ws.current.send('B站关注向晚大魔王');
五、websocket连接建立过程
WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。
-
客户端:申请协议升级
GET ws://localhost:9999/ HTTP/1.1
Host: localhost:9999
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
Upgrade: websocket
Origin: http://localhost:3000
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Sec-WebSocket-Key: WMY2vqAmlYdp2Glt2ni2gA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Connection: Upgrade:表示要升级协议Upgrade: websocket:表示要升级到websocket协议。Sec-WebSocket-Version: 13:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。
-
服务端:响应协议升级
服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: br7cuddot6vwCgBm19pjdbgf0eo=
-
Sec-WebSocket-Key/Accept
Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。
计算公式为:
- 将
Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。 - 通过SHA1计算出摘要,并转成base64字符串。
const crypto = require('crypto');
const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const secWebSocketKey = 'WMY2vqAmlYdp2Glt2ni2gA==';
let secWebSocketAccept = crypto.createHash('sha1')
.update(secWebSocketKey + magic)
.digest('base64');
console.log(secWebSocketAccept);
// br7cuddot6vwCgBm19pjdbgf0eo=
作用:
- 避免服务端收到非法的websocket连接(比如http客户端不小心请求连接websocket服务,此时服务端可以直接拒绝连接)
- 确保服务端理解websocket连接。因为ws握手阶段采用的是http协议,因此可能ws连接是被一个http服务器处理并返回的,此时客户端可以通过Sec-WebSocket-Key来确保服务端认识ws协议。
- 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的。这样可以避免客户端发送ajax请求时,意外请求协议升级(websocket upgrade)
- 可以防止反向代理(不理解ws协议)返回错误的数据。比如反向代理前后收到两次ws连接的升级请求,反向代理把第一次请求的返回给cache住,然后第二次请求到来时直接把cache住的请求给返回(无意义的返回)。
总之,Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。
六、websocket 断线与重连
-
服务端
-
服务端为什么主动断线?
WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。(Nginx代理websocket超时中断)
-
服务端如何判断何时断线?
当客户端第一次发送请求至服务端时会携带唯一标识、以及时间戳,服务端到db或者缓存去查询改请求的唯一标识,如果不存在就存入db或者缓存中,第二次客户端定时再次发送请求依旧携带唯一标识、以及时间戳,服务端到db或者缓存去查询改请求的唯一标识,如果存在就把上次的时间戳拿取出来,使用当前时间戳减去上次的时间,得出的毫秒秒数判断是否大于指定的时间,若小于的话就是在线,否则就是离线。
-
客户端
-
客户端何时需要重连?
最容易想到的是websocket连接断了,为了接下来能收发消息,我们需要再发起一次连接。但在很多场景下,即便websocket连接没有断开,实际上也不可用了,比如设备切换网络、链路中间路由崩溃、服务器负载持续过高无法响应等,这些场景下的websocket都没有断开,但对上层来说,都没办法正常的收发数据了。因此在重连前,我们需要一种机制来感知连接是否可用、服务是否可用,而且要能快速感知,以便能够快速从不可用状态中恢复。
一旦感知到了连接不可用,那便可以弃旧图新了,弃用并断开旧连接,然后发起一次新连接。这两个步骤看似简单,但若想达到快,且不是那么容易的。
首先是断开旧连接,对客户端来说,如何快速快速断开?协议规定客户端必须要和服务器协商后才能断开websocket连接,但是当客户端已经联系不上服务器、无法协商时,如何断开并快速恢复?
其次是快速发起新连接。此快非彼快,这里的快并非是立即发起连接,立即发起连接会对服务器带来不可预估的影响。重连时通常会采用一些退避算法,延迟一段时间后再发起重连。但如何在重连间隔和性能消耗间做出权衡?如何在“恰当的时间点”快速发起连接?
-
快速感知何时需要重连
需要重连的场景可以细分为三种,一是连接断开了,二是连接没断但是不可用,三是连接对端的服务不可用了。
第一种场景很简单,连接直接断开了,肯定需要重连了。
而对于后两者,无论是连接不可用,还是服务不可用,对上层应用的影响都是不能再收发即时消息了,所以从这个角度出发,感知何时需要重连的一种简单粗暴的方法就是通过心跳包超时:发送一个心跳包,如果超过特定的时间后还没有收到服务器回包,则认为服务不可用;这种方法最直接。那如果想要快速感知呢,就只能多发心跳包,加快心跳频率。但是心跳太快对移动端流量、电量的消耗又会太多,所以使用这种方法没办法做到快速感知,可以作为检测连接和服务可用的兜底机制。
如果要检测连接不可用,除了用心跳检测,还可以通过判断网络状态来实现,因为断网、切换wifi、切换网络是导致连接不可用的最直接原因,所以在网络状态由offline变为online时,大多数情况下需要重连下,但也不一定,因为webscoket底层是基于TCP的,TCP连接不能敏锐的感知到应用层的网络变化,所以有时候即便网络断开了一小会,对websocket连接是不会有影响的,网络恢复后,仍然能够正常地进行通信。因此在网络由断开到连接上时,立即判断下连接是否可用,可以通过发一个心跳包判断,如果能够正常收到服务器的心跳回包,则说明连接仍是可用的,如果等待超时后仍没有收到心跳回包,则需要重连。这种方法的优点是速度快,在网络恢复后能够第一时间感知连接是否可用,不可用的话可以快速执行恢复,但它只能覆盖应用层网络变化导致websocket不可用的情况。
综上,定时发送心跳包检测的方案贵在稳定,能够覆盖所有场景,但速度不太可;而判断网络状态的方案速度快,无需等待心跳间隔,较为灵敏,但覆盖场景较为局限。因此,我们可以结合两种方案:定时以不太快的频率发送心跳包,比如40s/次、60s/次等,具体可以根据应用场景来定,然后在网络状态由offline变为online时立即发送一次心跳,检测当前连接是否可用,不可用的话立即进行恢复处理。这样在大多数情况下,上层的应用通信都能较快从不可用状态中恢复,对于少部分场景,有定时心跳作为兜底,在一个心跳周期内也能够恢复。
-
快速断开旧连接
通常情况下,在发起下一次连接前,如果旧连接还存在的话,应该先把旧连接断开,这样一来可以释放客户端和服务器的资源,二来可以避免之后误从旧连接收发数据。
我们知道websocket底层是基于TCP协议传输数据的,连接两端分别是服务器和客户端,而TCP的TIME_WAIT状态是由服务器端维持的,因此在大多数正常情况下,应该由服务器发起断开底层TCP连接,而不是客户端。也就是说,要断开websocket连接时,如果是服务器收到指示要断开websocket,那它应该立即发起断开TCP连接;如果是客户端收到指示要断开websocket,那它应该发信号给服务器,然后等待底层TCP连接被服务器断开或直至超时。
那如果客户端想要断开旧的websocket,可以分websocket连接可用和不可用两种情况来讨论。当旧连接可用时,客户端可以直接给服务器发送断开信号,然后服务器发起断开连接即可;当旧连接不可用时,比如客户端切换了wifi,客户端发送了断开信号,但是服务器收不到,客户端只能迟迟等待,直至超时才能被允许断开。超时断开的过程相对来说是比较久的,那有没有办法可以快点断开?
上层应用无法改变只能由服务器发起断开连接这种协议层面的规则,所以只能从应用逻辑入手,比如在上层通过业务逻辑保证旧连接完全失效,模拟连接断开,然后在发起新连接,恢复通讯。这种方法相当于尝试断开旧连接不行时,直接弃之,然后就能快速进入下一流程,所以在使用时一定要确保在业务逻辑上旧连接已完全失效,比如:保证丢掉从旧连接收到所有数据、旧连接不能阻碍新连接的建立,旧连接超时断开后不能影响新连接和上层业务逻辑等等。
-
快速发起新连接
遇到因网络原因导致的重连时,是万万不能立即发起一次新连接的,否则当出现网络抖动时,所有的设备都会立即同时向服务器发起连接,这无异于黑客通过发起大量请求消耗网络带宽引起的拒绝服务攻击,这对服务器来说简直是灾难。所以在重连时通常采用一些退避算法,延迟一段时间再发起重连。
如果要快速连上呢?最直接的做法就是缩短重试间隔,重试间隔越短,在网络恢复后就能越快的恢复通讯。但是太频繁的重试对性能、带宽、电量的消耗就比较严重。如何在这之间做一个较好的权衡呢?
一种比较合理的方式是随着重试次数增多,逐渐增大重试间隔,同时监听网络变化。在网络状态由offline变为online这种比较可能重连上的时刻,可以适当地减小重连间隔,随重试次数的增多,重连间隔也会变大。
除此之外,还可以结合业务逻辑,根据成功重连上的可能性适当的调整间隔,如网络未连接时或应用在后台时重连间隔可以调大一些,网络正常的状态下可以适当调小一些等等,加快重连上的速度。
-
心跳机制与断线重连
心跳机制是每隔一段时间会向服务器发送一个数据包,告诉服务器自己还活着,同时客户端会确认服务器端是否还活着,如果还活着的话,就会回传一个数据包给客户端来确定服务器端也还活着,否则的话,有可能是网络断开连接了,需要重连。