服务器发送事件:WebSocket的替代方案

2022-02-15 02:05:46

在开发实时web应用程序时,WebSocket可能是第一个想到的东西。然而,服务器发送事件(SSE)是一种简单的替代方案,通常具有优越性。

最近,我一直对实现实时web应用程序的最佳方式感到好奇。也就是说,一个应用程序包含一个或多个组件,这些组件会对某些外部事件做出实时自动更新。这种应用程序最常见的例子是消息服务,我们希望每一条消息都能立即广播给所有连接的人,而不需要任何用户交互。

经过一些研究,我偶然发现了Martin Chaov的一篇精彩演讲,其中比较了服务器事件、WebSocket和长轮询。这篇演讲很有趣,内容丰富,也可以在博客上看到。我真的很推荐。然而,这是从2018年开始的,一些小事情发生了变化,所以我决定写这篇文章。

这使得它们在某些情况下非常理想,比如多人游戏,在这种情况下,通信是双向的,因为浏览器和服务器都会一直在频道上发送消息,并且要求这些消息以低延迟发送。

在第一人称射击游戏中,浏览器可以不断优化玩家的位置,同时同时从服务器接收所有其他玩家位置的更新。此外,我们绝对希望这些信息以尽可能少的开销传递,以避免游戏感觉迟钝。

这与HTTP的传统请求-响应模型相反,传统的请求-响应模型总是由浏览器发起通信,由于建立TCP连接和HTTP头,每条消息都有很大的开销。

然而,许多应用程序没有如此严格的要求。即使在实时应用程序中,数据流也通常是不对称的:服务器发送大部分消息,而客户机主要只是监听,偶尔只发送一次更新。例如,在聊天应用程序中,一个用户可能会连接到许多房间,每个房间都有数十或数百名参与者。因此,收到的信息量远远超过发送的信息量。

WebSocket有一个主要缺点:它们不能在HTTP之上工作,至少不能完全工作。它们需要自己的TCP连接。他们只使用HTTP建立连接,然后将其升级为独立的TCP连接,在此基础上可以使用WebSocket协议。

这似乎没什么大不了的,但这意味着WebSocket无法从任何HTTP功能中受益。也就是说:

至少,WebSocket协议首次发布时就是这样。如今,有一些补充标准试图改善这种情况。让我们仔细看看目前的情况。

注意:如果您不关心细节,可以跳过本节的其余部分,直接跳到服务器发送的事件或演示。

在标准连接上,每个浏览器都支持HTTP压缩,并且非常容易在服务器端启用。只需在选择的反向代理中翻转一个开关。对于WebSocket,问题更复杂,因为没有请求和响应,但需要压缩单个WebSocket框架。

2015年12月发布的RFC 7692试图通过定义“WebSocket的压缩扩展”来改善这种情况。然而,据我所知,没有流行的反向代理(如nginx、caddy)实现这一点,因此不可能透明地启用压缩。

这意味着,如果你想要压缩,它必须直接在你的后端实现。幸运的是,我找到了一些支持RFC 7692的库。例如,WebSocket和wsproto Python库,以及NodeJ的ws库。

在服务器上默认禁用扩展,在客户端上默认启用扩展。它在性能和内存消耗方面增加了大量开销,因此我们建议仅在确实需要时启用它。

注意这个节点。js在高性能压缩方面有各种各样的问题,其中并发性的增加,尤其是在Linux上,可能会导致灾难性的内存碎片和性能降低。

在浏览器方面,Firefox从版本37开始就支持WebSocket压缩。Chrome也支持它。然而,显然,狩猎和边缘并没有。

我没有花时间去核实移动领域的情况。

HTTP/2引入了对多路复用的支持,这意味着到同一主机的多个请求/响应不再需要单独的TCP连接。相反,它们都共享相同的TCP连接,每个都在自己独立的HTTP/2流上运行。

同样,每个浏览器都支持这一点,并且在大多数反向代理上很容易透明地启用。

相反,默认情况下,WebSocket协议不支持多路复用。同一主机的多个WebSocket将各自打开各自的TCP连接。如果想让两个独立的WebSocket端点共享远程连接,则必须在应用程序的代码中添加多路复用。

