前端实时推送浅析
前言
前段时间,完成了项目组分配的一个任务——数据实时大屏,即把商场销售、车流、客流和人流等数据展示到页面上。本次项目采用的是定时轮询的方式去查询数据,总结一下,存在的问题:十分钟执行的定时任务,有时候定时任务尚未执行完成,这时若有前端查询请求,实际上这是无效的查询请求。当然大屏的用户量较少,定时轮询获取数据并无太大问题,但是如果是百万甚至千万的用户,采用定时轮询问题就来了。所以,我根据已有的经验,总结了一下前端如果及时获取后端数据的方法,记录一下,以便将来针对不同的业务场景可以快速的进行技术选型。下面主要对四种方法进行分析和总结:
1、短轮询。2、长轮询,3、长连接SSE。4、websocket。并对websocket进行重点分析,如有不当之处,欢迎各位大神进行纠正。
短轮询
短轮询简介
短轮询原理比较简单,客户端按照一定的频率定时向后台服务器发送请求,服务器接收到请求后,进行响应返回数据给客户端,通常采取setInterval实现。
什么是短轮询?用通俗易懂的话举例解释,小张在看直播的时候,看到一个自己十分心仪的女主播小红,于是小张每十分钟就向女主播发一句问候语:“小姐姐,我要刷个游艇吗”,女主播一接到消息就回复他:“小哥哥,你真帅”,下一次回复他:“小哥哥,可以多发几个游艇吗”。
var xhr = new XMLHttpRequest();
setInterval(function () {
xhr.open('GET', '/sendGifToZB');
xhr.onreadystatechange = function () {
};
xhr.send();
}, 60000)
优点
短轮询的优点:
- 技术简单,易于理解。
- 易于维护,前端升级改造维护等不牵涉服务端。
- 兼容性好,几乎兼容当下所有的主流版本浏览器。
缺点
- 资源浪费,不断的建立连接,当定时时间短,客户量多时,会增加服务器的负担。
- 数据及时性问题,无法感知到客户端数据是否更新,产生很多无效请求。存在需要更新的时候没更新,不需要更新的时候又去请求的情况。
适用场景
轮询适用于那些同时在线用户数量比较少,对数据及时性要求不高,并且不特别注重性能或低版本浏览器的B/S应用。
长轮询
长轮询简介
客户端向服务端发起请求,如果服务器没有可以返回的数据,不会立刻返回一个空结果,而是保持这个连接,一直等待数据,一旦有数据,便将数据作为结果返回给客户端。
什么是长轮询?用通俗易懂的话举例解释,经过了几轮的直播发礼物之后,小红成了小张的女神。小张便给小红发消息:“你现在在干嘛”,小张心里满怀着期待,等啊等,一晚上之后,女神回复他:“昨晚我睡着了”,此刻,小张已经兴奋得无法用言语来形容,马上就说:“你现在在干嘛呀”。又过了一个钟,女神回复他:“我刚吃完早饭”,…
function getMessagesFromNS() {
$.ajax({
async: true,
url: '/getMessagesFromNS',
type: 'post',
dataType: 'json',
data: {
question: "nv shen,what are you doing now?"
},
timeout: 30000,
error: function (xhr, textStatus, thrownError) {
getMessagesFromNS();
},
success: function (response) {
if (message != "timeout") {
}
getMessagesFromNS();
}
});
}
优点
- 减少了无效的请求次数,节约了客户端和服务端的资源,尤其是客户端。
- 技术成本比较低,不比短轮询复杂多少。
- 兼容性好,几乎兼容当下所有的主流版本浏览器。
缺点
- 与短轮询一样,仍然无法解决及时性的问题。
- 长轮询相对于短轮询,因为存在一个等待数据的过程,所以需要服务器具有更大的并发能力。
适用场景
轮询适用于那些同时在线用户数量比较小,对数据及时性要求不高,并且不特别注重性能或低版本浏览器的B/S应用。
长连接SSE
长连接SSE简介
SSE是Server-sent Event的简写,是一种服务器端到客户端的单向消息推送。对应的浏览器端实现 Event Source 的接口被制定为HTML5 的一部分。SSE与长轮询机制类似,客户端向服务器发送一个请求,服务端会一直保持着连接,通过这个连接就可以让消息再次发送,由服务器单向发送给客户端。与长轮询的区别是,长轮询服务端发消息给客户端后,双方的连接就断了,需要客户端重新发起一个连接请求,一次连接接收一次消息。而SSE一次连接可以接收多次消息。
什么是SSE?用通俗易懂的话举例解释,小张经过不懈的努力,小张终于熬成了恋爱候选对象,小张便对女神小红说:“女神,你那边有什么需求吗,可以尽管提,我一定办到”。女神这时候心里有了一丝丝触动,于是回复小张说:“我要买一部手机”,两天后又跟小张说:“我要去马尔代夫旅游”,…
var source = new EventSource(url)
source.onopen = (event) => {
}
source.onmessage = (event) => {
}
source.onerror = (event) => {
}
source.addEventListener('eventName', event => {
}, false)
source.close()
Content - Type: text / event - stream
Cache - Control: no - cache
Connection: keep - alive
优点
- 解决了短轮询和长轮询中,解决不了的数据及时性问题,实现了数据流由服务器向客户端的推送。
- 进一步解决了资源浪费的问题。
- 技术成本适中,复杂于短轮询长轮询,但简单于websocket。
缺点
- 不兼容IE浏览器。
- 单工通信,连接之后,服务器端向客户端传输数据,客户端不能向客户端传输数据。
- 同源限制,存在CORS同源限制。
适用场景
适用于对数据及时性有要求,服务端向客户端单向推送数据的场景。
WebSocket
WebSocket简介
websocket是html5规范中的一个部分,它借鉴了socket这种思想,为web应用程序客户端和服务端之间提供了一种全双工通信机制。同时,它又是一种新的应用层协议,websocket协议是为了提供web应用程序和服务端全双工通信而专门制定的一种应用层协议,通常它表示为:ws://echo.websocket.org/?encoding=text HTTP/1.1,可以看到除了前面的协议名和http不同之外,它的表示地址就是传统的url地址。Websocket其实是一个新协议,跟HTTP协议基本没有关系,只是为了兼容现有浏览器的握手规范而已,也就是说它是HTTP协议上的一种补充,或者说借用了http的握手功能实现初始连接。
什么是websocket?用通俗易懂的话举例解释,小张经过不懈的努力,终于和小红成为了情侣,从此跟小红过上了幸福的生活,小红对小张也不再不冷不热了,双方都会主动发起聊天对话,小红经常问小张:“我们去吃什么”,“去哪里玩”…小张也会跟小红说:“你在干嘛”,“多喝热水”…
var ws = new WebSocket("ws://nvshen:520");
ws.onopen = function () {
ws.send("dring more hot water");
};
ws.onmessage = function (e) {
console.log(e.data);
};
ws.onclose = function () {
console.log("closed...");
};
ws.onerror = function () {
console.log(this.readyState);
}
优点
- 实现了全双工通信。
- 节约资源,WebSocket协议一旦建议后,互相沟通所消耗的请求头是很小的。
- 解决数据及时性问题,双方可以获取最新的消息,进行处理。
- 支持文本传输,二进制数据传输。
- 没有同源限制,客户端可以与任意服务器通信。
缺点
- 相对于其他三种,技术复杂,需要前后端支持。
- 兼容性相对较差,部分浏览器不支持。
适用场景
适用于对数据有及时性要求,服务端和客户端双工通信的场景,当然,也可以使用websocket进行单向的数据推送。
websocket底层原理探究
websocket底层原理可以简要的概括为三个阶段:1、握手阶段。2、数据交换阶段。3、关闭阶段。
- 首先客户端浏览器要和服务端建立一个TCP连接,基于 HTTP 协议实现,它借用了 HTTP 协议来完成一部分握手。
所以,握手阶段WebSocket 首先发起一个 HTTP 请求,在请求头加上 Upgrade 字段,该字段用于改变 HTTP 协议版本或者是换用其他协议,把 Upgrade 的值设为 websocket ,即将它升级为 WebSocket 协议。
Upgrade、Connection、Sec-WebSocket-Key、Sec-WebSocket-Version、Sec-WebSocket-Extension 几个属性是 WebSocket 的核心。
Upgrade、Connection: websocket属性通知 Apache 、 Nginx 等服务器,此次发起的请求要用 WebSocket 协议,而不是http或其他协议。
Sec-WebSocket-Key : 用于验证服务器端是否采用WebSocket 协议。由客户端生成并发给服务端,用于证明服务端接收到的是一个可受信的连接握手,可以帮助服务端排除自身接收到的由非 WebSocket 客户端发起的连接,该值是一串随机经过 base64 编码的字符串。
Sec-WebSocket-Version: 表示客户端所使用的协议版本。
Sec-WebSocket-Extensions: 表示客户端想要表达的协议级的扩展。
-
客户端浏览器会生成一个随机字符串(sec-websocket-key),自己留一份,然后基于http协议将随机字符串放在请求头中发送给服务端。 -
服务端收到随机字符串后会和服务端的魔法字符串(magic string)(魔法字符串是全球公认的)做一个拼接生成一个大的字符串,然后再用全球公认的算法(sha1+base64)进行加密,生成一个密文,接着将这个密文返回给客户端浏览器。
服务端通过从客户端请求头中读取 Sec-WebSocket-Key 与一串全局唯一的标识字符串(俗称魔串)“258EAFA5-E914-47DA- 95CA-C5AB0DC85B11”做拼接,生成长度为160位的 SHA-1 字符串,然后进行 base64 编码,作为 Sec-WebSocket-Accept 的值回传给客户端。
Connection和Upgrade: 与请求头中的作用相同
Sec-WebSocket-Accept: 表明服务器接受了客户端的请求。
- 客户端浏览器收到这个密文之后,会用同样的魔法字符串与自己生成的随机字符串进行拼接,再用和服务端相同的加密算法进行加密也得到有一个密文,然后拿自己的密文与服务端传过来的密文进行比较,如果结果一样,则说明服务端支持websocke协议,如果结果不一样,则说明服务端不支持websocket协议。
按照RFC中的描述:
FIN: 1 bit
表示这是一个消息的最后的一帧。第一个帧也可能是最后一个。
%x0 : 还有后续帧
%x1 : 最后一帧
RSV1、2、3: 1 bit each
除非一个扩展经过协商赋予了非零值以某种含义,否则必须为0。
如果没有定义非零值,并且收到了非零的RSV,则websocket链接会失败。
Opcode: 4 bit
解释说明 “Payload data” 的用途/功能
如果收到了未知的opcode,最后会断开链接
定义了以下几个opcode值:
%x0 : 代表连续的帧
%x1 : text帧
%x2 : binary帧
%x3-7 : 为非控制帧而预留的
%x8 : 关闭握手帧
%x9 : ping帧
%xA : pong帧
%xB-F : 为非控制帧而预留的
Mask: 1 bit
定义“payload data”是否被添加掩码,
如果置1, “Masking-key”就会被赋值,
所有从客户端发往服务器的帧都会被置1。
Payload length: 7 bit | 7+16 bit | 7+64 bit
“payload data” 的长度如果在0~125 bytes范围内,它就是“payload length”,
如果是126 bytes, 紧随其后的被表示为16 bits的2 bytes无符号整型就是“payload length”,
如果是127 bytes, 紧随其后的被表示为64 bits的8 bytes无符号整型就是“payload length”。
Masking-key: 0 or 4 bytes
所有从客户端发送到服务器的帧都包含一个32 bits的掩码(如果“mask bit”被设置成1),否则为0 bit。一旦掩码被设置,所有接收到的payload data都必须与该值以一种算法做异或运算来获取真实值。
Payload data: (x+y) bytes
它是"Extension data"和"Application data"的总和,一般扩展数据为空。
Extension data: x bytes
除非扩展被定义,否则就是0。
任何扩展必须指定其Extension data的长度。
Application data: y bytes
占据"Extension data"之后的剩余帧的空间。
客户端浏览器向服务端发送消息,但是发过来的数据是在浏览器内部进行加密过的密文,服务端收到密文后,会进行解密。主要步骤如下:
- 去掉头数据取得真实数据:拿到密文第二个字节的后7位,后7位成为pyload_lenth,如果pyload_lenth是127,数据要往后读8个字节,也就是说前面10个字节都是数据头,后面是真正的数据,如果pyload_lenth是126,数据要往后读两个字节,也就是前面4个字节是数据包的头,后面是真正的数据,如果pyload_lenth <= 125的话,不用换往后读了,前面连个字节就是数据头,后面是真正的数据。
- 对真实进行解密:无论pyload_lenth是127,是126,还是<=125,去掉数据头之后,对数据进一步解密,解密的过程就是再往后读4个字节,这4个字节就是masking-key ,就是掩码,后面就是数据,让masking-key的每个字节与4求余的结果和数据的每个字节进行位运算,就是与或运算,最终位运算运算完了,便得到真正的数据。
Websocket 的数据以frame数据格式,按照先后顺序传输出去。这样做的好处:
-
大数据的传输可以分片传输,避免长度标志位不足够的情况。 -
生成数据边传递消息,传输效率得到了极大的提高。
-
关闭阶段
- 发送关闭连接请求(Close Handshake)
即发送Close Frame(Opcode为0x8)。一旦一端发送/接收了一个Close Frame,就开始了Close Handshake,并且连接状态变为Closing。 Close Frame中如果包含Payload data,则data的前2字节必须为两字节的无符号整形,(同样遵循网络字节序:BE)用于表示状态码,如果2byte之后仍有内容,则应包含utf-8编码的关闭理由。 如果一端在之前未发送过Close Frame,则当他收到一个Close Frame时,必须回复一个Close Frame。但如果它正在发送数据,则可以推迟到当前数据发送完,再发送Close Frame。比如Close Frame在分片发送时到达,则要等到所有剩余分片发送完之后,才可以作出回复。 - 关闭WebSocket连接
当一端已经收到Close Frame,并已发送了Close Frame时,就可以关闭连接了,close handshake过程结束。这时丢弃所有已经接收到的末尾字节。 - 关闭TCP连接
当底层TCP连接关闭时,连接状态变为Closed。
如果TCP连接在Close handshake完成之后关闭,就表示WebSocket连接已经彻底关闭了。如果WebSocket连接并未成功建立,状态也为连接已关闭,但并不是彻底关闭。
正常关闭过程属于clean close,应当包含close handshake。 通常来讲,应该由服务器关闭底层TCP连接,而客户端应该等待服务器关闭连接,除非等待超时的话,那么自己关闭底层TCP连接。 服务器可以随时关闭WebSocket连接,而客户端不可以主动断开连接。
- 由于某种算法或规定,一端直接关闭连接。(特指在open handshake(打开连接)阶段)
- 底层连接丢失导致的连接中断。
由于某种算法或规范要求指定连接失败。这时,客户端和服务器必须关闭WebSocket连接。当一端得知连接失败时,不准再处理数据,包括响应close frame。
websocket 心跳检测和重连实现与封装
websocket 在使用过程中,最担心的问题就是:如果遭遇网络问题等,这个时候服务端没有触发onclose事件,这样会产生多余的连接,并且服务端会继续发送消息给客户端,造成数据丢失。需要一种机制来检测客户端和服务端是否处于正常连接的状态,因此,心跳检测和重连机制就产生了。
实现思路:
1、定时器,每隔一段指定的时间,向服务器发送一个数据,服务器收到数据后再发送给客户端,如果客户端通过onmessage事件能监听到服务器返回的数据,那么请求正常。 2、如果指定时间内,客户端没有收到服务器端返回的响应消息,判定连接断开,使用websocket.close关闭连接。 3、关闭连接的动作可以通过onclose事件监听到,因此在 onclose 事件内,我们可以调用reconnect事件进行重连。
以下对心跳检测和重连进行了封装:
class Heart {
heartTimeout = 0;
serverHeartTimeout = 0;
timeout: number;
constructor() {
this.timeout = 5000;
}
reset() {
clearTimeout(this.heartTimeout);
clearTimeout(this.serverHeartTimeout);
return this;
}
start(cb: CallableFunction): void {
this.heartTimeout = setTimeout(() => {
cb();
this.serverHeartTimeout = setTimeout(() => {
cb();
this.reset().start(cb());
}, this.timeout);
}, this.timeout);
}
}
interface options {
url: string;
hearTime?: number;
heartMsg?: string;
isReconnect?: boolean;
isRestory?: boolean;
reconnectTime?: number;
reconnectCount?: number;
openCb?: CallableFunction;
closeCb?: CallableFunction;
messageCb?: CallableFunction;
errorCb?: CallableFunction;
}
class Socket extends Heart {
ws!: WebSocket;
reConnecTimer!: number;
options: options = {
url: "",
hearTime: 0,
heartMsg: "ping",
isReconnect: true,
isRestory: false,
reconnectTime: 5000,
reconnectCount: -1,
openCb: (event: Event) => { console.log("连接成功" + event) },
closeCb: (event: Event) => { console.log("连接关闭" + event) },
messageCb: (data: string) => { console.log("接收消息为:" + data) },
errorCb: (event: Event) => { console.log("错误信息" + event) }
};
constructor(ops: options) {
super();
this.create();
}
create() {
if (!("WebSocket" in window)) {
new Error("当前浏览器不支持,无法使用");
return;
}
if (!this.options.url) {
new Error("地址不存在,无法建立通道");
return;
}
this.ws = new WebSocket(this.options.url);
this.onopen();
this.onclose()
this.onmessage()
}
onopen(callback?: CallableFunction) {
this.ws.onopen = (event) => {
clearTimeout(this.reConnecTimer);
super.reset().start(() => {
this.send(this.options.heartMsg as string);
});
if (typeof callback === "function") {
callback(event)
} else {
(typeof this.options.openCb === "function") && this.options.openCb(event)
}
}
}
onclose(callback?: CallableFunction) {
this.ws.onclose = (event) => {
super.reset();
!this.options.isRestory && this.onreconnect()
if (typeof callback === "function") {
callback(event);
} else {
(typeof this.options.closeCb === "function") && this.options.closeCb(event)
}
}
}
onerror(callback?: CallableFunction) {
this.ws.onerror = (event) => {
if (typeof callback === "function") {
callback(event)
} else {
(typeof this.options.errorCb === "function") && this.options.errorCb(event)
}
}
}
onmessage(callback?: CallableFunction) {
this.ws.onmessage = (event) => {
super.reset().start(() => {
this.send(this.options.heartMsg as string);
})
if (typeof callback === "function") {
callback(event.data)
} else {
(typeof this.options.messageCb === "function") && this.options.messageCb(event.data)
}
}
}
send(data: string) {
if (this.ws.readyState !== this.ws.OPEN) {
new Error("没有连接到服务器,无法推送信息");
return;
}
this.ws.send(data);
}
onreconnect() {
if (this.options.reconnectCount as number > 0 || this.options.reconnectCount === -1) {
this.options.reconnectTime = setTimeout(() => {
this.create();
if (this.options.reconnectCount !== -1) {
(this.options.reconnectCount as number)--;
}
}, this.options.reconnectTime);
} else {
clearTimeout(this.options.reconnectTime);
}
}
destroy() {
super.reset();
clearTimeout(this.reConnecTimer);
this.options.isRestory = true;
this.ws.close();
}
}
let options: options = {
url: "ws://127.0.0.1:520",
hearTime: 0,
heartMsg: "ping",
isReconnect: true,
isRestory: false,
reconnectTime: 1000,
reconnectCount: -1,
openCb: (event: Event) => { console.log("连接成功" + event) },
closeCb: (event: Event) => { console.log("连接关闭" + event) },
messageCb: (data: string) => { console.log("接收消息为:" + data) },
errorCb: (event: Event) => { console.log("错误信息" + event) }
};
let ws = new Socket(options)
参考文章
深入剖析WebSocket的原理
总结
通过此次的微博总结,加深了自己对前端数据推送各个技术的理解与记忆。时代是在进步的,技术也是如此,不断的呈现问题,解决问题,不断进化。作为一名程序员,也应该保持着与时俱进的能力,应对新时代的技术潮流,不断完善自己的能力,弥补自己的不足。让自己更强大,在工作中更有用武之地。
|