基于SSE的即时更新方案

相信大家都用过ChatGPT了,其中前端的一个交互就是,用户输入prompt之后,回答是逐步流式渲染的。在大模型计算的场景下,普通的http请求肯定不太合适,因为等待时间过长。要实现这种流式渲染的效果,服务端可以将先计算出的数据"推送"给用户,一边计算一边持续返回,避免用户因为等待时间过长关闭页面,而这个主动及时推送消息的实现方案,就可以采用 SSE 技术。

概述

SSE的全称是Server-Sent Events服务器推送事件,是一种服务端实时主动向浏览器推送消息的技术。SSE是HTML5 中一个与通信相关的API,浏览器端提供了给JavaScript使用的EventSource对象。该 API 与 WebSockets API 有一些相似之处,但也有一些差异:

SSE Websocket
协议 HTTP TCP
通信 只能从服务器向客户端推送数据 允许服务器和客户端之间实现双向通信
使用 轻量级,使用简单 相对复杂,需要借助库
连接数 HTTP/1 6个,HTTP/2 可协商(默认 100) 无限制

总体而言,WebSocket是一种更高级和更强大的技术,适用于实时和快速的双向通信,而SSE适用于需要轻量级或不需要双向通信的应用程序。在我们这个场景下,SSE就够用了,并且除了IE之外的浏览器大部分都支持。

服务端实现

协议

SSE API的本质是浏览器发起http请求,服务器在收到请求后,返回状态与数据,并附带以下header信息:

less 复制代码
Content-Type: text/event-stream   // SSE规定推送类型为事件流text/event-stream。
Cache-Control: no-cache,no-store  // 必须关闭缓存,以确保浏览器可以实时获取服务端发送的数据
Connection: keep-alive  // 保证当前链接持久化开启,减少请求延迟

消息格式

EventStream(事件流)格式可以是UTF-8编码的文本,也可以是使用 Base64 编码和 gzip 压缩的二进制消息。我们这里拿文本进行格式说明。

整个文本内容由一个或多个消息组成,每一个消息由一个或多个数据行组成,而每个数据行组成形式为字段名: 字段值,数据行之间使用一个换行符(\n)进行连接,数据行的字段名可选值有且只有这4个(event/data/id/retry)且都是可选的,如果不是这几个字段值的数据行,会被浏览器忽略。以冒号开头的数据行为注释行,也会被浏览器忽略。最后,多个消息之间需要使用两个换行符(\n\n)进行连接,最终形成SSE请求返回的完整文本。

对4个字段名简单做个解释:

  1. event:事件类型。如果指定了该字段,则在浏览器收到该条消息时在当前 EventSource 对象上触发一个事件,事件类型就是该字段的字段值。
  2. id:事件ID。事件的唯一标识符,浏览器会跟踪事件ID,如果发生断连,浏览器会把收到的最后一个事件ID放到 HTTP Header Last-Event-Id 中进行重连,作为一种简单的同步机制。
  3. retry:重连时间。整数值,单位 ms,如果与服务器的连接丢失,浏览器尝试重新连接的等待时间,默认一般为30000。
  4. data:消息数据。数据内容只能以一个字符串的文本形式进行发送。

举个例子:

makefile 复制代码
event: message
data: Hello, world

data: Another message

从数据结构上来看,1、2、4行就是3个数据行,1、2两个数据行是一条消息,4行也是一条消息,这两个消息组成了整个接口返回的文本。

解释一下,就是服务器向客户端推送了两条消息,第一条消息的事件名称为 message,事件数据为 Hello, world;第二条消息没有指定事件名称,事件数据为 Another message。

前端实现

在浏览器端,可以创建 EventSource对象监听服务器发送的事件。一旦建立连接,服务器就可以使用 HTTP 响应的text/event-stream内容类型发送事件消息,浏览器则可以通过监听实例对象的 onmessage、onopen 和 onerror 事件来处理这些消息。

建立连接

EventSource接受两个参数:URL和options。

go 复制代码
// url 为接口地址,一旦 EventSource 对象被创建后,浏览器立即开始对该地址发送过来的事件进行监听
const eventSource = new EventSource(url, { withCredentials: true/false });

// 手动关闭连接
eventSource.close(); 

监听事件

属性监听的写法:

javascript 复制代码
eventSource.onmessage = function(event) {
  console.log('Received message: ' + event.data);
}

eventSource.onopen = function(event) {
  console.log('Connection opened');
}

eventSource.onerror = function(event) {
  console.log('Error occurred: ' + event.event);
}

自定义事件

上面的写法其实还可以这样写(使用addEventListener进行监听),效果一样:

javascript 复制代码
eventSource.addEventListener('message', (event) => {
  console.log('Received message: ' + event.data);
});

eventSource.addEventListener('open', (event) => {
  console.log('Connection opened');
});

eventSource.addEventListener('error', (event) => {
  console.log('Error occurred: ' + event.event);
});

EventSource对象的属性监听只能监听预定义的事件类型(open、message、error),不能用于监听自定义事件类型。使用addEventListener则可以实现,假如服务端发送了这样一条消息:

makefile 复制代码
event: customEvent
data: Hello, world

那我们在客户端即可这样监听接收到这条消息:

javascript 复制代码
eventSource.addEventListener('customEvent', (event) => {
  console.log('Received message: ' + event.data);  // Hello, world
});

实践

服务端

我这里使用的是egg框架,代码如下:

