使用 SSE 而不是 WebSockets 实现 HTTP/2 上的单向数据流

已发表: 2022-03-10
快速总结↬每当我们使用实时数据设计 Web 应用程序时,我们都需要考虑如何将数据从服务器传送到客户端。 默认答案通常是“WebSockets”。 但是有更好的方法吗? 让我们比较一下三种不同的方法:Long polling、WebSockets 和 Server-Sent Events; 了解它们在现实世界中的局限性。 答案可能会让你大吃一惊。

在构建 Web 应用程序时,必须考虑他们将使用哪种交付机制。 假设我们有一个处理实时数据的跨平台应用程序; 一种股票市场应用程序,提供实时买卖股票的能力。 该应用程序由为不同用户带来不同价值的小部件组成。

当涉及到从服务器到客户端的数据传递时,我们仅限于两种通用方法:客户端拉取服务器推送。 作为任何 Web 应用程序的简单示例,客户端是 Web 浏览器。 当浏览器中的网站向服务器请求数据时,这称为客户端拉取。 相反,当服务器主动向您的网站推送更新时,称为服务器推送

如今,有几种方法可以实现这些:

  • 长/短轮询(客户端拉取)
  • WebSockets(服务器推送)
  • 服务器发送事件(服务器推送)。

在我们为我们的业务案例设定要求之后,我们将深入研究这三个备选方案。

商业案例

为了能够在不重新部署整个平台的情况下快速为我们的股票市场应用程序提供新的小部件并即插即用,我们需要这些小部件是独立的并管理它们自己的数据 I/O。 小部件不以任何方式相互耦合。 在理想情况下,他们都将订阅某个 API 端点并开始从中获取数据。 除了加快新功能的上市时间外,这种方法还使我们能够将内容导出到第三方网站,而我们的小部件则可以自行带来他们需要的一切。

跳跃后更多! 继续往下看↓

这里的主要缺陷是连接数量将随着我们拥有的小部件数量线性增长,并且我们将达到浏览器一次处理的 HTTP 请求数量的限制。

我们的小部件将要接收的数据主要由数字及其数字更新组成:初始响应包含十只股票,其中包含一些市场价值。 这包括添加/删除股票的更新以及当前提供的股票的市场价值的更新。 我们尽可能快地为每次更新传输少量 JSON 字符串。

HTTP/2 提供了来自同一域的请求的多路复用,这意味着我们只能为多个响应获得一个连接。 这听起来可以解决我们的问题。 我们首先探索不同的选项来获取数据,看看我们能从中得到什么。

  • 我们将使用 NGINX 进行负载平衡和代理,以将我们所有的端点隐藏在同一个域后面。 这将使我们能够开箱即用地使用 HTTP/2 多路复用。
  • 我们希望有效地使用移动设备的网络和电池。

替代方案

长轮询

长轮询

客户端拉动是软件实现,相当于坐在汽车后座上不断问“我们到了吗?”的烦人孩子。 简而言之,客户端向服务器请求数据。 服务器没有数据并在发送响应之前等待一段时间:

  • 如果在等待期间弹出某些东西,服务器会发送它并关闭请求;
  • 如果没有什么要发送,并且达到了最大等待时间,则服务器发送没有数据的响应;
  • 在这两种情况下,客户端都会打开下一个数据请求;
  • 起泡,冲洗,重复。

AJAX 调用在 HTTP 协议上工作,这意味着对同一域的请求默认情况下应该被多路复用。 但是,我们在尝试使其按要求工作时遇到了多个问题。 我们在小部件方法中发现的一些缺陷:

  • 标头开销
    每个轮询请求和响应都是一个完整的 HTTP 消息,并且在消息帧中包含一整套 HTTP 标头。 在我们有少量频繁消息的情况下,标头实际上代表了传输数据的较大百分比。 实际有用的有效载荷远小于传输的总字节数(例如,5 KB 数据的 15 KB 报头)。

  • 最大延迟
    服务器响应后,不能再向客户端发送数据,直到客户端发出下一个请求。 虽然长轮询的平均延迟接近一次网络传输,但最大延迟超过三个网络传输:响应、请求、响应。 但是,由于数据包丢失和重传,任何 TCP/IP 协议的最大延迟将超过三个网络传输(通过 HTTP 流水线可以避免)。 虽然在直接 LAN 连接中这不是一个大问题,但当一个人在移动和切换网络单元时,它就会变成一个问题。 在某种程度上,这在 SSE 和 WebSockets 中可以观察到,但在轮询时效果最大。

  • 连接建立
    尽管可以通过使用可重用于许多轮询请求的持久 HTTP 连接来避免这种情况,但是要相应地安排所有组件在短时间内轮询以保持连接处于活动状态是很棘手的。 最终,根据服务器的响应,您的投票将不同步。

  • 性能下降
    负载不足的长轮询客户端(或服务器)具有以消息延迟为代价降低性能的自然趋势。 发生这种情况时,推送到客户端的事件将排队。 这真的取决于实施; 在我们的例子中,我们需要在向我们的小部件发送添加/删除/更新事件时聚合所有数据。

  • 超时
    长轮询请求需要保持挂起状态,直到服务器有东西要发送给客户端。 如果代理服务器闲置时间过长,这可能会导致连接被关闭。

  • 多路复用
    如果响应同时通过持久的 HTTP/2 连接发生,则可能会发生这种情况。 这可能很棘手,因为轮询响应不能真正同步。

