Server Sent Event 技术实践

Server Sent Events(SSE)是一种基于 HTTP 的单向通信机制(服务端→客户端),很适合需要服务器单向实时推送的场景,相比 WebSocket 的双向推送,它更轻量。典型应用案例有:

  1. 物流跟踪:实时显示包裹位置信息
  2. 价格看板:每秒推送 XX 的最新价格
  3. AI 应用中的内容流式输出,这大概是 SSE 被大多数开发者熟知的原因

比如 deepseek 的聊天流式输出用的就是 SSE

核心特征

SSE 是基于 HTTP 的,并不是新的技术,只是在普通的 HTTP 请求加了相关规范。规范如下:

  • Content-Type 值为 text/event-stream
  • 长连接,基于HTTP的持久化连接特性,Connection值为keep-alive
  • 数据格式规范,服务器必须按照规范发送数据(data:id:retry:字段),不按照规范服务端不会出错,但是客户端实现方案都是按照规范解析数据的。
  • 支持自动重连,客户端解析数据中的 retry 字段来实现

在控制台中可以看到数据是一点一点传输来的

也可以通过 EventStream 面板查看可视化的数据

概念的东西总是晦涩难懂,下面通过代码示例来讲解。

服务端和客户端分别讲两种实现方式:

服务端 基于 Express 实现

jsx 复制代码
import express from "express";

const app = express();
app.get("/sse", (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const sendData = () => {
    const data = JSON.stringify({ time: new Date().toISOString() });
    res.write(`data: ${data}\n\n`); // 注意格式要求
  };

  const timer = setInterval(sendData, 1000);

  req.on("close", () => {
    clearInterval(timer);
    res.end();
  });
});

app.listen(3000, () => console.log("Server running on port 3000"));

注意响应头设置,可以对照上文核心特征看。

HTTP/1.1 是默认开启持久连接的,一般不需要设置,手动设置一般是为了处理兼容性问题,比如客户端 HTTP 版本过低。

再次可见 SSE 并不是新的技术,只是在普通的 HTTP 连接上加了相关规范

客户端

客户端实现的重点是:

  1. 流式处理数据
  2. 解析规范的数据格式

EventSource

jsx 复制代码
const es = new EventSource('http://localhost:3000/sse');

es.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
});

es.addEventListener('error', (err) => {
  console.error('Error:', err);
})

EventSource已经内置了重连逻辑,不需要处理retry数据。

执行new EventSource() 就会发送一个 GET 请求创建连接,具体用法可以见文档,这个 API 并不复杂,所以它的局限性也有很多:

  1. 不能自定义请求头
  2. 只支持 GET 连接
  3. 自动重连逻辑不可以定制

替代方案有基于fetch封装的 @microsoft/fetch-event-source

fetch-event-source

jsx 复制代码
import { fetchEventSource } from '@microsoft/fetch-event-source';

fetchEventSource('http://localhost:3000/sse', {
  onopen(res){},
  onmessage(event) {
    console.log('Event:', event.event, 'Data:', event.data);
  },
  onclose() {
    console.log('Connection closed');
  },
  onerror(err) {
    console.error('Fatal error:', err);
  }
})

用法很简单,第二个参数基于 fetch 原生参数扩展了4 种事件回调:

  • onopen
  • onmessage
  • onclose
  • onerror

所以用法和 fetch 一样,设置相应回调接收数据即可,几乎所有场景都可以定制化,相比 EventSource 功能丰富的多。

源码分析

接下来看一下部分源码,看它怎么接收并解析数据。

重点就是getBytesgetLinesgetMessages

第一步:读取数据
jsx 复制代码
export async function getBytes(stream: ReadableStream<Uint8Array>, onChunk: (arr: Uint8Array) => void) {
    const reader = stream.getReader();
    let result;
    while (!(result = await reader.read()).done) {
        onChunk(result.value);
    }
}

getBytes 的逻辑是读取响应数据,传给内部 onChunk 处理(也就是 getLines 返回的函数)。

传入的 stream 是 fetch 返回的 response.body,我们知道 response.body 就是一个可读流,可以看一下 TS 的类型定义。

所以就可以按照读取可读流内容的方式来做,这就解答了如何读取服务端响应的数据。

第二步第 1 部分:将数据拆分为 line

首先是把流式数据分成 field: value 这样的 line,这也是 SSE 规范的数据格式。

具体做法就是以 \n 或 \r 为单位拆分,然后以冒号为分界解析出每行的 field 和 value,然后传给内部 onLine (也就是 getMessage 返回的函数)。

代码就是字符串的处理,逻辑较细但不难,有兴趣可以看下代码,我加了注释

jsx 复制代码
/**
 * 将任意chunk解析成EventSource line buffers.
 * 每行应该是"field: value"格式,并以\r、\n或\r\n结尾
 */
