相信大家都用过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个字段名简单做个解释:
- event:事件类型。如果指定了该字段,则在浏览器收到该条消息时在当前 EventSource 对象上触发一个事件,事件类型就是该字段的字段值。
- id:事件ID。事件的唯一标识符,浏览器会跟踪事件ID,如果发生断连,浏览器会把收到的最后一个事件ID放到 HTTP Header Last-Event-Id 中进行重连,作为一种简单的同步机制。
- retry:重连时间。整数值,单位 ms,如果与服务器的连接丢失,浏览器尝试重新连接的等待时间,默认一般为30000。
- 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连接,但是也有一定的限制:
- 不能自定义请求头;
- 不能使用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请求来实现更复杂的传参,满足更复杂的场景。