服务器推送(Server Push)是一类特定技术的总称。一般情况,客户端与服务器的交互方式是:客户端发起请求,服务器收到请求返回响应结果,客户端接收响应结果进行处理。从上述的交互过程中可以看出,客户端想要获取数据,需要自主地向服务端发起请求,获取相关数据。

在大多数场景下,客户端的“主动式”行为已经可以满足需求了。然而,在一些场景下,需要服务器“主动”向客户端推送数据。例如:

  • 即时通信系统如聊天室等
  • 实时的数据监控与统计
  • 股票财经类看板等等

这类应用有几个重要特点:要求较高的实时性,同时客户端无法预期数据更新周期,在服务端获取最新数据时,需要将信息同步给客户端,而无须客户端发出请求。这类应用场景被称为“服务器推送”(Server Push)。“服务器推送”技术由来已久,从最初的简单轮询,到后来基于长轮询的COMET,到HTML5规范的SSE,以及实现全双工的WebSocket协议,“服务器推送”的技术不断发展。

实现方式

  • 短轮询
  • comet(长轮询)
  • websocket
  • sse(长连接)

短轮询

简易轮询本质上就是在前端创建一个定时器,每隔一定的时间去查询后端服务,如果有数据则进行相应的处理。本质上还是浏览器发送请求,服务端接受请求的一个过程。

优点:这种做法的优点就是非常简单,几乎不需要进行任何额外的配置或开发

缺点: 这种相当于定时轮询的方式在获取数据上存在显而易见的延迟,要想降低延迟,只能缩短轮询间隔;而另一方面,每次轮询都会进行一次完整的HTTP请求,如果没有数据更新,相当于是一次“浪费”的请求,对服务端资源也是一种浪费。因此,轮询的时间间隔需要进行仔细考虑。轮询的间隔过长,会导致用户不能及时接收到更新的数据;轮询的间隔过短,会导致查询请求过多,增加服务器端的负担。

comet(长轮询)

基于HTTP长连接、无须在浏览器端安装插件的“服务器推送”技术为“Comet”。

常用的COMET分为两种:基于AJAX的长轮询(long-polling)技术,以及基于iframe的长连接流(stream)模式。

  • 基于Ajax的长轮询

    在简单轮询中,我们会每隔一定的时间向后端请求。这种方式最大的问题之一就是,数据的获取延迟受限于轮询间隔,无法第一时间获取服务想要推送数据。长轮询是在此基础上的一种改进,客户端发起请求后,服务端会保持住该连接,直到后端有数据更新后,才会将数据返回给客户端;客户端在收到响应结果后再次发送请求,如此循环往复。

这样,服务端一旦有数据想要推送,可以及时送达到客户端。

AJAX的出现使得JavaScript可以调用XMLHttpRequest对象发出发出Http请求,JavaScript响应处理函数根据服务器返回的信息对HTML页面的显示进行更新。

  • 基于iframe的长连接流(stream)模式

Iframe是html标记,这个标记的src属性会保持对指定服务器的长连接请求,服务器端则可以不停地返回数据,这种方式跟传统的服务器推送则更接近。在长轮询的方式中,浏览器在收到数据后会直接调用JS回调函数,但是这种方式是通过返回数据中嵌入JS脚本的方式,如

1
<script type="text/javascript">js_func(“data from server ”)</script>

服务器端将返回的数据作为回调函数的参数,浏览器在收到数据后就会执行这段JS脚本。使用 iframe 请求一个长连接有一个很明显的不足之处:IE、Morzilla Firefox 下端的进度栏都会显示加载没有完成,而且 IE 上方的图标会不停的转动,表示加载正在进行。Google 使用一个称为“htmlfile”的 ActiveX 解决了在 IE 中的加载显示问题,并将这种方法用到了 gmail+gtalk 产品中。

