实时通信的服务器推送机制 Server Send Event

404 阅读5分钟

1. 什么是 Server Send Event

EventSource 是 HTML5 中的一项 API,用于在客户端和服务器之间建立持久的、单向的通信连接。

客户端通过EventSource对服务端发出请求,然后服务端通过Server Send Event方式向客户端推送数据。

客户端通过EventSource的方法进行监听,比如 onOpen, onMessage, onError

当我们提到推送数据,可能会想到 WebSocket。

确实,WebSocket 能双向通信,自然也能做服务器到浏览器的消息推送。

但如果只是单向推送消息的话,HTTP 就有这种功能,它就是 Server Send Event。

2. WebSocket 与 Server Send Event通信过程 区别

  • WebSocket 的通信过程是这样的:

图片

首先通过 http 切换协议,发送Upgrade: WebSocket, 标识需切换到websocket协议。

服务端返回 101 的状态码后,就代表协议切换成功。

之后就是通过WebSocket协议的数据通信了,一方可以随时向另一方推送消息。

  • HTTP 的 Server Send Event 是这样的:

图片

客户端通过EventSource的方式请求配置了sse的服务端数据接口,

服务端返回的 Content-Type 是text/event-stream,这是一个流,可以多次返回内容。

整个通信过程都是通过http协议

2. Sever Send Event用处

可能你是第一次听说 SSE,但你肯定用过基于它的应用。

比如你用的 CICD 平台,它的日志是实时打印的。

那它是如何实时传输构建日志的呢?就是用 SSE 来推送数据。

再比如说 ChatGPT,它回答一个问题不是一次性给你全部的,而是一部分一部分的加载回答。

这也是基于 SSE。

图片

图片

3. 如何实现Sever Send Event

创建 nest 项目:

npx nest new sse-test

图片

把它跑起来:

npm run start:dev

图片

访问 http://localhost:3000 可以看到 hello world,代表服务器跑成功了:

图片

然后在 AppController 添加一个 stream 接口:

图片

这里不是通过  @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

图片

在 App.tsx 里写如下代码:

import { useEffect } from 'react';

function App() {

  useEffect(() => {
    const eventSource = new EventSource("http://localhost:3000/stream");
    eventSource.onopen = (e) => {
            console.log("e", e);
    };
    eventSource.onmessage = (target) => {
            console.log("target", target); // MessageEvent
            console.log("New message", JSON.parse(target.data));
    };
    eventSource.onerror = () => {
            // todo
    };
  }, []);

  return (
    <div>hello</div>
  );
}

export default App;

这个 EventSource 是浏览器原生 api,就是用来获取 Sever Send Event 接口的响应的,它会把每次消息传入 onmessage 的回调函数。

我们在 nest 服务开启跨域支持:

图片

然后把 react 项目 index.tsx 里这几行代码删掉,它会导致额外的渲染:

图片

执行 npm run start

因为 3000 端口被占用了,它会跑在 3001:

图片

浏览器访问下:

图片

看到一段段的响应了没?

这就是 Server Send Event。

在 devtools 里可以看到,响应的 Content-Type 是 text/event-stream:

图片

然后在 EventStream 里可以看到每一次收到的消息:

图片

这样,服务端就可以随时向网页推送消息了。

4. Sever Send Event 兼容性

可以在 MDN 看到:

图片

除了 ie、edge 外,其他浏览器都没任何兼容问题。

5. 用处详解

那用在哪呢?

一些只需要服务端推送的场景就特别适合 Server Send Event。

比如这个站内信:

图片

这种推送用 WebSocket 就没必要了,可以用 SSE 来做。

那连接断了怎么办呢?

不用担心,浏览器会自动重连。

这点和 WebSocket 不同,WebSocket 如果断开之后是需要手动重连的,而 SSE 不用。

再比如说日志的实时推送。

我们来测试下:

tail -f 命令可以实时看到文件的最新内容:

图片

我们通过 child_process 模块的 exec 来执行这个命令,然后监听它的 stdout 输出:

const { exec } = require("child_process");

const childProcess = exec('tail -f ./log');

childProcess.stdout.on('data'(msg) => {
    console.log(msg);
});

用 node 执行它:

图片

然后添加一个 Sever Send Event 的接口:

