现在 ChatGPT Style 的 App 非常热,在 AI 回复消息时通常都是用这种"打字机"效果像上图这样,那这样的效果是怎么实现的呢?本文带你一探究竟。
实现这种效果至少有3种方式:
- 轮询接口
- Webscoket
- SSE
传统的 Ajax 轮询低效且消耗资源更多,先被我们排除掉,接下来看看 SSE 和 Webscoket。Websocket 大家不陌生,那 SSE 是什么呢? SSE是一种基于 HTTP 的服务器端推送技术,与传统的 Ajax 轮询相比,SSE更加高效,因为它只在有新数据时才会向客户端发送数据。想象一下,你正在玩一款在线游戏,想要得到实时的游戏状态。但是,如果每秒钟都要向服务器发起请求,那么你就不得不承受一堆烦人的加载时间和浪费的带宽。这时候,SSE就可以帮到你了,让你的体验更加流畅和舒适。
听起来 SSE 是不是很像 Websocket? 但他们有一些区别,SSE 可以让服务器向客户端推送数据,而无需客户端发起请求。这就像是你在等待一封信,只有发件人可以给你写信,而你却无法回信,即 SSE 是从服务端发送更新的单向通信。
SSE 通信是如何建立起来的呢?
使用 SSE,客户端通过发送 GET 请求与服务器建立持久连接,然后服务器通过该连接向客户端发送数据。在发送数据时,服务器需要将需要推送的数据封装在消息中,并使用特殊的 MIME 类型"text/event-stream"进行标识,以便客户端能够识别。客户端在接收到该响应后,会自动将其解析成一个事件流,并将事件流传递给 JavaScript 应用程序进行处理。
也就是说在收到浏览器的 GET 请求后,服务端只要设置响应头为 Content-Type: text/event-stream
即可开启 SSE 通信协议, 并且这个协议还有一个非常重要的特性:支持自动重连和心跳机制,确保连接的可靠性。如果连接断开,客户端会自动发起重连,而服务器可以通过发送一个空的消息,来保持连接的活跃状态。
开启 SSE Node.js 示例代码, 截选自 sse.js
js
SSEClient.prototype.initialize = function() {
...
this.res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive'
});
this.res.write(':ok\n\n');
};
这意味着 SSE 是一种高效、可靠、易于实现(心智负担低)的实时数据推送协议,给开发者的业务场景开发多了一种技术选择方案。
实现 SSE
服务端部分,以Node.js 为例
js
const http = require('http');
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
http.createServer((req, res) => {
if (req.url === '/sse') {
console.log('new client connected');
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const listener = (data) => {
res.write(`data: ${JSON.stringify(data)}\\n\\n`);
};
// Listen for events
emitter.on('event', listener);
// Remove listener on client disconnect
req.on('close', () => {
console.log('client disconnected');
emitter.off('event', listener);
});
}
}).listen(8080, () => {
console.log('SSE server listening on port 8080');
});
// Emit events
setInterval(() => {
emitter.emit('event', { message: 'Hello, world!' });
}, 1000);
简单解释一下代码,先通过 http
起一个 Http server 监听 /sse
路由,当收到客户端的请求 localhost:8080/sse
时建立一个 SSE 连接,通过设置响应头,建立了连接后,如果服务端有更新,就通过 events
库推送给前端即可。
在客户端部分, 我们什么都不需要做,浏览器已经帮我们实现了,并且还兼容性良好。
前端只要调用浏览器同的 EventSource
API 与服务端建立连接,然后监听服务端的更新并且将消息追加到页面上即可
html
<!DOCTYPE html>
<html>
<head>
<title>SSE Chat Example</title>
</head>
<body>
<ul id="chat"></ul>
<script>
const chat = document.getElementById('chat');
const source = new EventSource('/sse');
source.addEventListener('ping', function(event) {
const data = JSON.parse(event.data);
const message = data.message;
const li = document.createElement('li');
li.textContent = message;
chat.appendChild(li);
});
</script>
</body>
</html>
缺了点什么?
我们虽然可以通过浏览器的 EventSource API 来实现这样的打字效果。但是这个解决方案有一些限制。
从前文的介绍中我们可以看到第一个限制,它必须要求我们使用 GET 请求。而且当想给服务端传递一些信息时无法通过Body 传递,只能将内容写到 URL 中(还必须设置 withCredentials),那就会受到浏览器对 URL 长度的限制。比如大多数浏览器输入的 URL 最多 2000 个字符。当然也无法传递一些自定义请求头。前面我们虽然提到了断线重连,但是他的重连策略很弱,重连几次无果后就停止了,在生产环境还是很危险的。
那针对这些问题怎么办呢? 不使用 EventSource 呗,所以类似 ChatGPT 这样的应用都会封装一个 基于 fetch API 的 SSE 版本。怎么封装呢? 没有魔法,后端不需要做更改,前端通过 fetch 包装一层 SSE 的事件就可以把缺的这部分补回来,成为一个更好的 SSE 解决方案,并且在使用方式上没什么变化
js
// BEFORE:
const sse = new EventSource('/api/sse');
sse.onmessage = (ev) => {
console.log(ev.data);
};
// AFTER:
import { fetchEventSource } from 'your-fetch-event-source';
await fetchEventSource('/api/sse', {
onmessage(ev) {
console.log(ev.data);
}
});
- 解决请求限制,就是普通 fetch 请求,可以接收各种类型请求和请求头
js
fetchEventSource('/api/sse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
foo: 'bar'
}),
});
- 对监听到的数据有了更细粒度的控制,之前通过浏览器API拿到的直接就是后端更新的内容,现在拿到的是reponse对象
js
fetchEventSource('/api/sse', {
async onopen(response) {
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
return; // everything's good
} else if (response.status >= 400 && response.status < 500 && response.status !== 429) {
// client-side errors are usually non-retriable:
}
},
- 连接中断时可以自己定义出错的逻辑
js
class RetriableError extends Error { }
class FatalError extends Error { }
fetchEventSource('/api/sse', {
...
onclose() {
// if the server closes the connection unexpectedly, retry:
throw new RetriableError();
},
onerror(err) {
if (err instanceof FatalError) {
throw err; // rethrow to stop the operation
} else {
// do nothing to automatically retry. You can also
// return a specific retry interval here.
}
}
});
代码来自微软封装的@microsoft/fetch-event-source
,好奇宝宝可以自行阅读https://github.com/Azure/fetch-event-source/blob/main/src/fetch.ts
, 本篇分享结束,都看到这里了点个赞吧!