背景

在特定场景下,我们往往需要实时的去获取最新的数据,如获取消息推送或公告、聊天消息、实时的日志和学情等,都对数据的实时性要求很高,面对这类场景,最常用的可能就是轮询,但除了轮询还有长连接(Websocket)和服务端推送(SSE)方案可供选择。

轮询

轮询就是采用循环http请求的方式,通过重复的接口请求去获取最新的数据。

短轮询 (polling)

短轮询可能是我们用的最多的一种实时刷新数据的方式了,我们在讲轮询方案时,大部分指的就是短轮询,其实现方式和普通的接口无异,改造也只要前端增加定时器或useRequest配置轮询参数即可,其原理也非常简单,如下图,如果是http1.1及以上,TCP连接可以复用,当然http1.0及以下也是可以使用,但消耗会更多。短轮询的特点就是接口请求立即会返回,每次请求都可以理解为是一次新的请求。

短轮询的优缺点

短轮询最大的优点就是简单,前端设置时间间隔,定时去请求数据,而服务端只需同步的查询数据返回即可,但缺点也显而易见:

  1. 无用请求过多:从下图可以看出,每隔固定时间,一定有请求发出,且每次接口可能返回一样的数据或返回空结果,服务端会重复查询数据库、前端会重复重渲染
  2. 实时性不可控,如数据更新了,但轮询请求刚结束一轮,会造成轮询间隔内数据都得不到更新

长轮询 (long polling)

看完了上面关于短轮询的介绍,我们知道了轮询有两个主要的缺陷:一个是无用请求过多,另外一个是数据实时性不可控。为了解决这两个问题,于是有了更进一步的长轮询方案。

在上图中,客户端发起请求后,服务端发现当前没有新的数据,这个时候服务端没有立即返回请求,而是将请求挂起,在等待一段时间后(一般为30s或者是60s,设置一个超时返回主要是为了考虑过长的无数据连接占用会被网关或者某层中间件断开甚至是被运营商断开),如发现还是没有数据更新的话,就返回一个空结果给客户端。客户端在收到服务端的回复后,立即再次向服务端发送新的请求。这次服务端在接收到客户端的请求后,同样等待了一段时间,这次好运的是服务端的数据发生了更新,服务端给客户端返回了最新的数据。客户端在拿到结果后再次发送下一个请求,如此反复。

长轮询的优缺点

长轮询很完美地解决了短轮询的问题,首先服务端在没有数据更新的情况下没有给客户端返回数据,所以避免了客户端大量的重复请求。再者客户端在收到服务端的返回后,马上发送下一个请求,这就保证了更好的数据实时性。不过长轮询也有缺点:

  • 服务端资源大量消耗: 服务端会一直hold住客户端的请求,这部分请求会占用服务器的资源。对于某些语言来说,每一个HTTP连接都是一个独立的线程,过多的HTTP连接会消耗掉服务端的内存资源。
  • 难以处理数据更新频繁的情况: 如果数据更新频繁,会有大量的连接创建和重建过程,这部分消耗是很大的。虽然HTTP有TCP连接复用,但每次拿到数据后客户端都需要重新请求,因此相对于WebSocket和SSE它多了一个发送新请求的阶段,对实时性和性能还是有影响的。

从上面的描述来看,长轮询的次数和时延似乎可以更少,那是不是长轮询更好呢?其实不是的,这个两种轮询方式都有优劣势和适合的场景。

短轮询 ,长轮询怎么选?

长 轮询多用于操作频繁,点对点的通讯,而且连接数不能太多情况,每个TCP连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,次处理时直接发送数据包就OK了,不用建立TCP连接。例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成socket错误,而且频繁的socket 创建也是对资源的浪费。

而像WEB网站的http服务一般都用短 轮询,因为长连接对于服务端来说会耗费一定的资源,而像WEB网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源,如果用长连接,而且同时有成千上万的用户,如果每个用户都占用一个连接的话,那可想而知吧。所以并发量大,但每个用户无需频繁操作情况下需用短连好。

长连接

​WebSocket​

上面说到长轮询不适用于服务端资源频繁更新的场景,而解决这类问题的一个方案就是WebSocket。用最简单的话来介绍WebSocket就是:客户端和服务器之间建立一个持久的长连接,这个连接是双工的,客户端和服务端都可以实时地给对方发送消息。下面是WebSocket的图示:

WebSocket对于前端的同学来说是非常常见了,因为无论是webpack还是vite,用来HMR的reload就是通过WebSocket来进行的,有代码改动,工程重新编译,新变更的模块通知到浏览器加载新的模块,这里的通知浏览器加载新模块就是通过WebSocket的进行的。如上图,通过握手(协议转换)建立连接后,双方就保持持久连接,由于历史的关系,WebSocket建立连接是依赖HTTP的,但是其建连请求有明显的特征,目的是客户端和服务端都能识别并保持连接。

请求特征