使用Comet技术需要注意两点:

  1. 控制信息与数据信息使用不同的HTTP连接

    使用长连接时,存在一个很常见的场景:客户端网页需要关闭,而服务器端还处在读取数据的堵塞状态,客户端需要及时通知服务器端关闭数据连接。服务器在收到关闭请求后首先要从读取数据的阻塞状态唤醒,然后释放为这个客户端分配的资源,再关闭连接。所以在设计上,我们需要使客户端的控制请求和数据请求使用不同的 HTTP 连接,才能使控制请求不会被阻塞。在实现上,如果是基于 iframe 流方式的长连接,客户端页面需要使用两个 iframe,一个是控制帧,用于往服务器端发送控制请求,控制请求能很快收到响应,不会被堵塞;一个是显示帧,用于往服务器端发送长连接请求。如果是基于 AJAX 的长轮询方式,客户端可以异步地发出一个 XMLHttpRequest 请求,通知服务器端关闭数据连接。

  2. 在客户和服务器之间保持“心跳”信息

    在浏览器与服务器之间维持一个长连接会为通信带来一些不确定性:因为数据传输是随机的,客户端不知道何时服务器才有数据传送。服务器端需要确保当客户端不再工作时,释放为这个客户端分配的资源,防止内存泄漏。

    在实现上:服务器端在阻塞读时会设置一个时限,超时后阻塞读调用会返回,同时发给客户端没有新数据到达的心跳信息。此时如果客户端已经关闭,服务器往通道写数据会出现异常,服务器端就会及时释放为这个客户端分配的资源。

总结: 轮询与长轮询都是基于HTTP的,两者本身存在着缺陷:轮询需要更快的处理速度;长轮询则更要求处理并发的能力;两者都是“被动型服务器”的体现:服务器不会主动推送信息,而是在客户端发送ajax请求后进行返回的响应。而理想的模型是”在服务器端数据有了变化后,可以主动推送给客户端”,这种”主动型”服务器是解决这类问题的很好的方案。Web Sockets就是这样的方案。

长连接

SSE (Server-Sent Events) 是HTML5标准中的一部分,其实现原理类似于基于iframe的长连接模式。HTTP响应内容有一种特殊的content-type —— text/event-stream,该响应头标识了响应内容为事件流,客户端不会关闭连接,而是等待服务端不断得发送响应结果。SSE规范比较简单,主要分为两个部分:浏览器端可提供JavaScript使用的EventSource对象,以及服务器端与浏览器端之间的通讯协议。在浏览器中可以通过EventSource构造函数来创建该对象

1
var source = new EventSource('/sse');

而SSE的响应内容可以看成是一个事件流,由不同的事件所组成。这些事件会触发前端EventSource对象上的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 默认的事件
source.addEventListener('message', function (e) {
console.log(e.data);
}, false);

// 用户自定义的事件名
source.addEventListener('my_msg', function (e) {
process(e.data);
}, false);

// 监听连接打开
source.addEventListener('open', function (e) {
console.log('open sse');
}, false);

// 监听错误
source.addEventListener('error', function (e) {
console.log('error');
});

EventSource通过事件监听的方式来工作。注意上面的代码监听了my_msg事件,SSE支持自定义事件,默认事件通过监听message来获取数据。

SSE中,每个事件由类型和数据两部分组成,同时每个事件可以有一个可选的标识符。不同事件的内容之间通过仅包含回车符和换行符的空行(”\r\n”)来分隔。每个事件的数据可能由多行组成。

  • 类型为空白,表示该行是注释,会在处理时被忽略。
  • 类型为 data,表示该行包含的是数据。以 data 开头的行可以出现多次。所有这些行都是该事件的数据。
  • 类型为 event,表示该行用来声明事件的类型。浏览器在收到数据时,会产生对应类型的事件。例如我在上面自定义的my_msg事件。
  • 类型为 id,表示该行用来声明事件的标识符。
  • 类型为 retry,表示该行用来声明浏览器在连接断开之后进行再次连接之前的等待时间。