有关长轮询可能遇到的实际问题的更多信息,请参见此处

网络套接字

网络套接字

作为服务器推送方法的第一个示例,我们将研究 WebSockets。

通过 MDN:

WebSockets 是一种先进的技术,可以在用户的​​浏览器和服务器之间打开交互式通信会话。 使用此 API,您可以向服务器发送消息并接收事件驱动的响应,而无需轮询服务器以获取回复。

这是一种通过单个 TCP 连接提供全双工通信通道的通信协议。

HTTP 和 WebSocket 都位于 OSI 模型的应用层,因此依赖于第 4 层的 TCP。

  1. 应用
  2. 介绍
  3. 会议
  4. 运输
  5. 网络
  6. 数据链接
  7. 身体的

RFC 6455 指出,WebSocket“旨在通过 HTTP 端口 80 和 443 工作,并支持 HTTP 代理和中介”,从而使其与 HTTP 协议兼容。 为了实现兼容性,WebSocket 握手使用 HTTP Upgrade 标头从 HTTP 协议更改为 WebSocket 协议。

还有一篇非常好的文章,解释了您需要了解的关于 Wikipedia 上的 WebSockets 的所有信息。 我鼓励你阅读它。

在确定套接字实际上可以为我们工作之后,我们开始在我们的业务案例中探索它们的功能,并一堵又一堵地碰壁。

  • 代理服务器:一般来说,WebSockets 和代理有几个不同的问题:

    • 第一个与互联网服务提供商及其处理网络的方式有关。 半径代理问题阻塞端口等等。
    • 第二类问题与代理配置为处理不安全的 HTTP 流量和长期连接的方式有关(使用 HTTPS 会减少影响)。
    • 第三个问题“使用 WebSockets,你被迫运行 TCP 代理而不是 HTTP 代理。 TCP 代理不能注入标头、重写 URL 或执行我们传统上由 HTTP 代理处理的许多角色。”
  • 连接数:围绕数字 6 的 HTTP 请求的著名连接限制不适用于 WebSockets。 50 个套接字 = 50 个连接。 10 个浏览器选项卡乘以 50 个套接字 = 500 个连接等等。 由于 WebSocket 是用于传递数据的不同协议,因此它不会自动通过 HTTP/2 连接进行多路复用(它根本没有真正运行在 HTTP 之上)。 在服务器和客户端上实现自定义多路复用太复杂,无法使套接字在指定的业务案例中有用。 此外,这将我们的小部件耦合到我们的平台,因为它们需要客户端上的某种 API 来订阅,没有它我们无法分发它们。

  • 负载均衡(无多路复用) :如果每个用户打开n个套接字,则适当的负载均衡非常复杂。 当您的服务器超载时,您需要根据软件的实施创建新实例并终止旧实例,在“重新连接”时采取的操作可能会触发大量刷新链和对数据的新请求,这将使您的系统超载. WebSockets 需要在服务器和客户端上维护。 如果当前的服务器负载很高,则无法将套接字连接移动到另一台服务器。 它们必须关闭并重新打开。

  • DoS :这通常由前端 HTTP 代理处理,而 WebSocket 所需的 TCP 代理无法处理这些代理。 可以连接到套接字并开始用数据淹没您的服务器。 WebSocket 让您容易受到此类攻击。

  • 重新发明轮子:使用 WebSockets,人们必须自己处理许多在 HTTP 中处理的问题。

可以在此处阅读有关 WebSockets 实际问题的更多信息。

