前端数据推送浅析和websocket底层原理探究

x33g5p2x  于2021-09-19 转载在 其他  
字(12.2k)|赞(0)|评价(0)|浏览(314)

前言

前段时间,完成了项目组分配的一个任务——数据实时大屏,即把商场销售、车流、客流和人流等数据展示到页面上。本次项目采用的是定时轮询的方式去查询数据,总结一下,存在的问题:十分钟执行的定时任务,有时候定时任务尚未执行完成,这时若有前端查询请求,实际上这是无效的查询请求。当然大屏的用户量较少,定时轮询获取数据并无太大问题,但是如果是百万甚至千万的用户,采用定时轮询问题就来了。所以,我根据已有的经验,总结了一下前端如果及时获取后端数据的方法,记录一下,以便将来针对不同的业务场景可以快速的进行技术选型。下面主要对四种方法进行分析和总结:

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,//超时时间设定30秒
    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?用通俗易懂的话举例解释,小张经过不懈的努力,小张终于熬成了恋爱候选对象,小张便对女神小红说:“女神,你那边有什么需求吗,可以尽管提,我一定办到”。女神这时候心里有了一丝丝触动,于是回复小张说:“我要买一部手机”,两天后又跟小张说:“我要去马尔代夫旅游”,…

//客户端
//创建EventSource 实例
var source = new EventSource(url)
// 建立连接后,触发`open` 事件
source.onopen = (event) => {
  // ...
}
// 收到消息,触发`message` 事件
source.onmessage = (event) => {
  // ...
}
// 发生错误,触发`error` 事件
source.onerror = (event) => {
  // ...
}
// 自定义事件
source.addEventListener('eventName', event => {
  // ...
}, false)
source.close()

//服务端
//SSE的相应,需要设置如下的Http头信息

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?用通俗易懂的话举例解释,小张经过不懈的努力,终于和小红成为了情侣,从此跟小红过上了幸福的生活,小红对小张也不再不冷不热了,双方都会主动发起聊天对话,小红经常问小张:“我们去吃什么”,“去哪里玩”…小张也会跟小红说:“你在干嘛”,“多喝热水”…

//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、关闭阶段。

  • 握手环节
  1. 首先客户端浏览器要和服务端建立一个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协议将随机字符串放在请求头中发送给服务端。
1.
服务端收到随机字符串后会和服务端的魔法字符串(magic string)(魔法字符串是全球公认的)做一个拼接生成一个大的字符串,然后再用全球公认的算法(sha1+base64)进行加密,生成一个密文,接着将这个密文返回给客户端浏览器。

服务端通过从客户端请求头中读取 Sec-WebSocket-Key 与一串全局唯一的标识字符串(俗称魔串)“258EAFA5-E914-47DA- 95CA-C5AB0DC85B11”做拼接,生成长度为160位的 SHA-1 字符串,然后进行 base64 编码,作为 Sec-WebSocket-Accept 的值回传给客户端。

Connection和Upgrade: 与请求头中的作用相同

Sec-WebSocket-Accept: 表明服务器接受了客户端的请求。

  1. 客户端浏览器收到这个密文之后,会用同样的魔法字符串与自己生成的随机字符串进行拼接,再用和服务端相同的加密算法进行加密也得到有一个密文,然后拿自己的密文与服务端传过来的密文进行比较,如果结果一样,则说明服务端支持websocke协议,如果结果不一样,则说明服务端不支持websocket协议。

数据交换阶段

Websocket 的数据传输是以frame 形式传输的,我们先看一下frame的数据结构:

按照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数据格式,按照先后顺序传输出去。这样做的好处:

大数据的传输可以分片传输,避免长度标志位不足够的情况。
1.
生成数据边传递消息,传输效率得到了极大的提高。

