讲一讲@microsoft/fetch-event-source源码是怎么处理流式数据的
问题驱动我们看源码的动力,在此,我们去看看改插件解决的最主要的两个问题,支持post和获取的数据稳定。 主要的实现代码在 fetch.ts 与 parse.ts,后者是工具函数的集成。
实质就是fetch发送了请求。
至此,最重要的三个函数出现了。
getBytes
export async function getBytes(
stream: ReadableStream<Uint8Array>, // 可读取的二进制数据流
onChunk: (arr: Uint8Array) => void
) {
const reader = stream.getReader();
let result: ReadableStreamDefaultReadResult<Uint8Array>;
while (!(result = await reader.read()).done) { // 持续接收
onChunk(result.value);
}
}
getLines
export function getLines(
onLine: (line: Uint8Array, fieldLength: number) => void
) {
let buffer: Uint8Array | undefined;
let position: number; // 当前读取位置
let fieldLength: number; // 行中 `field` 部分的长度
let discardTrailingNewline = false;
// 返回一个可以处理每个传入字节块的函数:
return function onChunk(arr: Uint8Array) {
if (buffer === undefined) {
buffer = arr;
position = 0;
fieldLength = -1;
} else {
// 我们仍在解析旧行。将新字节附加到缓冲区中:
buffer = concat(buffer, arr);
}
const bufLength = buffer.length;
let lineStart = 0; // 当前行的起始索引
while (position < bufLength) {
if (discardTrailingNewline) {
if (buffer[position] === ControlChars.NewLine) {
lineStart = ++position; // 跳过到下一个字符
}
discardTrailingNewline = false;
}
// 开始向前查找直到行尾:
let lineEnd = -1; // \r 或 \n 字符的索引
for (; position < bufLength && lineEnd === -1; ++position) {
switch (buffer[position]) {
case ControlChars.Colon:
if (fieldLength === -1) {
// 行中的第一个冒号
fieldLength = position - lineStart;
}
break;
// @ts-ignore:7029 \r 情况应当直接进入 \n:
case ControlChars.CarriageReturn:
discardTrailingNewline = true;
case ControlChars.NewLine:
lineEnd = position;
break;
}
}
if (lineEnd === -1) {
// 我们到达了缓冲区的末尾,但行尚未结束。
// 等待下一个 arr 然后继续解析:
break;
}
// 我们已到达行尾,输出它:
onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
lineStart = position; // 我们现在在下一行
fieldLength = -1;
}
if (lineStart === bufLength) {
buffer = undefined; // 我们已完成读取
} else if (lineStart !== 0) {
// 创建一个新的视图到缓冲区,从 lineStart 开始,
// 这样当我们获得新 arr 时不需要复制之前的行:
buffer = buffer.subarray(lineStart);
position -= lineStart;
}
};
}
- 这个函数的作用是逐个解析传入的字节块,以找到数据中的行结束符,并在每找到一个完整的行时调用
onLine函数 。
测试用例可观全景:
describe('getLines', () => {
it('single line', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual('id: abc');
expect(fieldLength).toEqual(2);
});
// act:
next(encoder.encode('id: abc\n'));
// assert:
expect(lineNum).toBe(1);
});
it('multiple lines', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual(lineNum === 1 ? 'id: abc' : 'data: def');
expect(fieldLength).toEqual(lineNum === 1 ? 2 : 4);
});
// act:
next(encoder.encode('id: abc\n'));
next(encoder.encode('data: def\n'));
// assert:
expect(lineNum).toBe(2);
});
it('single line split across multiple arrays', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual('id: abc');
expect(fieldLength).toEqual(2);
});
// act:
next(encoder.encode('id: a'));
next(encoder.encode('bc\n'));
// assert:
expect(lineNum).toBe(1);
});
it('multiple lines split across multiple arrays', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual(lineNum === 1 ? 'id: abc' : 'data: def');
expect(fieldLength).toEqual(lineNum === 1 ? 2 : 4);
});
// act:
next(encoder.encode('id: ab'));
next(encoder.encode('c\nda'));
next(encoder.encode('ta: def\n'));
// assert:
expect(lineNum).toBe(2);
});
it('new line', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual('');
expect(fieldLength).toEqual(-1);
});
// act:
next(encoder.encode('\n'));
// assert:
expect(lineNum).toBe(1);
});
it('comment line', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual(': this is a comment');
expect(fieldLength).toEqual(0);
});
// act:
next(encoder.encode(': this is a comment\n'));
// assert:
expect(lineNum).toBe(1);
});
it('line with no field', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual('this is an invalid line');
expect(fieldLength).toEqual(-1);
});
// act:
next(encoder.encode('this is an invalid line\n'));
// assert:
expect(lineNum).toBe(1);
});
it('line with multiple colons', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual('id: abc: def');
expect(fieldLength).toEqual(2);
});
// act:
next(encoder.encode('id: abc: def\n'));
// assert:
expect(lineNum).toBe(1);
});
it('single byte array with multiple lines separated by \\n', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual(lineNum === 1 ? 'id: abc' : 'data: def');
expect(fieldLength).toEqual(lineNum === 1 ? 2 : 4);
});
// act:
next(encoder.encode('id: abc\ndata: def\n'));
// assert:
expect(lineNum).toBe(2);
});
it('single byte array with multiple lines separated by \\r', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual(lineNum === 1 ? 'id: abc' : 'data: def');
expect(fieldLength).toEqual(lineNum === 1 ? 2 : 4);
});
// act:
next(encoder.encode('id: abc\rdata: def\r'));
// assert:
expect(lineNum).toBe(2);
});
it('single byte array with multiple lines separated by \\r\\n', () => {
// arrange:
let lineNum = 0;
const next = parse.getLines((line, fieldLength) => {
++lineNum;
expect(decoder.decode(line)).toEqual(lineNum === 1 ? 'id: abc' : 'data: def');
expect(fieldLength).toEqual(lineNum === 1 ? 2 : 4);
});
// act:
next(encoder.encode('id: abc\r\ndata: def\r\n'));
// assert:
expect(lineNum).toBe(2);
});
});
- single line :
- 测试最基本的单行数据处理
- 验证能正确解析 "id: abc" 格式的数据
- 确认字段长度(fieldLength)计算正确
- multiple lines :
- 测试多行数据的处理
- 验证能正确处理连续的两行不同数据
- 确认每行的字段长度计算正确
- single line split across multiple arrays :
- 测试跨数据块的单行处理
- 验证当一行数据被分割成多个数据块时能正确组合
- 例如:"id: a" 和 "bc\n" 能被正确组合成 "id: abc"
- multiple lines split across multiple arrays :
- 测试跨数据块的多行处理
- 验证复杂场景:数据既跨行又跨数据块
- 确保行的拼接和分割都正确
- new line :
- 测试空行处理
- 验证对单个换行符的处理
- 确认空行的 fieldLength 为 -1
- comment line :
- 测试注释行处理(以冒号开头的行)
- 验证注释行的 fieldLength 为 0
- 确保注释内容被正确保留
- line with no field :
- 测试没有字段分隔符(冒号)的行
- 验证这种情况下 fieldLength 为 -1
- 确保原始内容被保留
- line with multiple colons :
- 测试包含多个冒号的行
- 验证只有第一个冒号被用作字段分隔符
- 确认 fieldLength 计算正确
- 换行符处理系列测试 :
- 测试不同换行符的处理:
- \n (Unix 风格)
- \r (旧 Mac 风格)
- \r\n (Windows 风格)
- 确保所有换行符格式都能正确分割行
- 测试不同换行符的处理:
这些测试用例全面覆盖了 getLines 函数的各种使用场景,包括:
- 基本功能
- 边界情况
- 错误处理
- 跨平台兼容性
- 数据分块处理
getMessages
export function getMessages(
onId: (id: string) => void,
onRetry: (retry: number) => void,
onMessage?: (msg: EventSourceMessage) => void
) {
let message = newMessage();
const decoder = new TextDecoder();
// 返回一个可以处理每个传入行缓冲区的函数:
return function onLine(line: Uint8Array, fieldLength: number) {
if (line.length === 0) {
// 空行表示消息的结束。触发回调并开始新消息:
onMessage?.(message);
message = newMessage();
} else if (fieldLength > 0) {
// 排除注释和没有值的行
// 行的格式为 "<field>:<value>" 或 "<field>: <value>"
// 参考:https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
const field = decoder.decode(line.subarray(0, fieldLength));
const valueOffset =
fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
const value = decoder.decode(line.subarray(valueOffset));
switch (field) {
case "data":
// 如果此消息已经有数据,则将新值附加到旧数据后面。
// 否则,只需设置为新值:
message.data = message.data ? message.data + "\n" + value : value;
break;
case "event":
message.event = value;
break;
case "id":
onId((message.id = value));
break;
case "retry":
const retry = parseInt(value, 10);
if (!isNaN(retry)) {
// 根据规范,忽略非整数
onRetry((message.retry = retry));
}
break;
}
}
};
}
- 通过处理传入的行数据,解析出消息的
data、event、id和retry字段,并通过回调函数处理这些字段。当遇到空行时,表示一条消息结束,并调用onMessage回调。
测试用例:
describe('getMessages', () => {
it('happy path', () => {
// arrange:
let msgNum = 0;
const next = parse.getMessages(id => {
expect(id).toEqual('abc');
}, retry => {
expect(retry).toEqual(42);
}, msg => {
++msgNum;
expect(msg).toEqual({
retry: 42,
id: 'abc',
event: 'def',
data: 'ghi'
});
});
// act:
next(encoder.encode('retry: 42'), 5);
next(encoder.encode('id: abc'), 2);
next(encoder.encode('event:def'), 5);
next(encoder.encode('data:ghi'), 4);
next(encoder.encode(''), -1);
// assert:
expect(msgNum).toBe(1);
});
it('skip unknown fields', () => {
let msgNum = 0;
const next = parse.getMessages(id => {
expect(id).toEqual('abc');
}, _retry => {
fail('retry should not be called');
}, msg => {
++msgNum;
expect(msg).toEqual({
id: 'abc',
data: '',
event: '',
retry: undefined,
});
});
// act:
next(encoder.encode('id: abc'), 2);
next(encoder.encode('foo: null'), 3);
next(encoder.encode(''), -1);
// assert:
expect(msgNum).toBe(1);
});
it('ignore non-integer retry', () => {
let msgNum = 0;
const next = parse.getMessages(_id => {
fail('id should not be called');
}, _retry => {
fail('retry should not be called');
}, msg => {
++msgNum;
expect(msg).toEqual({
id: '',
data: '',
event: '',
retry: undefined,
});
});
// act:
next(encoder.encode('retry: def'), 5);
next(encoder.encode(''), -1);
// assert:
expect(msgNum).toBe(1);
});
it('skip comment-only messages', () => {
// arrange:
let msgNum = 0;
const next = parse.getMessages(id => {
expect(id).toEqual('123');
}, _retry => {
fail('retry should not be called');
}, msg => {
++msgNum;
expect(msg).toEqual({
retry: undefined,
id: '123',
event: 'foo ',
data: '',
});
});
// act:
next(encoder.encode('id:123'), 2);
next(encoder.encode(':'), 0);
next(encoder.encode(': '), 0);
next(encoder.encode('event: foo '), 5);
next(encoder.encode(''), -1);
// assert:
expect(msgNum).toBe(1);
});
it('should append data split across multiple lines', () => {
// arrange:
let msgNum = 0;
const next = parse.getMessages(_id => {
fail('id should not be called');
}, _retry => {
fail('retry should not be called');
}, msg => {
++msgNum;
expect(msg).toEqual({
data: 'YHOO\n+2\n\n10',
id: '',
event: '',
retry: undefined,
});
});
// act:
next(encoder.encode('data:YHOO'), 4);
next(encoder.encode('data: +2'), 4);
next(encoder.encode('data'), 4);
next(encoder.encode('data: 10'), 4);
next(encoder.encode(''), -1);
// assert:
expect(msgNum).toBe(1);
});
it('should reset id if sent multiple times', () => {
// arrange:
const expectedIds = ['foo', ''];
let idsIdx = 0;
let msgNum = 0;
const next = parse.getMessages(id => {
expect(id).toEqual(expectedIds[idsIdx]);
++idsIdx;
}, _retry => {
fail('retry should not be called');
}, msg => {
++msgNum;
expect(msg).toEqual({
data: '',
id: '',
event: '',
retry: undefined,
});
});
// act:
next(encoder.encode('id: foo'), 2);
next(encoder.encode('id'), 2);
next(encoder.encode(''), -1);
// assert:
expect(idsIdx).toBe(2);
expect(msgNum).toBe(1);
});
});
- happy path :
- 测试正常流程
- 验证完整的消息处理:包含 retry、id、event 和 data 字段
- 确保所有回调都被正确调用
- 验证最终消息对象的完整性
- skip unknown fields :
- 测试处理未知字段的情况
- 验证未知字段(如 'foo')被正确忽略
- 确保只有已知字段被处理
- 确保 retry 回调不会被错误触发
- ignore non-integer retry :
- 测试非数字的 retry 值处理
- 验证非整数 retry 值被忽略
- 确保相关回调不会被触发
- 验证消息对象保持默认状态
- skip comment-only messages :
- 测试注释行的处理
- 验证注释行(以冒号开头)被正确跳过
- 确保其他有效字段正常处理
- 验证最终消息的正确性
- append data split across multiple lines :
- 测试多行数据的拼接
- 验证多个 data 字段被正确合并
- 确保换行符被正确处理
- 验证最终数据的格式正确性
- reset id if sent multiple times :
- 测试多次发送 id 的情况
- 验证 id 的重置机制
- 确保 id 回调被正确触发
- 验证最终消息状态的正确性
这些测试用例覆盖了 getMessages 的主要功能点:
- 基本消息解析
- 字段验证和处理
- 错误处理
- 特殊情况处理
- 数据拼接
- 状态重置
简单总结:
- 这三个函数实际上是实现了基于
fetch封装的处理SSE(Server-Send Events),处理流式的数据,进行接收分段、解析、组装