读EventSource思考

6 阅读7分钟

1、为什么使用EventSource,Fetch + Accept: text/event-stream, 也可以吧?

可以

// 手动处理所有细节
const response = await fetch('/api/stream', {
  headers: { 'Accept': 'text/event-stream' }
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  
  const chunk = decoder.decode(value);
  // 需要手动解析: "data: {...}\n\n"
  const lines = chunk.split('\n');
  // 还要自己处理重连、错误恢复...
}

2、EventSource只能GET请求,如果需要传递参数怎么办?

拼接URL,复杂的情况只能其他形式处理了,例如先 POST 创建,再 GET 订阅

3、什么情况用EventSource(高级api),什么情况用Fetch + Accept(低级)?他们的区别是什么?

95% 的情况下用EventSource ,因为:

  1. 代码简洁,维护成本低,10行代码解决,Fetch + Accept需要50行
  2. 浏览器优化更好
  3. 符合 SSE 设计初衷

只有在你需要:

  • POST 请求发送数据
  • 非标准协议解析
  • 特殊认证/重试逻辑
  • 现有代码库已用 Fetch

4、SSE的默认重连行为:EventSource的规范中没有提供设置超时或重连间隔的API。因此这种想法EventSource无法直接实现。

  这个Fetch + Accept可以自定义超时设置和重连间隔设置,简陋有简陋的好处,以下是ai生成的代码,可以参考思路

  构造:

class CustomSSEClient {
  constructor(url, options = {}) {
    this.url = url;
    this.options = {
      retryCount: 3,
      retryDelay: 1000,
      timeout: 30000,
      onMessage: () => {},
      onError: () => {},
      onOpen: () => {},
      ...options
    };
    
    this.retries = 0;
    this.controller = null;
    this.isConnected = false;
  }

  async connect() {
    try {
      this.controller = new AbortController();
      const timeoutId = setTimeout(() => {
        this.controller.abort();
      }, this.options.timeout);

      const response = await fetch(this.url, {
        method: 'GET',
        headers: {
          'Accept': 'text/event-stream',
          'Cache-Control': 'no-cache',
        },
        signal: this.controller.signal
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      this.isConnected = true;
      this.retries = 0; // 重置重试计数
      this.options.onOpen();

      await this.processStream(response.body);

    } catch (error) {
      this.handleError(error);
    }
  }

  async processStream(readableStream) {
    const reader = readableStream.getReader();
    const decoder = new TextDecoder();

    try {
      while (this.isConnected) {
        const { value, done } = await reader.read();
        
        if (done) {
          console.log('Stream completed');
          break;
        }

        const chunk = decoder.decode(value);
        this.parseAndHandleEvents(chunk);
      }
    } catch (error) {
      this.handleError(error);
    } finally {
      reader.releaseLock();
    }
  }

  parseAndHandleEvents(chunk) {
    const lines = chunk.split('\n');
    let currentEvent = {};

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        currentEvent.data = line.slice(6);
      } else if (line.startsWith('event: ')) {
        currentEvent.type = line.slice(7);
      } else if (line.startsWith('id: ')) {
        currentEvent.id = line.slice(4);
      } else if (line.startsWith('retry: ')) {
        // 忽略服务器的retry指令,我们自己控制
      } else if (line === '') {
        // 空行表示事件结束
        if (currentEvent.data) {
          this.options.onMessage({
            type: currentEvent.type || 'message',
            data: currentEvent.data
          });
        }
        currentEvent = {};
      }
    }
  }

  handleError(error) {
    this.isConnected = false;
    
    // 特定错误不重试
    if (error.name === 'AbortError') {
      console.log('Connection timeout');
      this.options.onError(new Error('Connection timeout'));
      return;
    }

    if (this.retries < this.options.retryCount) {
      this.retries++;
      const delay = this.options.retryDelay * this.retries; // 指数退避
      
      console.log(`Reconnecting in ${delay}ms (attempt ${this.retries})`);
      
      setTimeout(() => this.connect(), delay);
    } else {
      console.log('Max retries exceeded');
      this.options.onError(new Error('Max retries exceeded'));
    }
  }

  close() {
    this.isConnected = false;
    if (this.controller) {
      this.controller.abort();
    }
  }
}

  基础使用:

const sseClient = new CustomSSEClient('/api/stream', {
  retryCount: 5,
  retryDelay: 2000,
  timeout: 15000,
  
  onMessage: (event) => {
    try {
      const data = JSON.parse(event.data);
      console.log('Received:', data);
      
      if (data.status === 'done') {
        sseClient.close(); // 任务完成,主动关闭
      }
    } catch (err) {
      console.error('Parse error:', err);
    }
  },
  
  onError: (error) => {
    console.error('SSE Error:', error.message);
    // 可以显示用户友好的错误信息
    showErrorMessage(`连接失败: ${error.message}`);
  },
  
  onOpen: () => {
    console.log('SSE连接已建立');
    showConnectionStatus('connected');
  }
});

// 开始连接
sseClient.connect();

// 手动关闭
// sseClient.close();

5、断点重连机制

断点重连机制,如果中断了之后,下次请求带上新的seq序号,后端应该不会去重新请求第三方ai接口了吧?那意思是他会把本次请求存储起来,后续再去读?那什么时候删除呢?

举例说明:应该是前端每次都需要传一个会议任务号给后端,例如前端告诉后端老大我们有个任务号为“A”的任务需要执行,后端老大看见任务“A”之后先去看任务“A”是否有会议室,如果没有后端老大会开一个会议室(假设为“H1”),有就告诉前端去对应会议室,于是第一次请求的时候后端开发会去“H1”的会议室去执行任务,前端从会议室的门“door1”进去等待后端不断返回结果,由于前端的各种原因(网络,手动关闭等)导致前端离开了会议室,但是后端不管你,继续执行他的任务,执行完一些给你一些,发现前端走了之后,就会把先执行完的结果放一边,然后把门“door1”关上,如果半小时之后还是没有人来,那就把会议室关了,所有请求结果给后端老大,后端老大存起来这个任务A的结果,下次前端需要请求的时候拿着任务号“A”去找后端老大,重复以上流程,当发现有任务A有会议室那就让前端从新门(door2)进去(因为新的一次请求需要重新发起链接,上一次的门door已经关闭不可用了)会议室A等结果,进门之后发现有一些任务已经完成了,前端就可以直接拿到这些任务先渲染到页面上,然后后端也会把后续的任务给前端

看着有点乱,讲故事的形式描述一下

公司组织架构

角色职责技术对应
前端前端任务浏览器客户端
后端老大分配任务SessionManager
后端开发调用ai接口,真正干活的AI服务
会议室项目工作区ConversationSession
进展汇报通道SSE连接
任务号项目编号ConversationId
文件柜成果存档处generatedChunks数组

第一章:新项目启动

// 周一早上9:00
前端:"老大!我们接到客户新需求,任务号'A',请安排后端开发"

后端老大:"收到!让我看看任务'A'..."
    检查公司项目登记表...
    "任务'A'没有会议室"
    "创建新会议室'A1',安排后端开发工程师入场"

后端开发进入会议室A1:"我开始干活了!"
    开始编码...
    每完成一个小模块,就朝门口喊:"我要开始了"
    "我先喝口水"
    "开始开发了"
    "摸会鱼"
    "继续开发"
    ......

第二章:突发状况处理

// 前端突然掉线(网络问题)
前端:"太难等了,我去摸会鱼,待会见" 
    会议室门1自动关闭

// 但会议室内部:
后端开发:"前端虽然走了,但工作继续。"
    "继续开发"
    "摸会鱼"
    把所有成果都记录在会议室白板上

// 30分钟后前端恢复
前端:"老大,我摸鱼回来了,我想看看任务'A'做的怎么样了,后端有没有摸鱼"

后端老大:"好的,我看看,任务'A'的会议室还在"
    "给你开个新门2进入会议室A1"
    "这是你错过的进展:"
        - 启动了项目
        - 完成了需求分析
        - 喝了口水
        - 开始开发了一行代码
    
前端通过门2进入,立即看到白板上的所有进展

第三章:多人协作场景

// 另一个前端也想了解任务'A'
前端2"老大,我也想听任务'A'的进展!"

后端老大:"没问题!任务'A'会议室还在运行"
    "给你开个新门3进入会议室A1"

现在:
会议室A1内:后端开发继续工作
门2:前端1在收听  
门3:前端2在收听

后端开发:"兄弟们,我做完了" 
    同时向门2和门3喊话

第四章:项目完成与收尾

后端开发:"任务'A'全部完成,最终报告已提交。"

后端老大:
    向所有开着的门喊:"项目已完成!"
    关闭所有门(前端连接断开)
    启动清理倒计时30分钟:
        - 期间有新前端来问 → 直接给最终报告
        - 30分钟后无人问津 → 清空会议室,开发工程师下班

各种场景演绎

场景A:网络波动

前端:"老大,任务'A'的进展!"
后端老大:"门1已开,请收听..."

[网络断开]
前端:"啊!门1断了!"
后端开发:"继续工作,不理他..."

[网络恢复]  
前端:"老大,重新听任务'A'!"
后端老大:"门2已开!这是错过的内容..."

场景B:多标签页

// 同一个任务在多个浏览器标签页打开
标签页1"老大,任务'A'!" → 开门1
标签页2"老大,任务'A'!" → 开门2

后端开发:"完成模块X!" 
     同时喊给门1和门2

场景C:任务完成后的查询

// 任务已完成30分钟内
前端:"老大,任务'A'的最终结果?"
后端老大:"项目已完工,这是最终报告..."
    直接给存档的完整结果

// 任务已完成31分钟  
前端:"老大,任务'A'?"
后端老大:"会议室已清理,重新提需求做吧,代码已经被我删了..."

核心设计思想

1. 职责分离

后端开发:只负责在会议室里干活
后端老大:只负责会议室和门的调度  
前端:只负责通过门收听进展

2. 状态持久化

会议室状态独立于门的存在
门可以随便开关,不影响会议室工作
所有进展都记录在白板上,永不丢失

3. 资源优化

一个后端开发服务多个前端
工作只做一次,成果多人共享
无人关注时自动清理资源

github代码实例

思考来源:EventSource