2018年9月发布的RFC 8441试图通过添加对“使用HTTP/2引导WebSocket”的支持来修复这一限制。它已经在Firefox和Chrome中实现。然而,据我所知,没有majorreverse代理实现它。不幸的是,我也找不到inPython或Javascript的任何实现。

没有明确支持WebSocket的HTTP代理可以阻止未加密的WebSocket连接工作。这是因为代理将无法插入WebSocket框架并关闭连接。

然而,通过HTTPS进行的WebSocket连接不应该受到这个问题的影响,因为帧将被加密,代理应该只转发所有内容而不关闭连接。

要了解更多信息,请参阅Peter Lubbers的“HTML5 Web套接字如何与代理服务器交互”。

WebSocket连接不受同源策略的保护。这使得它们容易受到跨站点WebSocket劫持的攻击。

因此,如果WebSocket后端使用任何类型的客户端缓存身份验证,例如cookie或HTTP身份验证,则必须检查源标头的正确性。

我不会在这里详细介绍,但请考虑这个简短的例子。假设比特币交易所使用WebSockets提供交易服务。当您登录时,Exchange可能会设置一个cookie,使您的会话在给定的时间段内保持活动状态。现在,攻击者要偷走你珍贵的比特币,只需让你访问她控制的网站,然后打开与交易所的WebSocketconnection即可。恶意连接将被自动验证。也就是说,除非Exchange检查源标头并阻止来自未经授权域的连接。

我鼓励您查看Christian Schneider关于跨站点WebSocket劫持的精彩文章,以了解更多信息。

现在我们对WebSocket有了更多的了解,包括它们的优点和缺点,让我们了解一下服务器发送的事件,看看它们是否是有效的替代方案。

服务器发送的事件使服务器能够随时向客户端发送低延迟事件。它们使用一个非常简单的协议,该协议是HTML标准的一部分,由everybrowser支持。

与WebSocket不同,服务器发送的事件只以一种方式流动:从服务器到客户端。这使得它们不适合于一组非常特定的应用程序,也就是说,那些需要双向和低延迟的通信通道的应用程序,比如实时游戏。然而,这种折衷也是WebSockets的主要优势,因为服务器发送的事件是单向的,可以在HTTP之上无缝工作,无需定制协议。这使它们能够自动访问HTTP的所有功能,例如压缩或HTTP/2多路复用,这使它们成为大多数实时应用程序的一个非常方便的选择,在这些应用程序中,大部分数据都是从服务器发送的,由于HTTP头,请求中的一点开销是可以接受的。

数据:第一条消息事件:joindata:第二条消息。它有两个数据行、一个自定义事件类型和一个id.id:5:comment。可以用作keep alivedata:第三条消息。我没有更多的数据。数据:请稍后重试。重试:10次

数据字段可以重复表示消息中的多行,因此用于事件内容也就不足为奇了。

事件字段允许指定自定义事件类型,我们将在下一节中介绍,这些类型可用于在客户机上启动不同的事件处理程序。

另外两个字段id和retry用于配置automaticreconnection机制的行为。这是Server SentEvents最有趣的功能之一。它确保当服务器断开或关闭连接时,客户端将自动尝试重新连接,而无需任何用户干预。

重试字段用于指定尝试重新连接前等待的最短时间(以秒为单位)。它也可以在关闭客户端连接之前由服务器发送,以在连接太多客户端时减少负载。

id字段将标识符与当前事件关联。重新连接时,客户端将使用最后一个事件id HTTP头向服务器传输最后一个看到的id。这允许从正确的点恢复流。

最后,服务器可以通过返回HTTP 204无内容响应来停止自动重新连接机制。

现在让我们将所学付诸实践。在本节中,我们将使用服务器发送的事件和WebSocket实现一个简单的服务。这将使我们能够比较这两种技术。我们将了解开始使用每一款产品有多容易,并手动验证前面章节中讨论的功能。

我们将在后端使用python,在前端使用Caddy作为反向代理,当然还有几行javascript。

为了使我们的示例尽可能简单,我们的后端将由两个端点组成,每个端点流传输一个唯一的随机数序列。对于服务器发送的事件,可以从/sse1和/sse2访问它们,对于WebSocket,可以从/ws1和/ws2访问它们。而我们的前端将由一个索引组成。htmlfile,带有一些JavaScript,可以让我们启动和停止WebSocket和服务器发送的事件连接。