服务器端实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const app = http.createServer((req, res) => {
const sseSend = data => {
res.write('retry:10000\n');
res.write('event:my_msg\n');
// 注意文本数据传输
res.write(`data:${JSON.stringify(data)}\n\n`);
};

// 注意设置响应头的content-type
res.setHeader('content-type', 'text/event-stream');
// 一般不会缓存SSE数据
res.setHeader('cache-control', 'no-cache');
res.setHeader('connection', 'keep-alive');
res.statusCode = 200;

res.write('retry:10000\n');
res.write('event:my_msg\n\n');

EVENT.addListener(MSG_POST, sseSend);

req.socket.on('close', () => {
console.log('sse socket close');
EVENT.removeListener(MSG_POST, sseSend);
});
});

websocket

WebSocket与http协议一样都是基于TCP的。WebSocket其实不仅仅限于“服务器推送”了,它是一个全双工的协议,适用于需要进行复杂双向数据通讯的场景。因此也有着更复杂的规范。当客户端要和服务端建立WebSocket连接时,在客户端和服务器的握手过程中,客户端首先会向服务端发送一个HTTP请求,包含一个Upgrade请求头来告知服务端客户端想要建立一个WebSocket连接。

客户端建立一个WebSocket连接

1
var ws = new WebSocket('ws://127.0.0.1:8080');

当然,类似于HTTPHTTPSws相对应的也有wss用以建立安全连接。

1
2
3
4
5
6
7
8
9
10
11
12
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: Upgrade
Cookie: Hm_lvt_4e63388c959125038aabaceb227cea91=1527001174
Host: 127.0.0.1:8080
Origin: http://127.0.0.1:8080
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: 0lUPSzKT2YoUlxtmXvdp+w==
Sec-WebSocket-Version: 13
Upgrade: websocket

而服务器在收到请求后进行处理,响应头如下

1
2
3
4
Connection: Upgrade
Origin: http://127.0.0.1:8080
Sec-WebSocket-Accept: 3NOOJEzyscVfEf0q14gkMrpV20Q=
Upgrade: websocket

表示升级到了WebSocket协议。

注意,上面的请求头中有一个Sec-WebSocket-Key,这其实和加密、安全性关系不大,最主要的作用是来验证服务器是否真的正确“理解”了WebSocket、该WebSocket连接是否有效。服务器会使用Sec-WebSocket-Key,并根据一个固定的算法

1
2
mask = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";  // 一个规定的字符串
accept = base64(sha1(key + mask));

生成Sec-WebSocket-Accept响应头字段,交由浏览器验证。接下来,浏览器与服务器之间就可以愉快地进行双向通信了。在浏览器端,建立WebSocket连接后,可以通过onmessage来监听数据信息。

1
2
3
4
5
6
7
8
var ws = new WebSocket('ws://127.0.0.1:8080');
ws.onopen = function () {
console.log('open websocket');
};
ws.onmessage = function (e) {
var data = JSON.parse(e.data);
process(data);
};

在服务器端,由于WebSocket协议具有较多的规范与细节需要处理,因此建议使用一些封装较完备的第三方库。例如node中的websocket-node和著名的socket.io。node部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const http = require('http');
const WebSocketServer = require('websocket').server;

const app = http.createServer((req, res) => {
// ...
});
app.listen(process.env.PORT || 8080);
const ws = new WebSocketServer({
httpServer: app
});
ws.on('request', req => {
let connection = req.accept(null, req.origin);
let wsSend = data => {
connection.send(JSON.stringify(data));
};
// 接收客户端发送的数据
connection.on('message', msg => {
console.log(msg);
});
connection.on('close', con => {
console.log('websocket close');
EVENT.removeListener(MSG_POST, wsSend);
});
// 当有数据更新时,使用WebSocket连接来向客户端发送数据
EVENT.addListener(MSG_POST, wsSend);
});