探索 SSE:服务器推送技术的魅力与应用

SSE早在2004年就开始制定 HTML5 规范草案,08年被各大浏览器实现和支持,并在14年正式被W3C 标准化,但是其一直处于比较边缘化,很少被讨论的状态;本人也仅是只闻其名,直到最近做AI Copilot ,前后端的交互方式由原定的websocket改为了业内常用也比较符合使用场景的sse的交互方式;同时跟同事交流时发现和多人对sse并不是太了解,所以将近期做的调研进行整理输出

sse技术介绍

SSE(Server-Sent Events)是一种基于 HTTP 协议的实时通信技术。其为服务器主动向客户端单向推送实时数据的机制。

SSE 的工作原理是基于 HTTP协议的长链接技术。每当客户端向服务器发起请求后,服务器保持连接的打开状态。一旦有新的数据产生,服务器就能及时向客户端以文本流的形式进行推送。客户端通过监听特定事件来接收和处理这些数据。

SSE 的出现为那些只需要服务器向客户端单向传递实时数据的场景提供了高效、便捷的解决方案。

  • 相比传统的轮询方式,SSE 大大减少了不必要的请求和响应,降低了网络开销和服务器负载。
  • 与其他实时通信技术相比,SSE 凭借其基于 HTTP 协议的特性,具有实现简单、浏览器兼容性好、可靠且高效等优势。它适用于多种应用场景,如新闻实时推送、股票行情更新、实时监控数据展示等。在这些场景中,客户端创建好连接后只需被动接收服务器推送的数据,无需向服务器发送请求,SSE 能够很好地满足需求。

SSE 与其他通信技术的比较

#ssewebsocket长轮询(Long Polling)XMPP
协议基于HTTP 协议独立的基于 TCP 的全双工通信协议,需要握手升级HTTP协议专门用于即时通讯和在线状态管理的应用层协议
通信方式单向通信,服务器向客户端推送数据双向通信,客户端和服务器都能主动发送消息。收到请求后hold住连接,等有内容后再返回,然后重复此动作双向通信
数据格式只能传输文本数据文本数据,也支持二进制数据。和HTTP协议一致基于 XML 格式进行数据传输
重连机制客户端在连接中断时会自动尝试重连,重连间隔可以通过 retry 字段设置一般需要有心跳机制,在链接断开时手动处理重连逻辑定时请求主动监听并进行重连
实时性由于单向通信模型,实时性相对较弱,需要服务器定期发送数据有更低的延迟和更高的实时性,能立即将数据双向推送一般,每次成功获取数据后会再次发送请求
复杂性较低,易于实现和维护较高,需要处理连接的建立、维护和断开中等,需要重复创建请求
使用场景新闻推送、股票行情更新在线聊天、在线游戏消息很少的场景即时通讯、在线协作等

SSE 的编程实践

服务端实现

const express = require('express')
const app = express()

const arr = [
  `您好,感谢您的提问。针对您所描述的小米11 Ultra手机USB识别问题,可能是由于以下原因`,
  `造成的:\n1. USB端口松动:`,
  `长时间使用可能导致USB端口松动,影响接触不良。尝试重新插入USB线缆,`,
  `确保其牢固连接。\n2. 系统不稳定`,
  `:偶尔的系统不稳定可能会导致USB识别问题。您可以尝试重启手机,看看是否能够解决问题。\n3. 驱动程序问`,
  `题:手机的USB驱动程序可能出现故障,导致无法正确识别USB设备。您可以尝`,
  `试更新手机系统或联系小米官方客服寻求技术支持。\n如果以上方法都`,
  `无法解决问题,建议您携带手机前往小米授权服务中心进行检测和维修,以确定具体故障原因并获得专业的解决方案。希望能帮到您,祝您使用愉快!`
]
app.get("/stream", (req, res) => {
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();
  const id = +new Date();
  let currentIdx = -1
  const sendMessage = () => {
    currentIdx += 1
    setTimeout(() => {
      res.write(`data: ${JSON.stringify({
        finishReason: currentIdx === arr.length-1 ? 'stop' : "",
        messageId: id,
        content: arr[currentIdx]
      })}\n\n`);
      if (currentIdx < arr.length-1) {
        sendMessage()
      } else {
        res.end()
      }
    }, 800)
  }
  sendMessage()
  res.on("close", () => {
    res.end();
  });
});

