从 Chromium 105 开始,您可以使用 Streams API 在提供整个正文之前发起请求。
您可以利用这一点:
- 预热服务器。 换言之,您可以在用户聚焦某个文本输入字段后启动请求,将所有标头移开,然后等待用户按“发送”后再发送输入的数据。
- 逐步发送客户端上生成的数据,例如音频、视频或输入数据。
- 通过 HTTP/2 或 HTTP/3 重新创建 Web 套接字。
不过,由于这是一项底层网络平台功能,因此请不要受限于我的想法。 或许,您可以想到一个更精彩的“请求流式传输”用例。
演示
该图展示了如何将数据从用户流式传输到服务器,以及如何发回可实时处理的数据。
是的,这并不是最有想象力的示例,我只是想简单地说明一下,好吗?
无论如何,这是如何做到的?
以前是参加过提取直播的精彩冒险
响应流现已在所有现代浏览器中推出一段时间。利用它们,您可以在响应从服务器到达时访问部分内容:
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const {value, done} = await reader.read();
if (done) break;
console.log('Received', value);
}
console.log('Response fully received');
每个 value
都是 Uint8Array
字节。您获取的数组数量和数组的大小取决于网络的速度。如果您使用的是较快的网络连接,则您收到的数据块会更少、更大。
如果您的网络连接速度较慢,则会上传更多小数据块。
如果您想将字节转换为文本,可以使用 TextDecoder
;如果您的目标浏览器支持新版转换流,则可以使用:
const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
TextDecoderStream
是一个转换流,用于获取所有这些 Uint8Array
区块并将其转换为字符串。
视频流是非常棒的,因为您可以在收到数据时立即采取措施。例如,如果您收到包含 100 条“结果”的列表,则可以在收到第一条结果后立即将其显示出来,而不是等待 100 条结果全部完成后再显示。
总而言之,这就是响应流,我想介绍一项令人兴奋的新功能,就是请求流。
流式传输请求正文
请求可以包含正文:
await fetch(url, {
method: 'POST',
body: requestBody,
});
以前,您需要在启动请求之前准备好整个正文,但现在在 Chromium 105 中,您可以提供自己的 ReadableStream
数据:
function wait(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
const stream = new ReadableStream({
async start(controller) {
await wait(1000);
controller.enqueue('This ');
await wait(1000);
controller.enqueue('is ');
await wait(1000);
controller.enqueue('a ');
await wait(1000);
controller.enqueue('slow ');
await wait(1000);
controller.enqueue('request.');
controller.close();
},
}).pipeThrough(new TextEncoderStream());
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: stream,
duplex: 'half',
});
以上代码会向服务器发送“这是一个缓慢的请求”,一次一个单词,每个单词之间停顿一秒。
请求正文的每个分块都必须是 Uint8Array
字节,因此我使用 pipeThrough(new TextEncoderStream())
进行转换。
限制
流式传输请求是网络的新驱动力,因此存在一些限制:
半双工?
若要允许在请求中使用视频流,需要将 duplex
请求选项设置为 'half'
。
HTTP 有一个鲜为人知的特性(不过,这是否是标准行为取决于您的请求者):您可以在发送请求时开始接收响应。但是,它鲜为人知,没有得到服务器很好地支持,也没有受到任何浏览器的支持。
在浏览器中,只有在请求正文完全发送后,系统才会提供响应,即使服务器提早发送响应也是如此。 这适用于所有浏览器提取。
这种默认模式称为“半双工”。不过,某些实现(例如 Deno 中的 fetch
)对于流式提取,默认为“全双工”,这意味着响应可以在请求完成之前获得。
因此,为了解决此兼容性问题,在浏览器中,需要在具有流正文的请求上指定 duplex: 'half'
。
将来,浏览器可能支持对流式传输和非流式传输请求使用 duplex: 'full'
。
在此期间,双工通信的下一个最佳做法是使用流式传输请求进行一次提取,然后再次执行提取以接收流式传输响应。服务器需要通过某种方式来关联这两个请求,比如网址中的 ID。这就是演示的运作方式。
重定向受限
某些形式的 HTTP 重定向要求浏览器将请求的正文重新发送到另一个网址。为了支持这一点,浏览器必须缓冲流的内容,这在某种程度上会使该点失败,因此它不会这样做。
相反,如果请求具有流式传输正文,并且响应是 303 以外的 HTTP 重定向,则抓取将拒绝,并且不会遵循重定向。
系统允许使用 303 重定向,因为它们会将该方法明确更改为 GET
并舍弃请求正文。
需要 CORS 并触发预检
流式传输请求具有正文,但没有 Content-Length
标头。这是一种新型请求,所以需要使用 CORS,并且这些请求总是会触发预检。
不允许流式传输 no-cors
请求。
不适用于 HTTP/1.x
如果连接为 HTTP/1.x,抓取将被拒绝。
这是因为根据 HTTP/1.1 规则,请求和响应正文需要发送 Content-Length
标头,让对方知道自己将收到多少数据,或者将消息的格式更改为使用“分块编码”。使用分块编码时,正文会拆分为多个部分,每个部分都有自己的内容长度。
在处理 HTTP/1.1 响应时,分块编码很常见,但在处理请求时很少见,因此存在兼容性风险太大。
潜在问题
这是一项新功能,目前互联网上的使用率不高。 请留意以下几个问题:
服务器端不兼容
有些应用服务器不支持流式传输请求,而是在等待收到完整请求之后再显示任何请求,这有点违背了这一点。请改用支持流式传输的应用服务器,例如 NodeJS 或 Deno。
但是,你还没有在森林中大显身手! 应用服务器(例如 NodeJS)通常位于另一个服务器(通常称为“前端服务器”)后面,而后者可能位于 CDN 之后。如果其中任何一方决定先缓冲请求,然后再将其提供给链中的下一个服务器,则您将无法享受请求流式传输的优势。
在您的控制范围之外不兼容
由于此功能只能通过 HTTPS 发挥作用,因此您无需担心您与用户之间的代理,但是用户可能会在他们的计算机上运行代理。 某些互联网保护软件允许其监控浏览器与网络之间的一切活动,并且在某些情况下,此类软件会缓冲请求正文。
如果您想防范这种行为,可以创建类似于上述演示的“功能测试”,在其中尝试流式传输一些数据,而无需关闭数据流。 如果服务器收到数据,可通过其他提取方式做出响应。发生这种情况后,您就会知道客户端支持端到端的流式传输请求。
功能检测
const supportsRequestStreams = (() => {
let duplexAccessed = false;
const hasContentType = new Request('', {
body: new ReadableStream(),
method: 'POST',
get duplex() {
duplexAccessed = true;
return 'half';
},
}).headers.has('Content-Type');
return duplexAccessed && !hasContentType;
})();
if (supportsRequestStreams) {
// …
} else {
// …
}
想要了解一下,该功能检测的工作原理如下:
如果浏览器不支持特定的 body
类型,则会对该对象调用 toString()
并将结果用作正文。因此,如果浏览器不支持请求流,则请求正文会变为字符串 "[object ReadableStream]"
。
将字符串用作正文时,您可以方便地将 Content-Type
标头设置为 text/plain;charset=UTF-8
。因此,如果设置了该标头,我们就知道浏览器不支持请求对象中的视频流,我们可以提前退出。
Safari 支持请求对象中的视频流,但不允许将其与 fetch
搭配使用,因此测试了 duplex
选项,但 Safari 目前不支持该选项。
与可写流搭配使用
有时,使用 WritableStream
会更容易处理流。为此,您可以使用“身份”流,该流是一种可读/可写对,可接受传递到其可写端的任何内容,并将其发送到可读端。您可以通过创建不带任何参数的 TransformStream
来创建上述两种方法:
const {readable, writable} = new TransformStream();
const responsePromise = fetch(url, {
method: 'POST',
body: readable,
});
现在,您发送到可写流的任何内容都会成为请求的一部分。这样,您就可以共同编写数据流。 例如,下面是一个蠢萌的示例,其中数据从一个网址提取,经过压缩,然后发送到另一个网址:
// Get from url1:
const response = await fetch(url1);
const {readable, writable} = new TransformStream();
// Compress the data from url1:
response.body.pipeThrough(new CompressionStream('gzip')).pipeTo(writable);
// Post to url2:
await fetch(url2, {
method: 'POST',
body: readable,
});
上述示例使用了压缩流来通过 gzip 压缩任意数据。