WebSockets 的一些很好的用例是聊天和多人游戏,其中的好处超过了实现问题。 由于它们的主要好处是双向通信,而我们并不真正需要它,我们需要继续前进。

影响

我们在开发、测试和扩展方面增加了运营开销; 软件及其 IT 基础设施:轮询和 WebSockets。

我们在移动设备和网络上遇到了同样的问题。 这些设备的硬件设计通过保持天线和与蜂窝网络的连接保持活跃来保持开放连接。 这会导致电池寿命缩短、发热,在某些情况下还会导致额外的数据费用。

但为什么我们仍然有移动设备的问题?

让我们考虑一下默认移动设备是如何连接到互联网的:

移动设备在能够连接到 Internet 之前需要经过几个环节。

对移动网络如何工作的简单解释:通常,移动设备具有可以从小区接收数据的低功率天线。 这样,一旦设备从来电中接收到数据,它就会启动全双工天线以建立呼叫。 每当想拨打电话或访问 Internet(如果没有 WiFi 可用)时,都会使用相同的天线。 全双工天线需要与蜂窝网络建立连接并进行一些身份验证。 建立连接后,您的设备和手机之间会进行一些通信,以执行我们的网络请求。 我们被重定向到处理 Internet 请求的移动服务提供商的内部代理。 从那时起,该过程就为人所知:它向 DNS 询问www.domainname.ext的实际位置,接收资源的 URI,并最终重定向到它。

正如您可以想象的那样,此过程会消耗大量电池电量。 这就是为什么手机厂商给出的待机时间为几天,通话时间为几个小时的原因。

如果没有 WiFi,WebSockets 和轮询都需要全双工天线几乎持续工作。 因此,我们也面临着数据消耗增加和功耗增加(取决于设备)的热量。

当事情看起来很黯淡时,看起来我们将不得不重新考虑我们的应用程序的业务需求。 我们错过了什么吗?

上证所

服务器发送的事件

通过 MDN:

“EventSource 接口用于接收服务器发送的事件。 它通过 HTTP 连接到服务器,并以文本/事件流格式接收事件,而无需关闭连接。”

轮询的主要区别在于我们只获得一个连接并保持事件流通过它。 长轮询为每次拉取创建一个新的连接——因此我们在那里遇到的标题开销和其他问题。

通过 html5doctor.com:

服务器发送事件是由服务器发出并由浏览器接收的实时事件。 它们与 WebSocket 的相似之处在于它们是实时发生的,但它们在很大程度上是一种来自服务器的单向通信方法。

这看起来有点奇怪,但经过考虑——我们的主要数据流是从服务器到客户端,而从客户端到服务器的情况要少得多。

看起来我们可以将其用于交付数据的主要业务案例。 我们可以通过发送新请求来解决客户购买问题,因为协议是单向的,客户端无法通过它向服务器发送消息。 这最终将导致全双工天线在移动设备上启动的时间延迟。 然而,我们可以忍受它不时发生——毕竟这种延迟是以毫秒为单位的。

独特的功能

  • 连接流来自服务器并且是只读的。
  • 他们使用常规 HTTP 请求进行持久连接,而不是特殊协议。 开箱即用地通过 HTTP/2 进行多路复用。
  • 如果连接断开,EventSource 会触发错误事件并自动尝试重新连接。 服务器还可以在客户端尝试重新连接之前控制超时(稍后会详细说明)。
  • 客户端可以发送带有消息的唯一 ID。 当客户端在断开连接后尝试重新连接时,它将发送最后一个已知 ID。 然后,服务器可以看到客户端错过了n条消息,并在重新连接时发送积压的错过消息。

示例客户端实施

这些事件类似于浏览器中发生的普通 JavaScript 事件——比如点击事件——除了我们可以控制事件的名称和与之关联的数据。

让我们看看客户端的简单代码预览:

 // subscribe for messages var source = new EventSource('URL'); // handle messages source.onmessage = function(event) { // Do something with the data: event.data; };

我们从示例中看到的是客户端相当简单。 它连接到我们的源并等待接收消息。

为了使服务器能够通过 HTTP 或使用专用的服务器推送协议将数据推送到网页,规范在客户端引入了“EventSource”接口。 使用这个 API 包括创建一个 `EventSource` 对象和注册一个事件监听器。

WebSockets 的客户端实现看起来与此非常相似。 套接字的复杂性在于 IT 基础设施和服务器实施。

事件源

