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 就是一种规范。

相关推荐
Asthenia04123 分钟前
ES-Java:一网打尽SearchRequest/SearchSourceBuilder/BoolQueryBuilder/QueryBuilders
后端
堕落年代10 分钟前
Vue主流的状态保存框架对比
前端·javascript·vue.js
OpenTiny社区20 分钟前
TinyVue的DatePicker 组件支持日期面板单独使用啦!
前端·vue.js
冴羽21 分钟前
Svelte 最新中文文档教程(22)—— Svelte 5 迁移指南
前端·javascript·svelte
树上有只程序猿25 分钟前
Vue3组件通信:多个实战场景,轻松玩转复杂数据流!
前端·vue.js
Aska_Lv25 分钟前
业务架构设计---硬件设备监控指标数据上报业务Java企业级架构
后端·架构
剪刀石头布啊33 分钟前
css属性值计算过程
前端·css
m0_7482552636 分钟前
Spring Boot 3.x 引入springdoc-openapi (内置Swagger UI、webmvc-api)
spring boot·后端·ui
bin915337 分钟前
DeepSeek 助力 Vue3 开发:打造丝滑的表格(Table)之添加列宽调整功能,示例Table14基础固定表头示例
前端·javascript·vue.js·ecmascript·deepseek
小华同学ai40 分钟前
吊打中文合成!这款开源语音神器效果炸裂,逼真到离谱!
前端·后端·github