使用反向代理(如Caddy或nginx)非常有用,即使是在这样的小示例中也是如此。它让我们可以很容易地访问我们的backendof choice可能缺少的许多功能。

更具体地说,它允许我们轻松地提供静态文件并自动压缩HTTP响应;提供对HTTP/2的支持,让我们受益于多路复用,即使我们的后端只支持HTTP/1;最后是进行负载平衡。

我选择Caddy是因为它可以自动为我们管理HTTPS证书,让我们跳过一项非常无聊的任务,尤其是快速实验。

基本配置位于项目根目录下的CADDY文件中,如下所示:

这将指示Caddy在端口80和443上侦听本地接口,从而支持HTTPS并生成自签名证书。它还可以从静态目录压缩和服务静态文件。

最后一步,我们需要让Caddy代理我们的后端服务。服务器SentEvents只是普通的HTTP,所以这里没有什么特别之处:

要代理WebSockets,我们的反向代理需要有明确的支持。幸运的是,Caddy可以毫无问题地处理这个问题,尽管配置更加详细:

@websockets{header Connection*Upgrade*header Upgrade websocket}handle/ws1{reverse_proxy@websockets 127.0.1.1:6001}handle/ws2{reverse_proxy@websockets 127.0.1.1:6002}

让我们从前端开始,通过比较WebSocket的JavaScript API和服务器发送的事件。

WebSocket JavaScript API使用非常简单。首先,我们需要创建一个传递服务器URL的新WebSocket对象。这里wss表示连接是通过HTTPS进行的。如上所述,建议使用HTTPS以避免代理问题。

然后,我们应该通过设置on$event属性或使用addEventListener()来监听一些可能的事件(即打开、消息、关闭、错误)。

服务器发送事件的JavaScript API非常相似。它要求我们通过服务器的URL创建一个新的EventSource对象,然后允许我们以与之前相同的方式订阅事件。

常数=新的(";https://localhost/sse" ) ;锿。onopen=e=>。日志(";EventSource open";);锿。addEventListener(";消息";,e=>;.log(e.data));//自定义事件的事件侦听器。addEventListener(";join";,e=>;.log(`e.data}join`)

我们现在可以使用所有这些新获得的关于JS API的知识来构建我们真正的前端。

为了让事情尽可能简单,它将只包含一个索引。htmlfile,带有一系列按钮,可以让我们启动和停止WebSocket和EventSources。像这样

我们需要不止一个WebSocket/EventSource,这样我们就可以测试HTTP/2 multiplexingworks是否有效,以及打开了多少连接。

