使用提取 API 流式传输请求

Jake Archibald
Jake Archibald

从 Chromium 105 开始,您可以使用 Streams API 在获得整个正文之前启动请求。

您可以使用此功能执行以下操作:

  • 预热服务器。 换句话说,您可以在用户聚焦文本输入字段时开始请求,并清除所有标题,然后等待用户按下“发送”按钮,再发送他们输入的数据。
  • 逐步发送在客户端上生成的数据,例如音频、视频或输入数据。
  • 通过 HTTP/2 或 HTTP/3 重新创建 Web 套接字。

不过,由于这是一项低级 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',
});

上述命令会向服务器发送“This is a slow request”,每次发送一个字,每个字之间暂停一秒。

请求正文的每个块都需要是 Uint8Array 字节,因此我使用 pipeThrough(new TextEncoderStream()) 来进行转换。

限制

流式传输请求是 Web 的一项新功能,因此存在一些限制:

半双工?

如需允许在请求中使用流,需要将 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 压缩任意数据。