替代轮询、ws的轻量级服务端推送:Server-Sent Events

1,794 阅读8分钟

但行好事 莫问前程

前言🎀

在某些场景前端需要 持续的接收后端的数据更新,这通常使用两种方案:

  1. 客户端拉取 —— 以一定间隔向服务器请求更新;代表:长轮询,短轮询
  2. 服务端推送 —— 服务端主动将更新推送到客户端;代表:WebSocket,SSE

其中 SSE 与 WebSocket 一样同为 HTML5 新特性,但它却不那么广为人知,但实际上它早已被成熟的运用在服务端单向通信的场景中。

本文,我们一起学习 便捷、高效的 服务端推送技术SSE(Server-Sent Events),了解它的相关知识与应用,并简单实现 GPT 的交互效果(已放至结语中)。

简介

SSE(Server-Sent Events),即服务器发送事件,是一种基于 HTTP 协议、用于服务端向客户端推送实时数据的技术

与 WebSocket 不同的是,SSE 是单向的,数据消息只能从服务端到发送到客户端(如用户浏览器),会占用 HTTP 连接数。

在不需要请求服务端的情况下,相对于繁重的 ws,SSE 无疑是一种简单、高效的轻量级代替方案。

例如 GPT 使用 SSE 一边计算一边将回答内容推送给前端,避免用户等待时间过长

规范&定义

image.png

SSE 的实现借助了 http协议支持分块传输 的特性,简单来说:

  1. 客户端与服务端之间建立一个keep-alive长连接
  2. 服务端会响应数据类型为 text/event-stream的事件流
  3. 响应内容会被分割为多个块(chunks)进行传输
Cache-Control: no-cache
Connection: keep-alive
Content-Type: text/event-stream
Transfer-Encoding: chunked

Transfer-Encoding: chunked 表示响应内容是分块传输的,前端使用EventSource发送的请求通过 devtools 能直观的查看数据的传输过程。

Content-Type: text/event-stream 表示响应内容的类型是 事件流,是实现 SSE 的核心,前端需要持续读取事件流数据进行解析

- EventStream

EventStream(事件流)是一种连续的数据流,由一系列事件组成。

每个事件由下列的一行或多行字段组成,每行字段以 \n 结尾:

  • event:事件名称,可选项。
  • data:事件数据,必选,可以是任意格式的文本或JSON数据。
  • id:事件ID,可选项,用于标识事件,断线重连后可以它为依据(Last-Event-ID)恢复传输。
  • retry:重新连接时间间隔,可选项,用于指定客户端重新连接的时间间隔。

不同事件的内容之间通过约定的边界,一般为空行(\n\n)来分隔。

举个例子:

event: time\n
data: 2023-11-28 10:00:00\n\n

event: message\n
data: Hello, world!\n\n

event: custom-event\n
id: 12345\n
data: Custom event\n
data: data1\n
data: data2\n\n

...

最终数据传输的格式为 UTF-8格式编码的文本 或 使用 Base64 编码 和 gzip 压缩的二进制消息。

实现

服务端

服务端实现 SSE,需要遵循以下规范:

  1. 首先,设置 SSE 相关的响应头(事件流、长连接、chunk传输-事件流自带、禁用缓存)
  2. 其次,将数据封装为事件(event)按照一定格式发送给客户端
  3. 然后,设置适当的延迟和缓冲 控制发送时机(适用于动态生成内容和大数据传输)
  4. 最后,在合适的时候断开客户端连接
const express = require("express");
const app = express();
const port = 3001;
//允许跨域
app.all("*", function (req, res, next) {
  res.setHeader("Access-Control-Allow-Origin", "*");
  next();
});