每个EventSource对象都有以下成员:

  • URL:施工时设置。
  • 请求:最初为空。
  • 重新连接时间:以 ms 为单位的值(用户代理定义的值)。
  • 最后一个事件 ID:最初是一个空字符串。
  • 就绪状态:连接状态。
    • 连接 (0)
    • 打开 (1)
    • 已关闭 (2)

除了 URL,所有的都被视为私有的,不能从外部访问。

内置事件:

  1. 打开
  2. 信息
  3. 错误

处理连接中断

如果连接断开,浏览器会自动重新建立连接。 服务器可能会发送超时以重试或永久关闭连接。 在这种情况下,浏览器要么在超时后尝试重新连接,要么在连接收到终止消息时根本不尝试。 看起来相当简单——事实上也是如此。

示例服务器实现

那么如果客户端那么简单,也许服务器实现很复杂?

好吧,SSE 的服务器处理程序可能如下所示:

 function handler(response) { // setup headers for the response in order to get the persistent HTTP connection response.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); // compose the message response.write('id: UniqueID\n'); response.write("data: " + data + '\n\n'); // whenever you send two new line characters the message is sent automatically }

我们定义了一个处理响应的函数:

  1. 设置标题
  2. 创建消息
  3. 发送

请注意,您看不到send()push()方法调用。 这是因为标准定义了消息将在收到两个\n\n字符后立即发送,如示例中所示: response.write("data: " + data + '\n\n'); . 这将立即将消息推送到客户端。 请注意, data必须是转义字符串,并且末尾没有换行符。

消息构造

如前所述,消息可以包含一些属性:

  1. ID
    如果字段值不包含 U+0000 NULL,则将最后一个事件 ID 缓冲区设置为字段值。 否则,忽略该字段。
  2. 数据
    将字段值附加到数据缓冲区,然后将单个 U+000A 换行 (LF) 字符附加到数据缓冲区。
  3. 事件
    将事件类型缓冲区设置为字段值。 这会导致event.type获取您的自定义事件名称。
  4. 重试
    如果字段值仅包含 ASCII 数字,则将字段值解释为以十为底的整数,并将事件流的重新连接时间设置为该整数。 否则,忽略该字段。

其他任何内容都将被忽略。 我们不能介绍我们自己的领域。

添加event的示例:

 response.write('id: UniqueID\n'); response.write('event: add\n'); response.write('retry: 10000\n'); response.write("data: " + data + '\n\n');

然后在客户端,这是用addEventListener处理的,如下所示:

 source.addEventListener("add", function(event) { // do stuff with data event.data; });

只要您提供不同的 ID,您就可以发送多条以新行分隔的消息。

 ... id: 54 event: add data: "[{SOME JSON DATA}]" id: 55 event: remove data: JSON.stringify(some_data) id: 56 event: remove data: { data: "msg" : "JSON data"\n data: "field": "value"\n data: "field2": "value2"\n data: }\n\n ...

这极大地简化了我们可以对数据进行的操作。

特定服务器要求

在我们的后端 POC 期间,我们发现它有一些需要解决的细节才能有效地实施 SSE。 最好的情况是您将使用基于事件循环的服务器,例如 NodeJS、Kestrel 或 Twisted。 这个想法是,使用基于线程的解决方案,每个连接都有一个线程 → 1000 个连接 = 1000 个线程。 使用事件循环解决方案,您将拥有一个用于 1000 个连接的线程。

  1. 如果 HTTP 请求表明它可以接受事件流 MIME 类型,则您只能接受 EventSource 请求;
  2. 您需要维护所有已连接用户的列表才能发出新事件;
  3. 您应该侦听已断开的连接并将其从已连接用户列表中删除;
  4. 您应该选择维护消息历史记录,以便重新连接的客户端可以赶上错过的消息。

它按预期工作,一开始看起来很神奇。 我们得到了我们想要的一切,让我们的应用程序以一种有效的方式工作。 就像所有看起来好得令人难以置信的事情一样,我们有时会面临一些需要解决的问题。 但是,它们的实现或解决并不复杂:

  • 众所周知,旧代理服务器在某些情况下会在短暂超时后断开 HTTP 连接。 为了防止此类代理服务器,作者可以每 15 秒左右添加一个注释行(以 ':' 字符开头)。

  • 希望将事件源连接相互关联或与先前提供的特定文档相关联的作者可能会发现依赖 IP 地址不起作用,因为单个客户端可以有多个 IP 地址(由于有多个代理服务器),而单个 IP 地址可以有多个客户端(由于共享代理服务器)。 最好在提供文档时在文档中包含一个唯一标识符,然后在建立连接时将该标识符作为 URL 的一部分传递。

  • 作者还告诫说,HTTP 分块可能对该协议的可靠性产生意想不到的负面影响,特别是如果分块是由不知道时序要求的不同层完成的。 如果这是一个问题,可以禁用分块以提供事件流。

  • 如果每个页面都有指向同一域的 EventSource,则支持 HTTP 的每服务器连接限制的客户端在从站点打开多个页面时可能会遇到问题。 作者可以通过使用每个连接使用唯一域名的相对复杂的机制来避免这种情况,或者允许用户在每个页面的基础上启用或禁用 EventSource 功能,或者通过使用共享工作程序共享单个 EventSource 对象。

  • 浏览器支持和 Polyfills:Edge 落后于这个实现,但是有一个 polyfill 可以帮助你。 然而,SSE 最重要的案例是针对 IE/Edge 没有可行市场份额的移动设备。

一些可用的 polyfills:

  • 耶夫饼
  • amvtek
  • 雷米

无连接推送等功能

在受控环境中运行的用户代理,例如绑定到特定运营商的移动手持设备上的浏览器,可以将连接管理卸载到网络上的代理。 在这种情况下,出于一致性目的的用户代理被认为包括手机软件和网络代理。

例如,移动设备上的浏览器在建立连接后,可能会检测到它位于支持网络上,并请求网络上的代理服务器接管连接的管理。 这种情况的时间表可能如下:

  1. 浏览器连接到远程 HTTP 服务器并请求作者在 EventSource 构造函数中指定的资源。
  2. 服务器偶尔发送消息。
  3. 在两条消息之间,浏览器检测到除了保持 TCP 连接处于活动状态的网络活动之外它处于空闲状态,并决定切换到睡眠模式以节省电量。
  4. 浏览器与服务器断开连接。
  5. 浏览器联系网络上的服务并请求该服务(“推送代理”)来维持连接。
  6. “推送代理”服务联系远程 HTTP 服务器,并请求作者在 EventSource 构造函数中指定的资源(可能包括Last-Event-ID HTTP 标头等)。
  7. 浏览器允许移动设备进入睡眠状态。
  8. 服务器发送另一条消息。
  9. “推送代理”服务使用诸如 OMA 推送之类的技术将事件传送到移动设备,移动设备只唤醒足以处理事件,然后返回睡眠状态。

这可以减少总数据使用量,因此可以节省大量电力。

除了实现规范定义的现有 API 和文本/事件流连线格式以及以更分布式的方式(如上所述)之外,还可以支持其他适用规范定义的事件框架格式。

概括

经过漫长而详尽的 POC,包括服务器和客户端实现,看起来 SSE 是我们数据交付问题的答案。 它也有一些陷阱,但事实证明它们是微不足道的。

这就是我们的生产设置最终的样子:

架构概述
最终架构概述。 所有 API 端点都在 nginx 后面,因此客户端可以得到多路响应。

我们从 NGINX 得到以下信息:

  • 代理到不同地方的 API 端点;
  • HTTP/2 及其所有优点,例如连接多路复用;
  • 负载均衡;
  • SSL。

通过这种方式,我们在一个地方管理我们的数据交付和证书,而不是在每个端点上单独进行。

我们从这种方法中获得的主要好处是:

  • 数据高效;
  • 更简单的实现;
  • 它通过 HTTP/2 自动多路复用;
  • 将客户端上的数据连接数限制为一个;
  • 提供一种机制来通过将连接卸载到代理来节省电池。

SSE 不仅是提供快速更新的其他方法的可行替代方案; 在移动设备的优化方面,它看起来像是在自己的联盟​​中。 与替代方案相比,它的实现没有开销。 在服务器端实现方面,它与轮询没有太大区别。 在客户端,它比轮询简单得多,因为它需要初始订阅和分配事件处理程序——与管理 WebSocket 的方式非常相似。

如果您想获得一个简单的客户端-服务器实现,请查看代码演示。

资源

  • “在双向 HTTP 中使用长轮询和流式传输的已知问题和最佳实践”,IETF (PDF)
  • W3C 推荐,W3C
  • “WebSocket 会在 HTTP/2 中存活吗?”Allan Denis,InfoQ
  • “使用服务器发送的事件进行流式更新”,Eric Bidelman,HTML5 Rocks
  • “使用 HTML5 SSE 的数据推送应用程序”,O'Reilly Media 的 Darren Cook