app.listen(3100, () => {
  console.log(`Example app listening on port 3100`)
})
SSE 的 HTTP 响应头部通常包括以下内容:
  • Content-Type: 必须设置为 text/event-stream,标识这是个一个事件流。
  • Cache-Control: 通常设置为 no-storeno-cache 以防止缓存。
  • Connection: 可能设置为 keep-alive 表明服务器希望保持连接。
  • Retry-After: 可选,指示客户端在断开连接后重新连接前等待的时间(以毫秒为单位)。但是,这个头不是 SSE 规范的一部分,而是某些服务器使用的非标准方法。

SSE 使用特定的格式来组织数据,以便客户端能够解析这些事件:

  1. 数据行 (data:):
    • 每条数据以 data: 开头。
    • 数据行可以包含任意数量的文本,只要它们不包含 \n(换行符)。
    • 如果需要发送多行数据,可以在多个 data: 行中实现。
    • 每条数据行以 \n 结束。
  2. 事件名 (event:):
    • 用于定义事件的名称。
    • 如果没有指定 event,默认事件名称是 message
    • 事件名以 \n 结束。
  3. 事件 ID (id:):
    • 为每个事件提供一个唯一的 ID。
    • 这有助于客户端在重新连接时恢复事件流。
    • 事件 ID 以 \n 结束。
  4. 重试间隔 (retry:):
    • 建议客户端在重连失败后等待的时间(以毫秒为单位)。
    • 这个字段是非标准的,但在实践中被广泛使用。
    • 重试间隔以 \n 结束。
  5. 空行 (\n\n):
    • 两个连续的换行符 \n\n 表示一个事件的结束,并开始一个新的事件。

客户端实现(EventSource)

<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body>
  <button id="button">发送数据</button>
</body>

<script>
  document.querySelector('#button').addEventListener('click', () => {
    if (!!window.EventSource) {
      const evtSource = new EventSource('http://localhost:3200/stream');

      evtSource.addEventListener('message', function (event) {
        const message = JSON.parse(event.data)
        if (message.finishReason === 'stop') { // [done]
          console.log('传输结束');
          evtSource.close()
        }
      });

      evtSource.addEventListener('open', function (event) {
        console.log('连接已建立');
      });

      evtSource.addEventListener('error', function (event) {
        if (event.readyState === EventSource.CLOSED) {
          console.log('连接已关闭');
        } else {
          console.log(event);
        }
      });
      
      evtSource.addEventListener('close', function (event) {
        console.log('触发close');
      });
    } else {
      console.log('当前浏览器不支持 SSE');
    }
  })
</script>
</html>

EventSource是一个 Web API,允许网页通过 HTTP 长连接从 Web 服务器接收实时更新,而无需刷新页面或向服务器发送重复请求。

EventSource的类型定义:

[Exposed=(Window,Worker)]
interface EventSource : EventTarget {
  constructor(USVString url, optional EventSourceInit eventSourceInitDict = {});

  readonly attribute USVString url;
  readonly attribute boolean withCredentials;

  // ready state表明连接的当前状态
  const unsigned short CONNECTING = 0; // 未连接或正重连
  const unsigned short OPEN = 1;	// 连接已建立,可接受数据
  const unsigned short CLOSED = 2;  // 已断开,且不会重连
  readonly attribute unsigned short readyState;

  // networking
  attribute EventHandler onopen;
  attribute EventHandler onmessage;
  attribute EventHandler onerror;
  undefined close();
};

dictionary EventSourceInit {
  boolean withCredentials = false;
};

默认情况下,服务器发来的数据,总是触发EventSource实例的message事件。开发者还可以自定义 SSE 事件,这种情况下,发送回来的数据不会触发onmessage事件,而是进入对应自定义事件监听内。

// 服务端发送自定义事件
res.write(`event: foo
data: 自定义事件foo11\n\n`)
// 客户端接受自定义事件
source.addEventListener('foo', function (event) {
  console.log(event.data) // 输出:自定义事件foo
}, false);

EventSource默认会在服务器断开时每隔retry(默认3秒)时间进行自动重连,但是实际业务中可能需要对重连进行重新处理来减少前后端处理的压力:比如需要规定重连次数或者规定重连的时间间隔每隔3s、6s、10s最后断开连接等,就需要自行对API进行拦截封装

var maxRetries = 5; // 最大重连次数
var retryCount = 0; // 当前重连计数

