做过两个大模型聊天的项目,也是从那时起知道了SSE(Server-Sent Events),进而又知道了@microsoft/fetch-event-source。
今天有空把自己知道的做一个总结,便于以后查阅,主要是浅读一下@microsoft/fetch-event-source这个库的源码。
基本的SSE概念可以参考https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html和https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events。
既然有了浏览器原生支持的EventSource为什么还要@microsoft/fetch-event-source,可以参考@microsoft/fetch-event-source中提到的
大体意思是:原生EventSource只有get请求参数只能通过url传递,不能传递自定义的请求头,重试机制我们不能插手。而它解决了以上的问题。同时你还能拿到Response对象。
现在有很多前端项目,跟后端的鉴权机制是通过在请求头中添加Authorization,用原生的EventSource就不行。
接下来让我们看一下@microsoft/fetch-event-source源码吧
0. 克隆代码
git clone https://github.com/Azure/fetch-event-source.git
1. 目录结构
src目录中有以上4个文件,parse.spec.ts是用来做单元测试的,这里就不看了。index.ts只是简单的导出,也不看了。fetch.ts负责发送请求,parse.ts负责把请求返回的数据解析出消息。
2. fetch.ts
这个文件中最主要的就是fetchEventSource函数,也是这个库的最主要的内容。它的主要流程是调用fetch发送请求,用parse.ts中的函数解析响应返回的流得到一个个消息(事件)。
FetchEventSourceInit是fetchEventSource函数的第二个参数的类型定义,它包括
字段 | 含义 |
---|---|
headers | 请求头,Record<string, string> |
onopen | 响应回来调用的回调 |
onmessage | 接收到消息时的回调 |
onclose | 响应结束时的回调 |
onerror | 报错时的回调 |
openWhenHidden | 页面隐藏时是否依旧开这连接 |
fetch | 自定义的fetch,默认使用window.fetch |
方法defaultOnOpen, 默认的onopen回调,判断响应的Content-Type是否text/event-stream,如果不是则抛出异常。
方法fetchEventSource
- 请求headers补上accept,值为text/event-stream
- 根据参数openWhenHidden,决定是否注册visibilitychange事件,用来实现页面隐藏后取消请求,页面重新显示后重新发送请求。这个机制可以借鉴
- 给参数signal注册abort事件,这样在外部调用AbortController的abort时,这里就能取消请求了,调用了dispose和resolve
- 调用create方法发送请求
- 创建AbortController实例curRequestController,把它的signal传递给fetch,实现取消请求的功能
- 使用fetch发送请求
- 回调onopen
- 读取响应流,直到结束,读流
- 回调onclose
- 调用dispose和resolve
3. parse.ts
这个文件提供了对返回的流进行解析的函数。
- getBytes
调用ReadableStream实例的getReader方法获得ReadableStreamDefaultReader实例,然后循环调用ReadableStreamDefaultReader的read方法直到流结束,read方法返回的是ReadableStreamReadResult,它的value属性就是流的内容,是Unit8Array类型的。 - getLines
解析getBytes读取的Unit8Array对象,从中得到行。 - getMessage
解析getLines中得到的行,得到消息,遇到空行认为是一个消息的结束。
这里的解析过程让我想起了表达式的解析,有借鉴意义,同时这里对于高阶函数的运用让人眼前一亮!
连接关闭的问题
在实践过程中,我遇到了请求关闭的问题。从前端不论是原生的EventSource的close或是fetchEventSource的signal都能实现关闭请求。但是对于原生的EventSource却无法单独从后端关闭请求,我用的是node写的简单的后端,代码如下(在阮一峰老师的代码上改的)
const http = require('http');
const server = http.createServer((req, res) => {
const fileName = `.${req.url}`;
if (fileName === './stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write('retry: 10000\n');
res.write(`data: ${new Date()}\n\n`);
let n = 1;
const interval = setInterval(() => {
if (n > 10) {
res.end();
return;
}
res.write(`data: ${new Date()}\n\n`);
res.write('event: test\n');
res.write('data: this is test msg\n\n');
n++;
}, 1000);
req.connection.addListener('close', () => {
clearInterval(interval);
console.log('close...');
}, false);
}
});
server.listen(8811, '127.0.0.1');
我故意加了个计数,10次以后由服务器端关闭连接。结果是前端会停一会然后再重试。
以下是前端的代码:
<!DOCTYPE html>
<html>
<head>
<title>SSE</title>
</head>
<body>
<div id="container">
</div>
<button id="openBtn">open</button><button id="closeBtn">close</button>
<script>
const $container = document.querySelector('#container');
function addMessage(msg) {
$container.innerHTML += msg +'<br>';
}
const $openBtn = document.querySelector('#openBtn');
let source;
$openBtn.addEventListener('click', () => {
if (source) {
return;
}
source = new EventSource('http://localhost:8811/stream');
source.addEventListener('open', () => {
addMessage('opened...');
});
source.addEventListener('message', (event) => {
addMessage('[message] ' + event.data);
});
source.addEventListener('error', (error) => {
console.error('error:', error);
});
source.addEventListener('test', (event) => {
console.log(event);
addMessage('[test] ' + event.data);
});
});
const $closeBtn = document.querySelector('#closeBtn');
$closeBtn.addEventListener('click', () => {
if (!source) {
return;
}
source.close();
source = null;
addMessage('close...');
});
</script>
</body>
</html>
如果用@microsoft/fetch-event-source的话,主要在onclose不throw异常就不会再重试。
<!DOCTYPE html>
<html>
<head>
<title>@microsoft/fetch-event-source</title>
</head>
<body>
<div id="container">
</div>
<button id="openBtn">open</button><button id="closeBtn">close</button>
<script src="./lib/merge.js"></script>
<script>
const $container = document.querySelector('#container');
function addMessage(msg) {
$container.innerHTML += msg +'<br>';
}
const $openBtn = document.querySelector('#openBtn');
let source;
let abortController;
$openBtn.addEventListener('click', () => {
if (abortController) {
return;
}
abortController = new AbortController();
fetchEventSource('http://localhost:8811/stream', {
signal: abortController.signal,
async onopen(res) {
addMessage('open...');
console.log('open: ', res);
},
onmessage(msg) {
addMessage(`[${msg.event}]: ${msg.data}`);
},
onclose() {
addMessage('close...');
abortController = null;
},
onerror() {
console.log('error...');
}
})
});
const $closeBtn = document.querySelector('#closeBtn');
$closeBtn.addEventListener('click', () => {
if (abortController) {
abortController.abort();
abortController = null;
}
});
</script>
</body>
</html>
其中merge.js是我手动把@microsoft/fetch-event-source编译后合并到一个文件中的。
先说一下为什么@microsoft/fetch-event-source为啥跟EventSource的行为不同。
如果服务器端主动关闭了连接,getBytes这里会结束,然后往下执行onclose回调,如果onclose正常执行没有抛出异常的话,就会继续执行dispose和resolve,此时不会触发任何重试的机制。如果在onclose抛出了异常,就像@microsoft/fetch-event-source官方代码中说的trow new RetriableError(),那么就不会执行dispose和resolve(dispose中有curRequestController.abort调用)而是直接跳到底下的catch中,此时curRequestController.signal.aborted是false,因此就会触发重试机制。这里再说一下如果通过传递给fetchEventSource函数的signal来通知取消请求时,getBytes这里会直接抛出异常,根本走不到onclose而是到了底下的catch,此时由于在监控到fetchEventSource外部传入的signal的abort时间时调用了dispose,因此curRequestController.signal.aborted时true的,因此即使进入了catch也不会触发重试。
接下来说一下原生EventSource关闭连接的问题。前端通过调用EventSource实例的close肯定能关闭。但是后端主动关闭的话就会像上面说的过一段时间后重试。我没有办法再不修改前端的前提下让后端能关闭连接。我的想法是后端如果要关闭连接主动发一个事件给前端,让前端调用close方法关闭。但是这样又增加了前后端的耦合我其实是不太喜欢的,不过没办法,前后端本身就是耦合的。
关于自定义事件
原生EventSource和@microsoft/fetch-event-source还有一些不同,比如对自定义事件的处理和对注释的处理。
@microsoft/fetch-event-source所有事件都是通过onmessage处理的,通过type可以区分,默认的message事件type是空,自定义事件的消息type是有值的可以通过type来自行分发。
对于注释,原生EventSource是不会触发任何事件的,但是@microsoft/fetch-event-source已经回调onmessage,注释的type也是空,而且它的data也是空。
我把上面的示例代码打包放在这里了,需要的自取。
注:如果你觉得有帮助请关注点赞哈:)