请求头特征

  • HTTP 必须是 1.1 GET 请求
  • HTTP Header 中 Connection 字段的值必须为 Upgrade
  • HTTP Header 中 Upgrade 字段必须为 websocket
  • Sec-WebSocket-Key 字段的值是采用 base64 编码的随机 16 字节字符串
  • Sec-WebSocket-Protocol 字段的值记录使用的子协议,比如 binary base64
  • Origin 表示请求来源

响应头特征

  • 状态码是 101 表示 Switching Protocols
  • Upgrade / Connection / Sec-WebSocket-Protocol 和请求头一致
  • Sec-WebSocket-Accept 是通过请求头的 Sec-WebSocket-Key 生成

兼容性

WebSocket 协议在2008年诞生,2011年成为国际标准。现在所有浏览器都已经支持了。

实现一个简单的 WebSocket

基于原生WebSocket我们实现一个简单的长连。

连接

// 连接只需实例一个WebSocket
const ws = new WebSocket(`wss://${url}`);

发送消息

  ws.send("这是一条消息:"   count);

监听消息

ws.onmessage = function (event) {
console.log(event.data);
}

关闭连接

ws.close();

​在工程上使用WebSocket​

在工程上,很少直接基于原生WebSocket实现业务需求,使用WebSocket需要完成下面几个问题:

  • 鉴权:防止恶意连接连接进来接收消息
  • 心跳:客户端意外断开,导致死链占用服务端资源,长时间无消息的连接可能会被中间网关或运营商断开
  • 登录:通过建连需要识别出该连接是哪个用户,有无权限,需要推送哪些消息
  • 日志:监控连接,错误上报
  • 后台:能方便的查看在线连接的客户端数量,消息传输量

服务端推送(SSE)

SSE全称Server-sent Events,是HTML 5 规范的一个组成部分,该规范十分简单,主要由两个部分组成:第一个部分是服务器端与浏览器端之间的通讯协议,第二部分则是在浏览器端可供 JavaScript 使用的 EventSource 对象。通讯协议是基于纯文本的简单协议。服务器端的响应的内容类型是“text/event-stream”。响应文本的内容可以看成是一个事件流,由不同的事件所组成。每个事件由类型和数据两部分组成,同时每个事件可以有一个可选的标识符。不同事件的内容之间通过仅包含回车符和换行符的空行(“rn”)来分隔。每个事件的数据可能由多行组成。

​和 Websocket对比​

SSE

WebSocket

单向:仅服务端能发送消息

双向:客户端、服务端双向发送

仅文本数据

二进制、文本都可

常规HTTP协议

WebSocket协议

​兼容性​

​数据格式​

服务器向浏览器发送的 SSE 数据,必须是 UTF-8 编码的文本,

响应头

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

数据传输

服务端每次发送消息,由若干message​组成,使用nn​分隔,如果单个messag​过长,可以用n分隔。

    

field取值

data
event
id
retry

例子

// 注释,用于心跳包
: this is a test streamnn
// 设置断链1000ms重试一次
retry:1000 nn
event: 自定义消息nn

data: some textnn

data: another messagen
data: with two lines nn

​实现一个简单的SSE​

web端

实例化EventSource​,监听open、message、error

const source = new EventSource(url, { withCredentials: true });
// 监听消息
source.onmessage = function (event) {
// handle message
};
source.addEventListener('message', function (event) {
// handle message
}, false);

// 监听错误
source.onerror = function (event) {
// handle error
};
source.addEventListener('error', function (event) {
// handle error
}, false);

// 关闭连接
source.close()

服务端

以nodejs为例,服务端代码和普通请求无异,并没有新的处理类库。

    res.writeHead(200, {
"Content-Type":"text/event-stream",
"Cache-Control":"no-cache",
"Connection":"keep-alive",
"Access-Control-Allow-Origin": '*',
});
res.write("retry: 10000nn");
res.write("event: connecttimenn");
res.write("data: " (new Date()) "n");
res.write("data: " (new Date()) "nn");

// 模拟收到消息推送给客户端
interval = setInterval(function () {
res.write("data: " (new Date()) "nn");
}, 1000);

和WebSocket不同,SSE并不是新的通信协议,其本质是在普通HTTP请求的基础上定义一个Content-Type​,保持上连接,通过普通的接口也能模拟出SSE的效果,以XMLHttpRequest为例

     const xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:8844/long", true);
xhr.onload = (e) => {
console.log("onload", xhr.responseText);
};
xhr.onprogress = (e) => {
// 每次服务端写入response的数据,都会传输过来,并产生一次onprogress事件
console.log("onprogress", xhr.responseText);
};
xhr.send();

参考文献

rfc6455.pdf[1]

WebSocket协议中文版(rfc6455)[2]

深入剖析WebSocket的原理 - 知乎[3]

HTTP长连接实现原理 - 掘金[4]

WebSocket() - Web API 接口参考 | MDN[5]

EventSource - Web API 接口参考 | MDN[6]​