let source = new EventSource("http://localhost:3200/stream");
source.addEventListener('message', function (event) {
    console.log(event.data);
});
source.onerror = function (event) {
    console.error("An error occurred", event);
    // 检查重连次数
    if (retryCount < maxRetries) {
        retryCount++;
        // 关闭当前的 EventSource
        source.close();
        // 重置 EventSource 并尝试重新连接
        setTimeout(function () {
            source = new EventSource("/server-sent-events-endpoint");
        }, 5000); // 5 秒后尝试重新连接
    } else {
        console.log("Maximum retries reached. Stopping auto-reconnect.");
    }
};

EventSource虽然是浏览器内置的API,但是它依然有很多限制让我们用起来不是很方便,比如:

  • 请求方式只能是GET
  • 无法传递请求体(request body),也就导致参数只能放在url上
  • 无法自定义请求头

因此如果需要更灵活的控制请求,可以尝试使用fetch或XMLHttpRequest的方式自行处理返回数据;具体实现可以查看这个polyfillopen in new window

兼容性问题

因为EventSource并不是一个新的规范,因此在现代浏览器上都能得到很好地支持open in new window,但是对于RN这种非浏览器端运行的应用或者IE却是无法得到支持,那这种情况要如何解决呢?

幸运的是SSE因为是基于HTTP协议的,所以我们使用XMLHttpRequest进行封装,接收数据,转换成规范格式:

const _xhr = new XMLHttpRequest()
_xhr.open('GET', 'http://localhost:3200/stream', true)
_xhr.onreadystatechange = () => {
  console.log(_xhr.status, _xhr.readyState);
  if (_xhr.status >= 200 && _xhr.status < 400) {
    console.log(_xhr.responseText);
  }
}
_xhr.send()

当然,上面代码每次接收到的返回都会包含上一次的返回:

// 第一次返回
data: {"output":{"finishReason":"","messageId":1,"content":"您好,感谢您的提问,可能是由于以下原因"}}

// 第二次返回
data: {"output":{"finishReason":"","messageId":1,"content":"您好,感谢您的提问,可能是由于以下原因"}}

data: {"output":{"finishReason":"","messageId":1,"content":"造成的:\n1. USB端口松动:"}}

// 第三次返回
data: {"output":{"finishReason":"","messageId":1,"content":"您好,感谢您的提问,可能是由于以下原因"}}

data: {"output":{"finishReason":"","messageId":1,"content":"造成的:\n1. USB端口松动:"}}

data: {"output":{"finishReason":"","messageId":1,"content":"长时间使用可能导致USB端口松动,"}}

// 第四次返回
……
……

因此还需要我们依于SSE的规范,对返回信息进行分割处理。

更靠谱更简单的方式是使用一些现成的三方的polyfill,例如: eventsource-polyfillopen in new windowreact-native-sseopen in new window

SSE 的优势与局限性

优点

  1. 简单易实现:SSE 的实现相对较为简单,开发人员无需处理复杂的协议和握手流程,降低了开发的难度和时间成本。
  2. **基于 HTTP 协议,兼容性好:利用了广泛使用和成熟的 HTTP 协议,无需额外的基础设施和特殊配置,与现有网络架构兼容性好。
  3. 支持跨域:允许客户端通过普通的 AJAX 发起跨域请求,无需服务器进行特殊处理,方便了分布式系统的构建。
  4. 低服务器资源消耗:相比一些双向通信技术,SSE 在服务器端只需专注于数据的推送,因此资源消耗相对较低,能够处理更多的并发连接。

局限性

  1. 单向通信:SSE 只支持服务器向客户端推送数据,客户端无法主动向服务器发送信息,限制了交互的灵活性。

  2. 仅支持文本数据:无法传输二进制数据,对于一些需要传输多媒体等二进制内容的场景不太适用。

  3. 连接稳定性问题:可能受到网络环境、服务器性能等因素的影响,导致连接不稳定或中断,需要处理重连等情况。

  4. 部分浏览器兼容性:尽管大多数现代浏览器支持,但一些老旧的浏览器可能存在兼容性问题,需要额外的处理或提示。

综合来看,SSE 在适合的场景中能够发挥其优势,为单向数据推送提供高效的解决方案。但在面对复杂的交互需求和特定的数据类型要求时,可能需要考虑其他更全面的通信技术。

参考

小红包免费领
小礼物走一走
Last Updated:
Contributors: 邵礼豹, slbyml
部分内容来源于网络,如有侵权,请留言或联系994917123@qq.com;访问量:waiting;访客数:waiting