服务端推送方案
构建一个实时的Web应用程序具有挑战性,我们需要考虑如何将数据从服务器发送到客户端。实现这种“主动”的技术已经存在了相当长的一段时间,并且仅限于两种通用方法:客户端拉取或服务器推送。
实现这些有几种方法:
- 长/短轮询(客户端拉取)
- WebSockets(服务器发送)
- Server-Sent Events(服务器发送)
客户端拉取-客户端要求服务器定期更新
服务器推送-服务器主动向客户端推送更新(与客户端拉取相反)
让我们以一个简单的用例来比较上述技术并进行技术选型。
Example
我们的示例非常简单。我们需要开发一个仪表板Web应用程序,它可以从网站(eg:github/twitter/...)流式传输活动列表。这个应用程序的目的是在前面列出的各种方法中选择正确的方法。
一、轮询
轮询是一种客户机定期向服务器请求新数据的技术。我们可以用两种方式进行轮询:短轮询
和长轮询
。
简单地说,短轮询是一个基于Ajax的计时器,它以固定的延迟进行调用,而长轮询则基于Comet(即,当服务器事件发生时,服务器将不延迟地(实时)向客户机发送数据)。两者都有优点和缺点,并且都适合基于用例的情况。有关深入的详细信息,请阅读StackOverflow社区给出的答案。
让我们看看一个简单的客户端长轮询
片段可能是什么样子的:
/* Client - subscribing to the github events */
subscribe: (callback) => {
const pollUserEvents = () => {
$.ajax({
method: 'GET',
url: 'http://localhost:8080/githubEvents',
success: (data) => {
// 请求成功时调用
callback(data) // process the data
},
complete: () => {
// 请求完成时调用(无论成功与否)
pollUserEvents();
},
timeout: 30000
})
}
pollUserEvents()
}
这基本上是一个很长的轮询函数,它像往常一样第一次运行,但它设置了三十(30)秒的超时,在每次对服务器的异步Ajax调用之后,回调再次调用Ajax。
页面发起一个到服务器的请求,然后服务器一直保持连接打开,直到有数据可发送。发送完数据之后,浏览器关闭连接,随机又发起了一个到服务器的新请求。这一过程在页面打开期间一直持续不断。
Ajax调用在HTTP协议上工作,这意味着对同一域的请求在默认情况下应该是多路传输的。我们用这种方法发现了一些缺陷。
- 需要3次往返的轮询(TCP SIN、SSL和Data)
- 超时(如果代理服务器空闲时间过长,连接将被关闭)
二、WebSocket
基于Http协议的扩展,支持长连接,用于建立客户端和服务器的双向通道。
而传统的轮询方式(即采用http协议不断发送请求)的缺点:浪费流量(http请求头比较大)、浪费资源(没有更新也要请求)、消耗服务器CPU占用(没有信息也要接收请求)。
实现过程:
在Javascript中创建了WebSockets之后,会有一个 Http 的 Upgrade 请求发送到服务器,在取得服务器响应后,建立的连接会从 Http升级,从 Http协议转换为WebSocket协议。
So:
1、客户端发送 Http GET请求, upgrade
2、服务器响应给客户端 switching protocol。【Http => WebSocket】
3、可以进行 WebSocket 通信。
HTTP(协议)和WebSocket(协议)都位于OSI模型的应用层,因此依赖于第4层的TCP。
有一个MDN文档详细解释了WebSocket,我建议您也阅读它。
让我们看看一个非常简单的WebSocket客户端实现可能是什么样子的:
$(function () {
// if user is running mozilla then use it's built-in WebSocket
window.WebSocket = window.WebSocket || window.MozWebSocket;
const connection = new WebSocket('ws://localhost:8080/githubEvents');
connection.onopen = function () {
// connection is opened and ready to use
};
connection.onerror = function (error) {
// an error occurred when sending/receiving data
};
connection.onmessage = function (message) {
// try to decode json (I assume that each message
// from server is json)
try {
const githubEvent = JSON.parse(message.data); // display to the user appropriately
} catch (e) {
console.log('This doesn\'t look like a valid JSON: '+ message.data);
return;
}
// handle incoming message
};
});
如果服务器支持WebSocket协议,它将同意升级,并通过响应中的升级头进行通信。
让我们来看看如何在NodeJs中实现它:
const express = require('express');
const events = require('./events');
const path = require('path');
const app = express();
const port = process.env.PORT || 5001;
const expressWs = require('express-ws')(app);
app.get('/', function(req, res) {
res.sendFile(path.join(__dirname + '/static/index.html'));
});
app.ws('/', function(ws, req) {
const githubEvent = {}; // sample github Event from Github event API https://api.github.com/events
ws.send('message', githubEvent);
});
app.listen(port, function() {
console.log('Listening on', port);
});
一旦我们从Github事件API获取数据,我们就可以在建立连接后将其流式传输到客户端。对于我们的情况来说,这种方法也有一些缺陷。
- 对于WebSockets,我们需要处理HTTP中的许多问题。
- WebSocket是一种不同的数据传输协议,它不是通过HTTP/2连接自动多路复用的。在服务器和客户端上实现自定义多路复用有点复杂。
- WebSockets是基于帧的,而不是基于流的。当我们打开网络选项卡时。您可以看到WebSocket消息列在框架下。
有关WebSocket的深入细节,请阅读这篇很棒的文章,在这里您可以阅读更多关于碎片以及如何在引擎盖下处理碎片的内容。
三、SSE (Server-Sent Events)
SSE是一种机制,允许服务器在建立客户端-服务器连接后将数据异步推送到客户端。然后,服务器可以决定在新的“数据块”可用时发送数据。它可以看作是一个单向发布订阅模型。
它还提供了一个名为EventSource的标准javascript客户端API,在大多数现代浏览器中实现,作为W3C的HTML5标准的一部分。Polyfills可用于不支持EventSource API的浏览器。
我们可以看到,Edge和Opera Mini落后于这一实施,SSE最重要的案例是针对移动浏览器设备,这些浏览器在这些设备中没有可行的市场份额。Yaffle是一种众所周知的EventSource pollyfill。
由于SSE是基于HTTP的,它与HTTP/2具有天然的契合性,并且可以组合以获得最好的两个:HTTP/2处理基于复用流的高效传输层和SSE,提供API到应用程序以启用推送。因此,通过HTTP/2实现多路复用。连接断开时会通知客户端和服务器。通过消息维护唯一的ID,服务器可以看到客户端丢失了n条消息,并在重新连接时发送丢失的积压消息。
让我们看看一个简单的客户端实现可能是怎样的:
const evtSource = new EventSource('/events');
evtSource.addEventListener('event', function(evt) {
const data = JSON.parse(evt.data);
// Use data here
},false);
这段代码相当简单。它连接到我们的源并等待接收消息。这个例子nodejs服务器将类似于这样。
// events.js
const EventEmitter = require('eventemitter3');
const emitter = new EventEmitter();
function subscribe(req, res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
});
// Heartbeat
const nln = function() {
res.write('\n');
};
const hbt = setInterval(nln, 15000);
const onEvent = function(data) {
res.write('retry: 500\n');
res.write(`event: event\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
emitter.on('event', onEvent);
// Clear heartbeat and listener
req.on('close', function() {
clearInterval(hbt);
emitter.removeListener('event', onEvent);
});
}
function publish(eventData) {
// Emit events here recieved from Github/Twitter APIs
emitter.emit('event', eventData);
}
module.exports = {
subscribe, // Sending event data to the clients
publish // Emiting events from streaming servers
};
// App.js
const express = require('express');
const events = require('./events');
const port = process.env.PORT || 5001;
const app = express();
app.get('/events', cors(), events.subscribe);
app.listen(port, function() {
console.log('Listening on', port);
});
这种方法的主要好处是:
- 更简单的实现和数据效率
- 它是通过HTTP/2在开箱即用的情况下自动多路复用的。
- 将客户端上数据的连接数限制为一个
如何选择SSE、WebSocket和轮询?
在详尽的客户端和服务器示例实现之后,看起来SSE
是我们数据传输问题的最终答案。它也有一些问题,但可以解决。
可以使用Server-Sent Events的应用程序的几个简单示例:
- 流式股票价格实时图表
- 重要事件的实时新闻报道(发布链接、推文和图像)
- 由Twitter流式API提供的实时Github/Twitter仪表板墙
- 监视服务器统计信息,如正常运行时间、运行状况和正在运行的进程。
然而,SSE不仅仅是提供快速更新的其他方法的可行替代方案。在一些特定的场景中,每个场景都比其他场景占优势,比如在我们的案例中,SSE被证明是一个理想的解决方案。考虑一个像MMO(大型多人在线)游戏这样的场景,它需要连接两端的大量消息。在这种情况下,websockets控制SSE。
如果您的用例需要显示实时的市场新闻、市场数据、聊天应用程序等,比如在我们的例子中,依赖于HTTP/2+SSE将为您提供一个高效的双向通信通道,同时从HTTP世界中获益。
如果您想使用我刚刚使用的用例,请拉取Github代码。
其他知识点
- http/1.1 keep alive和http/2 多路复用区别?
参考
- Polling vs SSE vs WebSocket— How to choose the right one
- Server-Sent Events 教程——阮一峰
- WebSocket 教程——阮一峰
- HTTP2.0关于多路复用的研究
- 浅析HTTP/2的多路复用