常数wss=[];函数startWS(i){if(wss[i]!==未定义)返回;const ws=wss[i]=new(";wss://localhost/ws" +i);ws。onopen=e=>。日志(";WS open";);ws。onmessage=e=>。日志(如数据);ws。onclose=e=>;closeWS(一);}函数closeWS(i){if(wss[i]!==未定义){.log(";Closing websocket";);websockets[i]。关闭();删除WebSocket[i];}

服务器发送事件的前端代码几乎相同。唯一的区别是OneError事件处理程序,这是因为在出现错误时会记录一条消息,浏览器会尝试重新连接。

常数=[];函数开始(i){if(ess[i]!==undefined)返回;const es=ess[i]=new(";https://localhost/sse" +i);锿。onopen=e=>。日志(";ES open";);锿。onerror=e=>。日志(";ES错误";e);锿。onmessage=e=>。日志(e.数据);}函数关闭(i){if(ess[i]!==未定义){.log(";关闭EventSource";);ess[i]。close()删除ess[i]}

为了编写后端,我们将使用Starlette(一个简单的Python异步web框架)和Uvicorn作为服务器。此外,为了使事情模块化,我们将把数据生成过程与端点的实现分开。

我们希望两个端点中的每一个都能生成一个唯一的随机数字序列。为了实现这一点,我们将使用随机种子的流id(即1或2)aspat。

理想情况下,我们也希望我们的河流是可恢复的。也就是说,如果连接断开,客户端应该能够从它收到的最后一条消息恢复流,或者重新读取整个序列。为了实现这一点,我们将为每个消息/事件分配一个ID,并在生成每个消息之前使用它初始化随机种子和流ID。在我们的例子中,ID只是一个从0开始的计数器。

综上所述,我们已经准备好编写get_data函数,它负责生成我们的随机数:

导入随机def get_数据(流id:int,事件id:int)->;int:rnd=random。随机的。种子(流id*事件id)返回rnd。兰德兰奇(1000)

Starlette的入门非常简单。我们只需要初始化应用程序,然后注册一些路由:

要编写WebSocket服务,我们选择的web服务器和框架都必须有明确的支持。幸运的是,Uvicorn和Starlette能够完成这项任务,编写WebSocket端点和编写普通路由一样方便。

来自WebSocket。异常导入WebSocketException@app。websocket_路线(";/ws{id:int}";)异步def websocket_端点(ws):id=ws。路径参数[";id";]尝试:等待ws。在itertools中为i接受()。count():data={";id";:i,";msg";:get#u data(id,i)}等待ws。发送json(数据)等待异步IO。睡眠(1)WebSocketException除外:打印(";客户端已断开连接";)

上面的代码将确保每次浏览器请求以/ws开头、后跟数字(例如/ws1和/ws2)的路径时,都会调用我们的websocket_端点函数。

然后,对于每个匹配的请求,它将等待WebSocket连接建立,然后启动一个无限循环,每秒发送随机数,编码为JSON负载。

对于服务器发送的事件,代码非常相似,只是不需要特殊的框架支持。在本例中,我们注册了一个与URL匹配的路由,URL以/sse开头,以数字结尾(例如/sse1,/sse2)。然而,这一次我们的端点只是设置了适当的头并返回StreamingResponse:

来自starlette。响应导入StreamingResponse@app。路线(";/sse{id:int}";)异步定义sse#U端点(req):返回StreamingResponse(sse#U生成器(req),标头={";内容类型";:";文本/事件流";";缓存控制";:";无缓存";";连接";&&#

StreamingResponse是Starlette提供的一个实用程序类,它接受一个生成器并将输出流化到客户端,从而保持连接打开。

sse_generator的代码如下所示,与WebSocketendpoint几乎相同,只是消息是根据服务器发送的事件协议编码的:

异步def sse_生成器(req):id=req。路径参数[";id";]因为我在itertools。count():data=get_data(id,i)data=b";id:%d\n数据:%d\n\n";%(i,数据)产生等待异步输入的数据。睡眠(1)

最后,假设我们将所有代码放在一个名为server的文件中。py,我们可以使用Uvicorn启动startour后端端点,如下所示:

$uvicorn--主机127.0.1.1--端口6001服务器:app&;$uvicorn——主机127.0.1.1——端口6002服务器:app&;

好的,现在让我们通过展示实现我们之前吹嘘的所有这些优秀功能是多么容易来结束。

@@-32,10+33,12@@async def websocket_端点(ws):async def sse_生成器(req):id=req。路径参数[";id";]stream=zlib。itertools中i的compressobj()。count():data=get_data(id,i)data=b";id:%d\n数据:%d\n\n";%(i,数据)-产量数据+产量流。压缩(数据)+产生流。刷新(zlib.Z_SYNC_flush)等待asyncio。睡眠(1)@-47,5+50,6@异步定义sse#U端点(req):";内容类型";:"文本/事件流""缓存控制";:"没有缓存""连接";:"保持活力";,+"内容编码";:"放气(#34;,})

由于Caddy支持HTTP/2,因此默认情况下会启用多路复用。我们可以再次使用DevTools确认所有SSE请求都使用了相同的连接:

意外连接错误时的自动重新连接非常简单,只需读取后端代码中的最后一个事件ID头即可:

我们可以通过启动到其中一个端点的连接,然后杀死乌维康来测试它是否有效。连接将断开,但浏览器将自动尝试重新连接。因此,如果我们重新启动服务器,我们将看到流从它停止的地方恢复!

WebSockets是一种建立在HTTP和TCP之上的大型机器,它提供了一组非常特定的功能,即双向低延迟通信。

为了做到这一点,它们引入了许多复杂因素,最终使客户机和服务器实现都比solutionsba更复杂

......