使用提取 API 流式传输请求

Jake Archibald
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',
});

上面的代码会发送“此请求速度很慢”每次发送一个字词,并在每个字词之间暂停 1 秒。

请求正文的每个分块都必须是字节 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 压缩任意数据。