app.get("/sse", (req, res) => {
  const str =
    "Server-Sent Events是一种用于实现服务器向客户端实时推送数据的技术。它基于HTTP协议,使用长连接来保持服务器与客户端之间的通信。与传统的轮询或基于WebSocket的实时通信相比,SSE具有简单易用、轻量级的特点。";
  // 设置 SSE 相关的响应头
  res.setHeader("Content-Type", "text/event-stream;charset=utf-8");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  
  let index = 0;
  // 封装事件 & 控制发送频率
  const timer = setInterval(() => {
    if (index < str.length) {
      // 每次仅推送一个字符
      const data = `data: ${JSON.stringify(str[index])}\n`;
      // 此处省略了event id 等属性 . . .
      // const chunk = `${event}${id}${data}\n`;
      const chunk = `${data}\n`
      // 分割为块进行推送
      res.write(chunk);
      index++;
    } else {
      clearInterval(timer);
      // 断开连接
      res.end();
    }
  }, 30);
  
  // 监听客户端断开连接事件  
  req.on('close'() => {  
    console.log('Client closed connection.');  
    res.end();  
  });
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

注:尽可能的遵循事件的规范 chunk: { event, id, data, retry }data为必选项

遵守事件流规范的响应,可以通过 postman 很直观的看到它的响应内容:

客户端

通过浏览器内置的 API - EventSource ,我们可以便捷的进行SSE通信,但EventSource存在不少缺陷,我更推荐灵活性强、GPT同样在使用的方案:fetch

fetch 方案需要手动解析接收到的数据流:

  1. 获取可读流(ReadableStream)对象 res.body
  2. 获取一个读取器(reader)对象 res.body.getReader()
  3. 通过 reader.read() 方法,异步读取响应体的内容(ReadableStreamDefaultReader)
  4. 将数据流转换为UTF-8字符串
  5. 根据事件流的规范 以\n为根据 拆分字段数据
  6. 持续读取数据流,等待服务端断开或者手动关闭
fetch(url, { headers })
  .then((res) => {
    if (res.status === 200) {
      console.log("status 200");
      // 获取可读流(ReadableStream)对象
      return res.body;
    }
  })
  .then((rb) => {
    // 获取一个读取器(reader)对象,提供read()方法 可以通过cancel()关闭
    const reader = rb?.getReader();
    let buffer = "";

    // read()方法,可以异步读取响应体的内容(ReadableStreamDefaultReader)
    reader?.read().then(function process({ done, value }) {
    // done: boolean 数据流是否接收完成,value: Uint8Array 返回数据
      if (done) {
        console.log("status done");
        return;
      }

      // 将数据流转换为UTF-8字符串
      const message = new TextDecoder("utf-8").decode(value);

      buffer += message;
      const lines = buffer.split("\n");
      buffer = lines.pop() || '';

      lines.forEach((line) => {
        console.log(line);
      });
      // 开始读取流信息
      return reader?.read().then(process);
    });
  })
  .catch((e) => {
    console.log("status error");
  });

fetch 并未原生提供终止操作方法,可以通过 AbortControllerAbortSignal 实现请求中断

let controller;

controller = new AbortController();

fetch(url, { signal: controller.signal, headers })

// 断开 fetch-SSE 连接
const closeSSE = () => {
    if (controller) {
      controller.abort();
      controller = undefined;
      outputElement.innerHTML += `close connection<br />`;
    }
};

EventSource

注:在前端中 使用SSE 不等于 一定使用了EventSource

浏览器内置了与服务器发送事件通信的接口 EventSource

const source = new EventSource(url, { withCredentials: boolean = false });

source.onmessage = (e) => {
    console.log('message: ', { ...JSON.parse(e.data) });
};

source.onopen = (e) => {
    console.log('connection open');
};

source.onerror = (e) => {
    console.log('error message: ' + e.event);
};


// 监听自定义事件 
source.addEventListener('eventA', (e) { 
    console.log('eventA message: ' + e.data); 
})

优点:接口中定义了与服务器连接、接收事件/数据、处理错误、关闭连接等功能的特性。它真的很方便!但...

缺点:只能使用GET请求,不能自定义 HTTP 请求头,鉴权等操作只能通过传输同源下的 Cookie。这也是我不推荐它的原因~

特性EventSourcefetch API
兼容性广泛支持,包括Internet Explorer 8及更高版本在较新的浏览器中得到支持,不完全支持Internet Explorer
数据格式只支持服务器发送的文本数据,自动转换为文本可以获取包括文本、JSON、Blob等在内的各种数据格式
错误处理自动尝试重新连接,可以监听'error'事件来处理错误没有内置的重试机制,需要手动处理错误并可能需要进行重试
流式处理支持简单处理服务器发送的流式数据不直接支持流式处理,但可以使用Response对象的body属性获取流式接口
CORS问题受同源策略限制,除非服务器配置了适当的CORS头,否则无法跨源加载不受同源策略限制,可以跨源请求数据,但需要服务器配置适当的CORS头
灵活性只能发送GET请求,拼接字符串传参可以发起任意类型请求。传参灵活

总结

长/短轮询 适用于实时性要求不高的应用 并且浪费服务器资源。

WebSocket适合复杂的双向通信,而 SSE适合简单的服务器推送场景

SSE通信的数据类型为事件流,每个事件由以下字段组成{ event?, id?, data, retry? }

服务端实现:设置对应的响应头,封装数据为事件,控制发送频率,合理断开连接

客户端实现EventSource使用简单 但不够灵活,fetch使用灵活 但需要手动解析数据

使用 SSE 之前要判断是否适合业务场景,并且使用中要注意连接断开的时机 以及错误处理

更多

nginx 可能不会自动压缩 text/event-stream类型传输的数据。

http{
    # 开启压缩机制
    gzip on;
    # 指定会被压缩的MIME类型
    gzip_types text/plain application/javascript text/css application/xml text/javascript image/jpeg image/gif image/png;
    # 省略后续...
}

至少后端是这么跟我说的😫,我自己也调查了一下:

  1. 根据 nginx官方文档语法:  gzip_types mime-type [mime-type ...] ,nginx-gzip 匹配 MIME类型 进行压缩

  2. 翻阅 IANA官方注册表,还真找不到text/event-stream. . .

  3. 再查询 HTTP官方Content-Type的定义 也是

  1. 尝试自定义MIME Type gzip_types: text/event-stream; 数据是被压缩了,但是响应内容却是一次性过来的

事件流的 MIME Type 没有官方定义?nginx能做到同时分块的压缩、传输吗?希望有大佬替我解惑

在数据量大的情况下,不经过压缩的数据会十分占用带宽,几乎相同的数据量,压缩前后差距极大

目前解决方法为:后端传输数据时手动对 event.data 进行gzip压缩,前端在解码后用pako库对数据进行解压使。

对比一下,优化效果比较明显:

结语🎉

demo源码:github.com/XIwE1/sse-g…

fetch-event-source第三方库:github.com/Azure/fetch…

不要光看不实践哦,希望本文能对你有所帮助。

持续更新前端知识,脚踏实地不水文,真的不关注一下吗~

写作不易,如果有收获还望 点赞+收藏 🌹

才疏学浅,如有问题或建议还望指教!