@Sse('stream2')
stream2() {
const childProcess = exec('tail -f ./log');

return new Observable((observer) => {
  childProcess.stdout.on('data'(msg) => {
    observer.next({ data: { msg: msg.toString() }});
  })
});

监听到新的数据之后,把它返回给浏览器。

浏览器连接这个新接口:

图片

测试下:

图片

可以看到,浏览器收到了实时的日志。

很多构建日志都是通过 SSE 的方式实时推送的。

日志之类的只是文本,那如果是二进制数据呢?

二进制数据在 node 里是通过 Buffer 存储的。

const { readFileSync } = require("fs");

const buffer = readFileSync('./package.json');

console.log(buffer);

图片

而 Buffer 有个 toJSON 方法:

图片

这样不就可以通过 sse 的接口返回了么?

试一下:

@Sse('stream3')
stream3() {
    return new Observable((observer) => {
        const json = readFileSync('./package.json').toJSON();
        observer.next({ data: { msg: json }});
    });
}

图片

图片

确实可以。

也就是说,基于 sse,除了可以推送文本外,还可以推送任意二进制数据。

6. 总结

服务端实时推送数据,除了用 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 更合适呢?

7. 说下缺点哈:

默认浏览器 EventSource API对允许发出的请求类型施加了一些限制:允许传入的唯一参数url是和withCredentials,因此:

  • 您无法传入请求正文:您必须对 URL 中执行请求所需的所有信息进行编码,在大多数浏览器中,URL的长度限制为 2000 个字符
  • 您无法传入自定义请求标头
  • 您只能发出 GET 请求 - 无法指定其他方法。
  • 如果连接被切断,您无法控制重试策略:浏览器会默默地为您重试几次,然后停止,这对于任何类型的健壮应用程序来说都不够好。

查看微软的 fetch-event-source

8.说下特点哈

  • 每当连接关闭时,浏览器都会在约 3 秒后自动重新连接到源.

    可通过包含以 开头的行来更改该超时retry:,后跟尝试重新连接之前要等待的毫秒数。

    以下示例尝试在 10 秒后重新连接:

    retry: 10000\n
    data: hello world\n\n
    
  • 采取长链接

    res.writeHead(200, {  
        'Content-Type': 'text/event-stream',  
        'Cache-Control': 'no-cache',  
        'Connection': 'keep-alive'  
    });
    
  • 指定事件名称

    单个事件源可以通过包含事件名称来生成不同类型的事件。如果存在以 开头的行event:,后跟事件的唯一名称,则该事件与该名称关联。在客户端上,可以设置事件侦听器来侦听该特定事件。

    例如,以下服务器输出发送三种类型的事件:通用“message”事件、“userlogon”和“update”事件:

    data: {"msg": "First message"}\n\n
    event: userlogon\n
    data: {"username": "John123"}\n\n
    event: update\n
    data: {"username": "John123", "emotion": "happy"}\n\n
    

    在客户端设置事件监听器:

    source.addEventListener('message', function(e) {
    var data = JSON.parse(e.data);
    console.log(data.msg);
    }, false);
    
    source.addEventListener('userlogon', function(e) {
    var data = JSON.parse(e.data);
    console.log('User login:' + data.username);
    }, false);
    
    source.addEventListener('update', function(e) {
    var data = JSON.parse(e.data);
    console.log(data.username + ' is now ' + data.emotion);
    }, false);
    
  • 取消事件流

    通常,当连接关闭时,浏览器会自动重新连接到事件源,但可以从客户端或服务器取消该行为。

    要从客户端取消流,只需调用:

    source.close();
    

    要从服务器取消流,请以“非”响应或返回除(例如)text/event-stream Content-Type之外的 HTTP 状态。200 OK``404 Not Found

    这两种方法都会阻止浏览器重新建立连接。

9. 客户端与服务端推数据交流的方式?

  • 轮询是绝大多数 AJAX 应用程序使用的传统技术。基本思想是应用程序重复轮询服务器以获取数据。客户端发出请求并等待服务器响应数据。如果没有可用的,则返回空响应。额外的轮询会产生更大的 HTTP 开销。

  • 长轮询(Hanging GET / COMET) 与轮询略有不同。在长轮询中,如果服务器没有可用数据,服务器将保持请求打开,直到有新数据可用为止。因此,这种技术通常被称为“悬挂 GET”。当信息可用时,服务器做出响应,关闭连接,然后重复该过程。其效果是,服务器不断地响应可用的新数据。缺点是这种过程的实现通常涉及黑客攻击,例如将脚本标签附加到“无限”iframe。

    if (!!window.EventSource) {
        var source = new EventSource('stream.php');
    } else {
        // Result to xhr polling :(
    }
    
  • Websocket

参考文章:

  1. rxjs: cn.rx.js.org/about.html
  2. web.dev/eventsource…
  3. Nest 通关秘
  4. zhuanlan.zhihu.com/p/518969389…