提到推送数据,大家可能会首先想到 WebSocket。
确实,WebSocket 能双向通信,自然也能做服务器到浏览器的消息推送。
但如果只是单向推送消息的话,HTTP 就有这种功能,它就是 Server Send Event。
WebSocket 的通信过程是这样的:
![88426801951f11a538f0aa0dc923e15a.png](https://img-blog.csdnimg.cn/img_convert/88426801951f11a538f0aa0dc923e15a.png)
首先通过 http 切换协议,服务端返回 101 的状态码后,就代表协议切换成功。
之后就是 WebSocket 格式数据的通信了,一方可以随时向另一方推送消息。
而 HTTP 的 Server Send Event 是这样的:
![8d1256651bd7a5ca1d44fb4c80c7ed03.png](https://img-blog.csdnimg.cn/img_convert/8d1256651bd7a5ca1d44fb4c80c7ed03.png)
服务端返回的 Content-Type 是 text/event-stream,这是一个流,可以多次返回内容。
Sever Send Event 就是通过这种消息来随时推送数据。
可能你是第一次听说 SSE,但你肯定用过基于它的应用。
比如你用的 CICD 平台,它的日志是实时打印的。
那它是如何实时传输构建日志的呢?
明显需要一段一段的传输,这种一般就是用 SSE 来推送数据。
再比如说 ChatGPT,它回答一个问题不是一次性给你全部的,而是一部分一部分的加载回答。
这也是基于 SSE。
![78d78e8fe9896d1ed9ceda3df6323b83.png](https://img-blog.csdnimg.cn/img_convert/78d78e8fe9896d1ed9ceda3df6323b83.png)
知道了什么是 SSE 以及它的应用,我们来自己实现一下吧:
创建 nest 项目:
npx nest new sse-test
![eebe92748b8fa0bc1c54cb1804a7330c.png](https://img-blog.csdnimg.cn/img_convert/eebe92748b8fa0bc1c54cb1804a7330c.png)
把它跑起来:
npm run start:dev
![3146552086657107b3707987aa7a1333.png](https://img-blog.csdnimg.cn/img_convert/3146552086657107b3707987aa7a1333.png)
访问 http://localhost:3000 可以看到 hello world,代表服务器跑成功了:
![c413ac6f8d6d9ebbce6e6e2dcfe02f38.png](https://img-blog.csdnimg.cn/img_convert/c413ac6f8d6d9ebbce6e6e2dcfe02f38.png)
然后在 AppController 添加一个 stream 接口:
![efdb8cf68c3389058f253badb4562585.png](https://img-blog.csdnimg.cn/img_convert/efdb8cf68c3389058f253badb4562585.png)
这里不是通过 @Get、@Post 等装饰器标识,而是通过 @Sse 标识这是一个 event stream 类型的接口。
@Sse('stream')
stream() {
return new Observable((observer) => {
observer.next({ data: { msg: 'aaa'} });
setTimeout(() => {
observer.next({ data: { msg: 'bbb'} });
}, 2000);
setTimeout(() => {
observer.next({ data: { msg: 'ccc'} });
}, 5000);
});
}
返回的是一个 Observable 对象,然后内部用 observer.next 返回消息。
可以返回任意的 json 数据。
我们先返回了一个 aaa、过了 2s 返回了 bbb,过了 5s 返回了 ccc。
然后写个前端页面:
创建一个 react 项目:
npx create-react-app --template=typescript sse-test-frontend
![d962249bb596bd6513ab48aa6a640cfa.png](https://img-blog.csdnimg.cn/img_convert/d962249bb596bd6513ab48aa6a640cfa.png)
在 App.tsx 里写如下代码:
import { useEffect } from 'react';
function App() {
useEffect(() => {
const eventSource = new EventSource('http://localhost:3000/stream');
eventSource.onmessage = ({ data }) => {
console.log('New message', JSON.parse(data));
};
}, []);
return (
<div>hello</div>
);
}
export default App;
这个 EventSource 是浏览器原生 api,就是用来获取 sse 接口的响应的,它会把每次消息传入 onmessage 的回调函数。
我们在 nest 服务开启跨域支持:
![23956eb07f30e3b182e723d19a7540fb.png](https://img-blog.csdnimg.cn/img_convert/23956eb07f30e3b182e723d19a7540fb.png)
然后把 react 项目 index.tsx 里这几行代码删掉,它会导致额外的渲染:
![498859485d0c7e38c35752618a1da503.png](https://img-blog.csdnimg.cn/img_convert/498859485d0c7e38c35752618a1da503.png)
执行 npm run start
因为 3000 端口被占用了,它会跑在 3001:
![9a99c0ce84d3ebc4dc2645d8461a082c.png](https://img-blog.csdnimg.cn/img_convert/9a99c0ce84d3ebc4dc2645d8461a082c.png)
浏览器访问下:
![9bc50ff6a6b675f7d449227a873f133a.gif](https://img-blog.csdnimg.cn/img_convert/9bc50ff6a6b675f7d449227a873f133a.gif)
看到一段段的响应了没?
这就是 Server Send Event。
在 devtools 里可以看到,响应的 Content-Type 是 text/event-stream:
![23cf7ba5ebdd3f1dca18a077fd4a3744.png](https://img-blog.csdnimg.cn/img_convert/23cf7ba5ebdd3f1dca18a077fd4a3744.png)
然后在 EventStream 里可以看到每一次收到的消息:
![6dc266a78523b6ae012ea31dce213165.png](https://img-blog.csdnimg.cn/img_convert/6dc266a78523b6ae012ea31dce213165.png)
这样,服务端就可以随时向网页推送消息了。
那它兼容性怎么样呢?
可以在 MDN 看到:
![7db5b61daf74a1a88ac65c95b4fc53a3.png](https://img-blog.csdnimg.cn/img_convert/7db5b61daf74a1a88ac65c95b4fc53a3.png)
除了 ie、edge 外,其他浏览器都没任何兼容问题。
基本是可以放心用的。
那用在哪呢?
一些只需要服务端推送的场景就特别适合 Server Send Event。
比如这个站内信:
![4efd4a0f3f0dea62ac9b6d9be62d98a7.png](https://img-blog.csdnimg.cn/img_convert/4efd4a0f3f0dea62ac9b6d9be62d98a7.png)
这种推送用 WebSocket 就没必要了,可以用 SSE 来做。
那连接断了怎么办呢?
不用担心,浏览器会自动重连。
这点和 WebSocket 不同,WebSocket 如果断开之后是需要手动重连的,而 SSE 不用。
再比如说日志的实时推送。
我们来测试下:
tail -f 命令可以实时看到文件的最新内容:
![6bd4e2ab74afdadf39b95d9b30754ebb.gif](https://img-blog.csdnimg.cn/img_convert/6bd4e2ab74afdadf39b95d9b30754ebb.gif)
我们通过 child_process 模块的 exec 来执行这个命令,然后监听它的 stdout 输出:
const { exec } = require("child_process");
const childProcess = exec('tail -f ./log');
childProcess.stdout.on('data', (msg) => {
console.log(msg);
});
用 node 执行它:
![ff42d3f3aad24e89e538703e4c796b82.gif](https://img-blog.csdnimg.cn/img_convert/ff42d3f3aad24e89e538703e4c796b82.gif)
然后添加一个 sse 的接口:
@Sse('stream2')
stream2() {
const childProcess = exec('tail -f ./log');
return new Observable((observer) => {
childProcess.stdout.on('data', (msg) => {
observer.next({ data: { msg: msg.toString() }});
})
});
监听到新的数据之后,把它返回给浏览器。
浏览器连接这个新接口:
![2421b02a53129a40ebf4a994ac0fc32e.png](https://img-blog.csdnimg.cn/img_convert/2421b02a53129a40ebf4a994ac0fc32e.png)
测试下:
可以看到,浏览器收到了实时的日志。
很多构建日志都是通过 SSE 的方式实时推送的。
日志之类的只是文本,那如果是二进制数据呢?
二进制数据在 node 里是通过 Buffer 存储的。
const { readFileSync } = require("fs");
const buffer = readFileSync('./package.json');
console.log(buffer);
![4031e1952394a82368e797a60d53ca34.png](https://img-blog.csdnimg.cn/img_convert/4031e1952394a82368e797a60d53ca34.png)
而 Buffer 有个 toJSON 方法:
![eea1dbe9096c08bd979a9981d6320b0b.png](https://img-blog.csdnimg.cn/img_convert/eea1dbe9096c08bd979a9981d6320b0b.png)
这样不就可以通过 sse 的接口返回了么?
试一下:
@Sse('stream3')
stream3() {
return new Observable((observer) => {
const json = readFileSync('./package.json').toJSON();
observer.next({ data: { msg: json }});
});
}
![e76d28b1d5c8cf48be2239ea75a878e3.png](https://img-blog.csdnimg.cn/img_convert/e76d28b1d5c8cf48be2239ea75a878e3.png)
![713597a7226290995368129179eccff1.png](https://img-blog.csdnimg.cn/img_convert/713597a7226290995368129179eccff1.png)
确实可以。
也就是说,基于 sse,除了可以推送文本外,还可以推送任意二进制数据。
总结
服务端实时推送数据,除了用 WebSocket 外,还可以用 HTTP 的 Server Send Event。
只要 http 返回 Content-Type 为 text/event-stream 的 header,就可以通过 stream 的方式多次返回消息了。
它传输的是 json 格式的内容,可以用来传输文本或者二进制内容。
我们通过 Nest 实现了 sse 的接口,用 @Sse 装饰器标识方法,然后返回 Observe 对象就可以了。内部可以通过 observer.next 随时返回数据。
前端使用 EventSource 的 onmessage 来接收消息。
这个 api 的兼容性很好,除了 ie 外可以放心的用。
它的应用场景有很多,比如站内信、构建日志实时展示、chatgpt 的消息返回等。
再遇到需要消息推送的场景,不要直接 WebSocket 了,也许 Server Send Event 更合适呢?