SSEGWSW:服务工作者发送的服务器发送的事件网关

你好

我叫Sasha,我是Tinkoff Business的架构师。

在本文中,我想谈一谈如何通过使用服务工作者,克服浏览器对同一域内打开的长期HTTP连接数量的限制。

如果需要,可以随时跳过背景,问题描述,寻找解决方案并立即进行处理。

上证所

背景知识


很久以前,在Tinkoff Business上进行过Websocket上的聊天。

一段时间后,他不再适合自己的个人帐户设计,总的来说,他长期以来一直希望将角度1.6重写为角度2+。 我认为是时候开始对其进行更新了。 同事支持者发现聊天前端将改变,并建议同时重做API,特别是-将传输从websocket更改为SSE(服务器发送的事件) 。 他建议这样做,因为在更新NGINX配置时,所有连接都断开了,因此恢复起来很痛苦。

我们讨论了新解决方案的体系结构,得出的结论是,我们将使用常规HTTP请求接收和发送数据。 例如,发送POST:/ api / send-message消息 ,获取GET对话框的列表:/ api / conversations-list,依此类推。 异步事件(例如“来自对话者的新消息”)将通过SSE发送。 因此,我们将提高应用程序的容错能力:如果SSE连接断开,则聊天仍将有效,只有聊天不会收到实时通知。

除了在websocket中进行聊天之外,我们还在跟踪“瘦通知”组件的事件。 该组件使您可以向用户的个人帐户发送各种通知,例如,成功完成可能需要几分钟的帐户导入。 为了完全放弃websocket,我们将此组件移至了单独的SSE连接。

问题


当您打开一个浏览器选项卡时,将创建两个SSE连接:一个用于聊天,一个用于微妙的通知。 好吧,让他们被创造出来。 对不起还是什么? 我们不感到抱歉,但浏览器感到抱歉! 它们对域的并发持久连接数限制 。 猜猜Chrome中有多少? 对,六个! 我打开了三个标签-我对整个连接池进行了评分,您再也无法发出HTTP请求。 对于HTTP / 1.x协议,这是正确的。 在HTTP / 2中,由于多路复用而没有这种问题。

有两种方法可以在基础结构级别解决此问题:

  1. 域分片。
  2. HTTP / 2。

这两种方法似乎都很昂贵,因为许多基础架构都必须受到影响。

因此,对于初学者来说,我们尝试在浏览器端解决问题。 第一个想法是在选项卡之间进行某种形式的传输,例如,通过LocalStorageBroadcast Channel API进行传输。

含义是这样的:我们仅在一个选项卡中打开SSE连接,然后将数据发送到其余选项卡。 该解决方案似乎也不是最佳选择,因为它将需要释放构成Tinkoff Business个人帐户的所有50个SPA。 发布50个应用程序也很昂贵,因此我继续寻找其他方法。

解决方案


我最近与服务人员一起工作,并认为:是否可以在这种情况下应用它们?

要回答这个问题,您首先需要了解服务人员通常会做什么? 他们可以代理请求,看起来像这样:

self.addEventListener('fetch', event => { const response = self.caches.open('example') .then(caches => caches.match(event.request)) .then(response => response || fetch(event.request)); event.respondWith(response); }); 

我们监听HTTP请求的事件,并根据需要进行响应。 在这种情况下,我们尝试从缓存中进行响应,如果无法解决问题,则向服务器发出请求。

好的,让我们尝试拦截SSE连接并回答它:

 self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } event.respondWith(new Response('Hello!')); }); 

在网络请求中,我们看到下图:

图片

在控制台中,这是:

图片

已经不错。 该请求已被拦截,但是SSE不想以text / plain的形式进行响应,而是想要text / event-stream 。 现在如何创建流? 但是我什至可以通过服务人员的信息流做出回应吗? 好吧,让我们看看:

图片

太好了! Response类将ReadableStream作为主体。 阅读文档之后 ,您会发现ReadableStream具有一个带有enqueue()方法的控制器-借助它的帮助您可以流式传输数据。 适合,来吧!

 self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const responseText = 'Hello!'; const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream); event.respondWith(response); }); 

图片

没有错误,连接挂起处于挂起状态,并且没有数据到达客户端。 将我的请求与真实的服务器请求进行比较,我意识到答案在标题中。 对于SSE请求,必须指定以下标头:

 const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; 

添加这些标头时,连接将成功打开,但是客户端将不会接收到数据。 这很明显,因为您不能只发送随机文本-必须有某种格式。

在javascript.info中, 很好地描述了您要从服务器发送数据的数据格式 。 它可以用一个函数轻松地描述:

 const sseChunkData = (data: string, event?: string, retry?: number, id?: number): string => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; 

为了符合SSE格式,服务器必须发送以双换行符\ n \ n分隔的消息。

该消息包含以下字段:

  • 数据 -消息正文,一行中的多个数据被解释为一条消息,由换行符分隔\ n;
  • id-重新连接时更新在Last-Event-ID标头中发送的lastEventId属性;
  • 重试 -重新连接之前的建议延迟(以毫秒为单位),无法使用JavaScript设置;
  • event-用户事件的名称,在数据之前指示。

添加必要的标题,将答案更改为所需的格式,然后查看会发生什么情况:

 self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const sseChunkData = (data, event, retry, id) => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; const responseText = sseChunkData('Hello!'); const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream, {headers: sseHeaders}); event.respondWith(response); }); 

图片

哦,我的世界! 是的,我没有服务器就建立了SSE连接!

结果


现在,我们可以成功拦截SSE请求并对其做出响应,而无需超出浏览器范围。

最初的想法是与服务器建立连接,但只有一件事-从服务器将数据发送到选项卡。 开始吧!

 self.addEventListener('fetch', event => { const {headers, url} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; //   SSE- if (!isSSERequest) { return; } //    SSE const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; // ,    SSE const sseChunkData = (data, event, retry, id) => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; //    ,   — url,  — EventSource const serverConnections = {}; //   url             const getServerConnection = url => { if (!serverConnections[url]) serverConnections[url] = new EventSource(url); return serverConnections[url]; }; //          const onServerMessage = (controller, {data, type, retry, lastEventId}) => { const responseText = sseChunkData(data, type, retry, lastEventId); const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); controller.enqueue(responseData); }; const stream = new ReadableStream({ start: controller => getServerConnection(url).onmessage = onServerMessage.bind(null, controller) }); const response = new Response(stream, {headers: sseHeaders}); event.respondWith(response); }); 

github上的相同代码。
对于一个不那么琐碎的任务,我有一个非常简单的解决方案。 但是,当然,仍有许多细微差别。 例如,关闭所有选项卡,完全支持SSE协议等时,您需要关闭与服务器的连接。

我们已经成功决定了所有这一切-我相信这对您来说不会困难!

Source: https://habr.com/ru/post/zh-CN471718/


All Articles