使用提取 API 流式传输请求

杰克·阿奇博尔德
Jake Archibald

从 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 响应时,分块编码很常见,但在处理请求时很少见,因此存在兼容性风险太大。

潜在问题

这是一项新功能,目前互联网上的使用率不高。 请留意以下几个问题:

服务器端不兼容

有些应用服务器不支持流式传输请求,而是在等待收到完整请求之后再显示任何请求,这有点违背了这一点。请改用支持流式传输的应用服务器,例如 NodeJSDeno

但是,你还没有在森林中大显身手! 应用服务器(例如 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 压缩任意数据。