简介
Server-sent events(SSE),也即服务器推送事件,是一种基于 HTTP 协议实现的单向实时数据推送技术。它允许服务器在不需要客户端轮询的情况下,主动向客户端发送新的数据。
特性
SSE 主要有以下几点特性:
- 文本格式的数据传输:只能发送通过 utf-8 编码的数据
- 自动重连:如果连接断开,浏览器会自动发起重连(默认延迟 3 秒)
- 事件 ID 支持:可用于浏览器断线重连后补发数据
- 轻量级:相比 WebSocket,SSE 基于 HTTP 协议,且浏览器原生支持断线重连,相对更加简单易用
到这里,我们很容易就会将 SSE 和 WebSocket 联系起来,接下来我们就来看看这两者之间的主要对比。
对比 WebSocket
特性 | SSE | WebSocket |
---|---|---|
通信模式 | 单向(服务端到客户端) | 双向(服务端和客户端) |
协议 | HTTP 协议 | WebSocket 协议 |
数据类型 | 纯文本 | 文本或二进制数据 |
连接保持 | 保持 HTTP 长连接,断线后自动重连 | 需要应用层处理断线重连逻辑 |
复杂度 | 简单易用(类似 HTTP 请求) | 较为复杂,需要更多的管理和维护 |
安全性 | 支持 HTTPS | 支持 WSS |
服务器支持 | 大多数 HTTP 服务器无需配置即可支持 | 需要服务器支持 WebSocket 协议 |
浏览器支持 | 除 IE 外,其它浏览器普遍支持 | 现代浏览器普遍支持 |
从以上主要对比来看,在服务端到客户端单向通信的场景下,SSE 看起来的确更加的简单易用点。但只要 SSE 能实现的场景,WebSocket 也都能实现。结合这些,我们再来看看 SSE 的一些常见使用场景。
常见使用场景
- 服务器日志推送
- 服务器状态监控
- 天气、股票行情等实时更新
- 新闻推送或社交媒体动态更新
- 大语言模型流式输出响应内容
当然 SSE 的使用场景绝不仅仅局限于以上几种,你可以根据业务场景灵活使用。了解完使用场景,下面我们就来看看如何在业务中使用它!
规范及应用
这一节我们将分别从服务端和客户端来了解 SSE 的一些基本规范及应用。
服务端应用
定义响应头
要使用 SSE,服务端首先需要将相应接口的响应内容类型设置为 "text/event-stream"
。类似这样:
// 响应内容类型必须是 text/event-stream
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Cache-Control', 'no-store')
为方便说明,本节所有服务端代码都将使用 node.js 来示例,其它语言自行调整即可。
定义响应内容
每一次连接的响应内容都由若干条消息组成,消息之间以一对换行符分隔。每条消息又由若干行组成,每一行都是这种格式:[field]: value\n
。其中 field
字段的取值只能是 data
、event
、id
和 retry
,其它任何字段都将被客户端视为无效字段而忽略,以下是具体说明:
data
用于定义每条响应消息的具体内容。内容格式只能是文本格式,类似这样:
res.write('data: first message.\n\n')
res.write('data: second message.\n\n')
这样客户端就会连续收到两条消息,分别是 first message.
和 second message.
。如图示:
而如果你这样定义:
res.write('data: first message.\n')
res.write('data: second message.\n\n')
客户端收到的将是一条消息,如图示:
可以看到,客户端会将一对换行符之前的多个 data
字段定义的消息内容合并起来作为一条消息的完整内容,直到遇见上一对换行符。每条消息都必须通过一个或多个 data
字段定义具体的消息内容,否则将被客户端视为无效消息而忽略。
event
用于定义每条消息的事件类型。默认是 message
,如上图示。你可以根据业务需要指定任何其它类型,类似这样:
res.write('data: running\n')
res.write('event: serverStatus\n\n')
这样客户端将会收到一条类型是 serverStatus
,内容是 running
的消息,如图示:
id
用于定义每条消息的事件 id。如下示例,当我们这样发送消息时:
let number = 0;
const intervalId = setInterval(() => {
number++;
res.write(`id: ${number}\n`);
res.write(`data: ${number}\n\n`);
}, 1000);
客户端收到的消息将会是这样的:
可以看到每条消息都有一个自己的 id 值。指定事件 id 后,当客户端断线重连时,http 请求头中的 Last-Event-Id
的值便是上次连接发送的最后一条消息的事件 id,服务端可以此来补发数据等:
retry
定义 SSE 连接断开后的重连时间。当 SSE 连接断开后,客户端默认 3 秒后重新连接。服务端可结合业务逻辑使用 retry
字段自定义重连时间,它的值必须是一个以毫秒为单位的整数值。
res.write('data: the server is still restarting.\n')
res.write('retry: 60000\n\n')
res.end()
一般来讲,当服务端明确知道客户端请求的数据还未准备好,且需等待一定时间时,便可定义好重连时间,并发消息告诉客户端,之后关闭连接即可。客户端在连接断掉后,等待时间达到重连时间时,便会再次发起连接。
以上便是如何定义响应内容的主要说明了。当然除此之外,服务端还可以定时发送以冒号开头的注释消息,以保持连接不断:
res.write(': this is a test stream \n\n')
客户端在收到这种非规范格式的消息后,会选择忽略它。
到这里 SSE 在服务端的规范及应用基本就介绍完了。需要额外再啰嗦一下的是,以上代码示例使用的都是 node.js 原生的写法,实际业务中用起来肯定是没有像 nestjs 已经集成好的 sse 装饰器或者是借助于诸如 better-sse 等封装好的 npm 包那样简洁方便。其它语言肯定也都有相应的框架或者工具库封装了 SSE 的常用能力,它们会让你的实现看起来更加优雅!
okkk.. 下一节我们就来看看服务端定义的 SSE 接口如何在客户端使用。
客户端应用
实例化 EventSource
在客户端使用 SSE 的话你需要先实例化 EventSource,实例化时 EventSource 会根据你传入的 url 发起一个到服务端的持久化连接:
const sse = new EventSource(url);
当这里传入的 url 是跨域 url 时,你可以通过传入第二个参数来决定是否带上当前域的 cookie 来发起请求。默认不带,需要带上的话可以这样传参:
const sse = new EventSource(url, { withCredentials: true });
默认事件监听
实例化 EventSource 后,便可以通过 EventSource 实例的 onmessage
方法来监听默认事件(message
)的消息,如下示例:
const sse = new EventSource("/api/v1/sse");
sse.onmessage = (e) => {
// 来自服务端的消息
console.log(e.data)
}
除了可以监听 onmessage
外,你还可以通过 onopen
和 onerror
这两个方法来分别监听连接的建立和异常:
sse.onopen = () => {
// sse 连接已建立
}
sse.onerror = (error) => {
// sse 连接异常
console.error(error)
}
自定义事件监听
除了默认的 message
事件,我们的业务中很可能还会用到其它的自定义事件,比如上面在介绍 event
字段时提到的 serverStatus
事件。通过 EventSource 实例的 addEventListener
方法即可监听这些自定义事件:
function onServerStatusChange(e) {
const serverStatus = e.data;
}
sse.addEventListener("serverStatus", onServerStatusChange);
通过 addEventListener
添加的事件监听当然就可以通过 removeEventListener
移除了:
sse.removeEventListener("serverStatus", onServerStatusChange);
如果你愿意,你当然也可以通过这种方式来监听前面提到的 message
、open
及 error
事件了:
// 同 sse.onmessage = onMessage
sse.addEventListener("message", onMessage);
sse.addEventListener("open", onOpen);
sse.addEventListener("error", onError);
查看连接状态
你可以方便的通过 EventSource 实例的 readyState
属性来获取当前连接的状态。连接的三种状态的值也可以通过 EventSource 实例的 CONNECTING
、OPEN
和 CLOSED
这三个属性分别获取。如下示例:
const sse = new EventSource("/api/v1/sse")
// 连接中
sse.readyState === sse.CONNECTING
// 连接开启
sse.readyState === sse.OPEN
// 连接关闭
sse.readyState === sse.CLOSED
中断连接
实例化后的 EventSource 实例,在 SSE 连接建立成功后,将一直保持连接开启。无论中途是因为网络原因还是服务端主动关闭而导致连接临时中断,之后 EventSource 实例都会主动发起重连。如果要永久中断连接,就需要调用 EventSource 实例的 close
方法来关闭连接。
const sse = new EventSource("/api/v1/sse")
// 中断连接
sse.close()
以上基本就是客户端建立 SSE 连接并接收服务端推送消息的所有内容了。相比 WebSocket 的话,少了心跳检测及断线重连相关的逻辑,使用起来确实简易一些。了解完基本规范及使用方法,下面我们再来看下使用 SSE 时需要注意的一些点。
注意事项
浏览器对于同源请求的并发数限制
如果你们的 Web 服务器配置使用的 http 协议仍是 http/1.1,由于浏览器对于同源请求的并发数有限制,大都为 6,而 SSE 的 http 连接又都是长连接,这就导致一旦一个浏览器下已经存在了 6 个同源的 SSE 连接,其它的同源请求将不能被正常受理。这是一个非常严重的问题,这个问题不解决,我觉得就不要使用 SSE,否则得不偿失!常见的解决方案如下:
- 将 http 协议从 http/1.1 切换到 http/2。有了多路复用的特性支持,http/2 的同源最大并发请求数将达到 100(默认值)。你可以根据业务需要继续调整这个值,但基本是不会再有上面的同源请求瓶颈限制了。
- 部署一个或多个非同源的 SSE 服务,客户端直接跨域请求相应服务。
- 当页面不可见时,及时中断该页面下的所有 SSE 连接。
- 使用其它方案比如 WebSocket 代替。
针对解决方案1,虽然现在主流浏览器都已支持 http/2,但仍有一些老版本浏览器并不完全支持。你如果要兼容这些老版本浏览器,在做好协议回退的同时,还得结合其它解决方案一起使用。
EventSource 仅支持 get 请求
原生的 EventSource 仅支持 get 请求,而实际业务场景中,我们很可能需要发起 post 请求。此时你可以使用已经封装好的类似 @microsoft/fetch-event-source 这样的 npm 包,或者你也可以参考这篇文章来原生实现。
Nginx 配置
使用 SSE 时,你可能需要根据情况做以下 nginx 配置:
- 禁用代理缓存:
proxy_cache off
。否则你将会在后续的连接中拿到之前缓存的数据。 - 禁用代理缓冲:
proxy_buffering off
。这样 nginx 将不再对 SSE 响应进行缓冲,直接透传给客户端。 - 调整连接超时:
proxy_read_timeout 120s
。这一步通常也不是必须的,nginx 默认会在两次读取超过 60s 时关闭连接,但 SSE 客户端往往也会在连接断开后 3s 发起重连, 所以影响倒也不大。何况服务端还可以通过定时发送注释消息以保持连接不断,所以这个配置完全可以视业务场景决定是否使用。
总结
抛开使用 http/1.1 时的同源请求并发数限制,SSE 还是挺适合这种从服务端到客户端的单向文本消息推送场景。尤其像新闻、社交媒体动态的实时更新以及监控系统的状态更新等。
在这些场景中,SSE 能通过简单的单向通信和自动重连机制提供稳定可靠的服务!
参考资料
How to Use Server-sent Events in Node.js
SSE (Server-Sent Events) Using A POST Request Without EventSource