function getLines(
  onLine: (line: Uint8Array, fieldLength: number) => void
) {
  // 存储正在处理的数据缓冲区
  let buffer: Uint8Array | undefined;
  // 当前读取位置的指针
  let position: number;
  // 当前行中field部分的长度(冒号前的长度)
  let fieldLength: number;
  // 标记是否需要丢弃尾随的换行符
  let discardTrailingNewline = false;

  return function onChunk(arr: Uint8Array) {
    if (buffer === undefined) {
      // 如果缓冲区为空,直接使用新的数组
      buffer = arr;
      position = 0;
      fieldLength = -1;
    } else {
      // 如果还在解析之前的chunk,将新数据加到现有buffer中
      buffer = concat(buffer, arr);
    }

    const bufLength = buffer.length;
    // 当前正在处理的行的起始位置
    let lineStart = 0;

    // 持续处理缓冲区中的数据,直到处理完所有完整的行
    while (position < bufLength) {
      if (discardTrailingNewline) {
        // 如果上一个字符是\r,检查是否需要跳过后续的\n
        if (buffer[position] === ControlChars.NewLine) {
          lineStart = ++position;
        }
        discardTrailingNewline = false;
      }

      // 向前查找直到找到行尾
      let lineEnd = -1; // \r 或 \n 字符的位置
      for (; position < bufLength && lineEnd === -1; ++position) {
        switch (buffer[position]) {
          case ControlChars.Colon:
            // 记录每行第一个冒号的位置,用于分隔field和value
            if (fieldLength === -1) {
              fieldLength = position - lineStart;
            }
            break;
          case ControlChars.CarriageReturn:
            // 如果遇到\r,标记需要检查后续的\n
            discardTrailingNewline = true;
            lineEnd = position;
            break;
          case ControlChars.NewLine:
            // 找到行尾
            lineEnd = position;
            break;
        }
      }

      if (lineEnd === -1) {
        // 已到达buffer末尾但行未结束
        // 等待下一个数据块继续解析
        break;
      }

      // 找到行尾,调用回调函数处理该行
      onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
      lineStart = position;
      fieldLength = -1;
    }

    if (lineStart === bufLength) {
      // 所有数据都已处理完毕,清空buffer
      buffer = undefined;
    } else if (lineStart !== 0) {
      // 创建一个从lineStart开始的新视图,避免在获取新数据时复制之前已处理的行
      buffer = buffer.subarray(lineStart);
      position -= lineStart;
    }
  };
第二步第 2 部分:以 line 为单位创建message

getLines 拆分出的数据还是Unit8Array 格式,在这一步要通过TextDecoder解码数据,然后拼凑成message,然后再调用 onmessage,message 的格式如下:

jsx 复制代码
{
  data: "",
  event: "",
  id: "",
  retry: "",
}

有没有熟悉的感觉,data、id、retry都是 SSE 规范的数据格式

jsx 复制代码
function newMessage(): EventSourceMessage {
  return {
    data: "",
    event: "",
    id: "",
    retry: undefined,
  };
}

function getMessages(
  onId: (id: string) => void,
  onRetry: (retry: number) => void,
  onMessage?: (msg: EventSourceMessage) => void
) {
  let message = newMessage();
  const decoder = new TextDecoder();

  return function onLine(line: Uint8Array, fieldLength: number) {
    if (line.length === 0) {
      onMessage?.(message);
      message = newMessage();
    } else if (fieldLength > 0) {
      const field = decoder.decode(line.subarray(0, fieldLength));
      const valueOffset =
        fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
      const value = decoder.decode(line.subarray(valueOffset));

      switch (field) {
        case "data":
          message.data = message.data ? message.data + "\n" + value : value; // otherwise,
          break;
        case "event":
          message.event = value;
          break;
        case "id":
          onId((message.id = value));
          break;
        case "retry":
          const retry = parseInt(value, 10);
          if (!isNaN(retry)) {
            onRetry((message.retry = retry));
          }
          break;
      }
    }
  };
}
重试机制

fetch-event-source 没有提供 onretry 事件,重试机制是内部已经做了的。

当请求出错,并且不是通过 signal 取消请求时,会重试,默认重试时间的设置有三种,优先级递减:

  1. onerror 返回的数字
  2. 服务端传的 retry 字段
  3. 默认 1 秒

也提供了取消重试的方法,当用户传入的 onerror 报错时,不再重试。

这个方法还挺妙的,一般我们会通过在 onerror 里手动 throw Error 来取消重试。

服务端 基于 Nest 实现

Nest 提供了 Sse 装饰器,所以实现起来非常简单

返回数据要求是 rxjs 的 Observable 格式,Nest 内部集成了 rxjs,大量逻辑都是基于此实现的。

源码分析

这部分需要有具备一些前置知识:

  • node 的 Stream API
  • Nest 的基本实现原理

首先 Sse 装饰器会给 Controller 中的处理方法加上 SSE_METADATA 元数据。后续处理会根据此走 SSE 相关逻辑。

重点就在 responseController.sse 中,具体步骤如下:

  1. 检查响应值是否是 rxjs 的 Observable 格式
  2. 创建 SseStream 并 pipe 到 response 上,这是一个 Transform 流,这里是重点 ,做了以下两件事:
    1. 设置响应头(Content-Type等)
    2. 将响应数据转为 SSE 规范的字符串
  3. 用 rxjs 的 API 订阅响应数据(Observable对象),每次接收到数据都 pipe 到 SseStream

就是这个流程,通过流程图来看:

来看代码,以下代码在源码基础上做了简化:

jsx 复制代码
public sse(result, response, request) {
    // 确保result是一个Observable
    if (!isObservable(result)) {
      throw new ReferenceError(
        'You must return an Observable stream to use Server-Sent Events (SSE).',
      );
    }

    // 创建SSE流并将其pipe到响应对象
    const stream = new SseStream(request);
    stream.pipe(response);

    // 订阅Observable,处理消息流
    const subscription = result
      .pipe(
        // 将消息转换为标准的MessageEvent格式
        map((message): MessageEvent => {
          if (isObject(message)) {
            return message as MessageEvent;
          }
          return { data: message as object | string };
        }),
        concatMap(message => {
          return new Promise<void>(resolve =>
            stream.writeMessage(message, () => resolve()),
          );
        }),
      )
      .subscribe({
        // 当Observable完成时结束响应
        complete: () => {
          response.end();
        },
      });

    // 监听请求关闭事件,清理资源
    request.on('close', () => {
      subscription.unsubscribe();
      if (!stream.writableEnded) {
        stream.end();
      }
    });
  }

这部分代码是比较易读的,rxjs 订阅的数据通过 writeMessage 写给 SseStream。

jsx 复制代码
class SseStream extends Transform {
  private lastEventId: number = null;

  constructor(req?: IncomingMessage) {
    super({ objectMode: true });
    // 处理req相关逻辑
  }

  pipe(destination) {
    if (destination.writeHead) {
      destination.writeHead(200, {
        'Content-Type': 'text/event-stream',
        Connection: 'keep-alive',
        'Cache-Control':
          'private, no-cache, no-store, must-revalidate, max-age=0, no-transform',
        Pragma: 'no-cache',
        Expire: '0',
        'X-Accel-Buffering': 'no',
      });
      destination.flushHeaders();
    }

    destination.write('\n');
    return super.pipe(destination);
  }

  /**
   * Transform流的核心转换方法
   * 将消息对象转换为SSE格式的字符串
   */
  _transform(
    message: MessageEvent,
    encoding: string,
    callback: (error?: Error | null, data?: any) => void,
  ) {
    let data = message.type ? `event: ${message.type}\n` : '';
    data += message.id ? `id: ${message.id}\n` : '';
    data += message.retry ? `retry: ${message.retry}\n` : '';
    data += message.data ? toDataString(message.data) : '';
    data += '\n';
    this.push(data);
    callback();
  }

  writeMessage(
    message: MessageEvent,
    cb: (error: Error | null | undefined) => void,
  ) {
    // 如果消息没有ID,则自动生成
    if (!message.id) {
      this.lastEventId++;
      message.id = this.lastEventId.toString();
    }

    this.write(message, 'utf-8');
  }
}

SseStream 重写了 pipe 方法,我们在上面看到 SseStream 是 pipe 给 response 的,所以这里的逻辑就是给 response 加上响应头,再调原生的 pipe。

SseStream 核心逻辑在 _transform 方法中,按照 SSE 规范生成字符串。

结束

服务端和客户端都提供了两种实现示例,也做了源码解读,可以很清晰的看到 SSE 的原理,通篇看下来其实能感觉到,SSE 就是一种规范。

相关推荐
未来之窗软件服务15 分钟前
打开所在文件路径,鸿蒙系统,苹果macos,windows,android,linux —智能编程—仙盟创梦IDE
前端·ide·资源管理器·仙盟创梦ide
caihuayuan518 分钟前
前端面试2
java·大数据·spring boot·后端·课程设计
houzhizhen30 分钟前
SQL JOIN 关联条件和 where 条件的异同
前端·数据库·sql
郭尘帅66640 分钟前
SpringBoot学习(上) , SpringBoot项目的创建(IDEA2024版本)
spring boot·后端·学习
野犬寒鸦1 小时前
MySQL索引详解(下)(SQL性能分析,索引使用)
数据库·后端·sql·mysql
^小桃冰茶4 小时前
CSS知识总结
前端·css
巴巴_羊5 小时前
yarn npm pnpm
前端·npm·node.js
.生产的驴5 小时前
SpringBoot 集成滑块验证码AJ-Captcha行为验证码 Redis分布式 接口限流 防爬虫
java·spring boot·redis·分布式·后端·爬虫·tomcat
chéng ௹7 小时前
vue2 上传pdf,拖拽盖章,下载图片
前端·css·pdf
嗯.~7 小时前
【无标题】如何在sheel中运行Spark
前端·javascript·c#