关闭阶段

  • 正常的连接关闭流程
  1. 发送关闭连接请求(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在分片发送时到达,则要等到所有剩余分片发送完之后,才可以作出回复。
  2. 关闭WebSocket连接
    当一端已经收到Close Frame,并已发送了Close Frame时,就可以关闭连接了,close handshake过程结束。这时丢弃所有已经接收到的末尾字节。
  3. 关闭TCP连接
    当底层TCP连接关闭时,连接状态变为Closed。
  • 彻底关闭

如果TCP连接在Close handshake完成之后关闭,就表示WebSocket连接已经彻底关闭了。如果WebSocket连接并未成功建立,状态也为连接已关闭,但并不是彻底关闭。

  • 正常关闭

正常关闭过程属于clean close,应当包含close handshake。

通常来讲,应该由服务器关闭底层TCP连接,而客户端应该等待服务器关闭连接,除非等待超时的话,那么自己关闭底层TCP连接。

服务器可以随时关闭WebSocket连接,而客户端不可以主动断开连接。

  • 异常关闭
  1. 由于某种算法或规定,一端直接关闭连接。(特指在open handshake(打开连接)阶段)
  2. 底层连接丢失导致的连接中断。
  • 连接失败

由于某种算法或规范要求指定连接失败。这时,客户端和服务器必须关闭WebSocket连接。当一端得知连接失败时,不准再处理数据,包括响应close frame。

websocket 心跳检测和重连实现与封装

websocket 在使用过程中,最担心的问题就是:如果遭遇网络问题等,这个时候服务端没有触发onclose事件,这样会产生多余的连接,并且服务端会继续发送消息给客户端,造成数据丢失。需要一种机制来检测客户端和服务端是否处于正常连接的状态,因此,心跳检测和重连机制就产生了。

实现思路:

1、定时器,每隔一段指定的时间,向服务器发送一个数据,服务器收到数据后再发送给客户端,如果客户端通过onmessage事件能监听到服务器返回的数据,那么请求正常。
2、如果指定时间内,客户端没有收到服务器端返回的响应消息,判定连接断开,使用websocket.close关闭连接。
3、关闭连接的动作可以通过onclose事件监听到,因此在 onclose 事件内,我们可以调用reconnect事件进行重连。

以下对心跳检测和重连进行了封装:

/** * websocket心跳检测和重连封装 * * @Author:Fannie * @Date:2021年7月25日 */
class Heart {
  //心跳计时器
  heartTimeout = 0;
  //心跳计时器
  serverHeartTimeout = 0;
  //间隔时间
  timeout: number;
  /** *构造方法 */
  constructor() {
    //设置间隔时间5s
    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;//重连次数 -1 则不限制
  openCb?: CallableFunction;//连接成功的回调
  closeCb?: CallableFunction;//关闭的回调
  messageCb?: CallableFunction;//消息的回调
  errorCb?: CallableFunction;//错误的回调
}
 /** * 编写Socket,继承Heart * **/
class Socket extends Heart {
  ws!: WebSocket;
  reConnecTimer!: number;//重连计时器
  // reConnecCount = 10;//变量保存,防止丢失
  options: options = {
    url: "",
    hearTime: 0,
    heartMsg: "ping",//心跳信息
    isReconnect: true,//是否自动重连
    isRestory: false,//是否销毁
    reconnectTime: 5000,//重连时间间隔
    reconnectCount: -1,//重连次数 -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;
    }
      //websocket四部曲
    this.ws = new WebSocket(this.options.url);
    this.onopen();
    this.onclose()
    this.onmessage()
  }
  /** * 自定义连接成功事件 * 如果callback存在,调用callback,不存在调用options中的回调 * @param callback 回调函数 */
  onopen(callback?: CallableFunction) {

    this.ws.onopen = (event) => {
      clearTimeout(this.reConnecTimer);//清除重连定时器
      // this.options.reconnectCount = this.reConnecCount;//计数器重置
      //建立心跳机制
      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)
      }

    }
  }
  /** * 自定义关闭事件 * 如果callback存在,调用callback,不存在调用options中的回调 * @param callback 回调函数 */
  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)
      }
    }
  }
  /** * 自定义错误事件 * 如果callback存在,调用callback,不存在调用options中的回调 * @param callback 回调函数 */
  onerror(callback?: CallableFunction) {

    this.ws.onerror = (event) => {
      if (typeof callback === "function") {
        callback(event)
      } else {
        (typeof this.options.errorCb === "function") && this.options.errorCb(event)
      }
    }
  }
  /** * 自定义消息监听事件 * 如果callback存在,调用callback,不存在调用options中的回调 * @param callback 回调函数 */
  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)
      }
    }
  }
  /** * 自定义发送消息 * @param 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);
      // this.options.reconnectCount = this.reConnecCount;
    }
  }
  /** * 销毁 */
  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,//重连次数 -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的原理

总结

通过此次的微博总结,加深了自己对前端数据推送各个技术的理解与记忆。时代是在进步的,技术也是如此,不断的呈现问题,解决问题,不断进化。作为一名程序员,也应该保持着与时俱进的能力,应对新时代的技术潮流,不断完善自己的能力,弥补自己的不足。让自己更强大,在工作中更有用武之地。

相关文章