ini 复制代码
let count = 0;
class DemoController extends app.Controller {
  async sseTalk() {
    const { ctx } = this;
    const res = ctx.res;

    // 设置响应头
    ctx.set({
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    });

    ctx.status = 200;
    // 关闭Egg框架自动处理响应(必须使用 ctx.body = 'xxx'来返回),后面使用res对象自行发送数据
    ctx.respond = false;

    const interval = setInterval(() => {
      count++;
      const data = { message: 'this is message' + count };
      const eventData = `data: ${JSON.stringify(data)}\n\n`;
      res.write(eventData);
    }, 1000);

    // 监听连接关闭事件,清除定时器
    res.on('close', () => {
      count = 0;
      clearInterval(interval);
    });
  }
}

前端

ini 复制代码
const App = () => {
  const ref = React.useRef();
  let eventSource = null;
  const onOpen = () => {
    eventSource = new EventSource('http://localhost:7001/api/demo/sseTalk'); //实例化
    eventSource.onmessage = function (e) {
      ref.current.innerHTML += `接收SSE数据:${e.data}<br />`;
    };
    eventSource.onopen = () => {
      ref.current.innerHTML += `SSE连接成功,准备接收数据...<br />`;
    }
  };
  const onClose = () => {
    eventSource.close();
    ref.current.innerHTML += `SSE连接关闭<br /><br />`;
  };
  return (
    <div>
      <Button className='margin-right-16' onClick={onOpen}>建立SSE链接</UI.Button>
      <Button onClick={onClose}>断开连接</UI.Button>
      <div className='margin-top-16' ref={ref}></div>
    </div>
  );
};

运行效果如下:

Fetch实现

使用浏览器提供的EventSource API可以快速简单的实现SSE连接,但是也有一定的限制:

  1. 不能自定义请求头;
  2. 不能使用POST(默认为GET),所以如果传参太长的话可能超过浏览器的长度限制;

使用 Fetch API 实现一个替代接口,用于模拟 SSE 实现,简单实现如下:

javascript 复制代码
// 定义一个通用的模拟方法
const fetchEventSource = async (url, options = {}) => {
  const { onopen, onmessage, onclose } = options;
  const response = await fetch(url, {
    cache: 'no-cache',
    keepalive: true,
    headers: {
      'Content-Type': 'text/event-stream',
    },
  });

  if (response.status !== 200) return;
  
	onopen && onopen();
  const reader = response.body?.getReader();

  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      onclose && onclose();
      break;
    }
    const data = new TextDecoder().decode(value);
    onmessage && onmessage(data);
  }
};

然后使用这个方法发起SSE请求:

javascript 复制代码
const App = () => {
  const ref = React.useRef();
  const onOpen = () => {
    // 使用封装好的fetch sse 方法
  	fetchEventSource('http://localhost:7001/api/demo/sseTalk', {
      onopen: () => {
        ref.current.innerHTML += `FetchSSE 连接成功<br />`;
      },
      onmessage: (d) => {
        ref.current.innerHTML += `FetchSSE 连接成功,接收到数据:${d}<br />`;
      },
      onclose: () => {
        ref.current.innerHTML += `FetchSSE 连接关闭<br /><br />`;
      },
    });
  };
  return (
    <div>
      <Button className='margin-right-16' onClick={onOpen}>建立FetchSSE链接</UI.Button>
      <div className='margin-top-16' ref={ref}></div>
    </div>
  );
};

在上面Nodejs服务端代码的基础上,增加一段自动结束的判断:

ini 复制代码
const interval = setInterval(() => {
  count++;
  const data = { time: new Date().toLocaleString(), message: 'this is message' + count };
  const eventData = `data: ${JSON.stringify(data)}\n\n`;
  res.write(eventData);

  // 增加代码,当发动10条消息后,自动结束消息发送
  if (count > 10) {
    count = 0;
    clearInterval(interval);
    res.end();
  }
}, 1000);

运行效果如下:

总结

SSE 技术是一种轻量级的实时通信技术,基于 HTTP 协议,具有服务端推送、断线重连、简单轻量等优点。SSE 的核心是服务器端向客户端推送数据,客户端通过 EventSource 对象来接收推送的数据。使用 SSE,服务器可以主动向客户端推送数据,而客户端不需要发送请求,只需要保持与服务器的连接状态即可,可以代替很多长轮询的场景。

另外,客户端除了使用html5支持的EventSource API来接收SSE请求的数据外,也可以使用fetch来模拟类似的功能,其优势在于可以自定义请求头,还可以支持post请求来实现更复杂的传参,满足更复杂的场景。

相关推荐
测试老哥25 分钟前
外包干了两年,技术退步明显。。。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
追逐时光者1 小时前
免费、简单、直观的数据库设计工具和 SQL 生成器
后端·mysql
初晴~1 小时前
【Redis分布式锁】高并发场景下秒杀业务的实现思路(集群模式)
java·数据库·redis·分布式·后端·spring·
盖世英雄酱581362 小时前
InnoDB 的页分裂和页合并
数据库·后端
小_太_阳2 小时前
Scala_【2】变量和数据类型
开发语言·后端·scala·intellij-idea
直裾2 小时前
scala借阅图书保存记录(三)
开发语言·后端·scala
ThisIsClark2 小时前
【后端面试总结】深入解析进程和线程的区别
java·jvm·面试
星就前端叭3 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
小林coding4 小时前
阿里云 Java 后端一面,什么难度?
java·后端·mysql·spring·阿里云