Strumieniowe przesyłanie żądań za pomocą interfejsu API pobierania

Jake Archibald
Jake Archibald

W Chromium w wersji 105 możesz za pomocą interfejsu Streams API wysłać żądanie, zanim cała jego treść będzie dostępna.

Może Ci to pomóc:

  • Ogrzej serwer. Innymi słowy, możesz uruchomić żądanie, gdy użytkownik zaznaczy pole do wprowadzania tekstu, i usunąć wszystkie nagłówki, a następnie poczekać, aż użytkownik kliknie „Wyślij”, zanim wyślesz wpisane dane.
  • Stopniowo wysyłaj dane wygenerowane przez klienta, takie jak dźwięk, obraz czy dane wejściowe.
  • Utwórz ponownie gniazda sieciowe przez HTTP/2 lub HTTP/3.

Jednak jest to funkcja platformy internetowej niskopoziomowej, więc nie ograniczaj się do moich pomysłów. Może warto pomyśleć o znacznie ciekawszym przypadku użycia do przesyłania żądań strumieniowania?

Demonstracyjny

Pokazuje on, jak można strumieniować dane użytkownika na serwer oraz wysyłać je z powrotem, które można przetwarzać w czasie rzeczywistym.

Ten przykład nie jest zbyt ekscytujący, miał być tylko dla mnie prosty, dobrze?

Jak to działa?

Dawniej podczas ekscytujących przygód streamingu

Strumienie odpowiedzi są już od jakiegoś czasu dostępne we wszystkich nowoczesnych przeglądarkach. Umożliwiają one dostęp do tych fragmentów odpowiedzi wysyłanych z serwera:

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');

Każdy element value to Uint8Array bajtów. Liczba wyświetlanych tablic i ich rozmiar zależą od szybkości sieci. Jeśli korzystasz z szybkiego połączenia, będziesz przesyłać mniej, ale większe porcje danych. Jeśli masz wolne połączenie, będziesz otrzymywać więcej mniejszych fragmentów.

Jeśli chcesz przekonwertować bajty na tekst, możesz użyć narzędzia TextDecoder lub nowszego strumienia przekształceń (jeśli obsługują je przeglądarki docelowe):

const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

TextDecoderStream to strumień przekształcenia, który przechwytuje wszystkie fragmenty Uint8Array i przekształca je w ciągi.

Strumień to świetna sprawa, ponieważ można zacząć działać na podstawie otrzymanych danych. Na przykład jeśli wyświetla się lista 100 wyników, można wyświetlić pierwszy wynik natychmiast, zamiast czekać na wszystkie 100.

Tak czy owak, chodzi o strumienie odpowiedzi. Chciałam właśnie opowiedzieć o strumieniach odpowiedzi.

Treść żądań strumieniowania

Żądania mogą mieć treść:

await fetch(url, {
  method: 'POST',
  body: requestBody,
});

Wcześniej przed wysłaniem żądania trzeba było przygotować całą treść żądania, ale teraz w Chromium 105 można dostarczać własne ReadableStream danych:

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

Powyższe spowoduje wysłanie do serwera komunikatu „To jest wolne żądanie” – słowo po drugim, przy czym nastąpi 1-sekundowa przerwa między każdym wyrazem.

Każdy fragment treści żądania musi mieć Uint8Array bajtów, więc do konwersji używam pipeThrough(new TextEncoderStream()).

Ograniczenia

Żądania strumieniowania to nowa funkcja dostępna w internecie, więc podlega kilku ograniczeniom:

Pół dupleks?

Aby można było używać strumieni w żądaniu, opcja żądania duplex musi być ustawiona na 'half'.

Mało znana cecha protokołu HTTP (chociaż to, czy jest to standardowe działanie, zależy od osoby, której pytasz), polega na tym, że możesz zacząć otrzymywać odpowiedź jeszcze w trakcie wysyłania żądania. Jest jednak tak mało znana, że nie jest dobrze obsługiwana przez serwery i nie jest obsługiwana przez żadną przeglądarkę.

W przeglądarkach odpowiedź nigdy nie staje się dostępna, dopóki treść żądania nie zostanie w pełni wysłana, nawet jeśli serwer wyśle odpowiedź wcześniej. Dotyczy to wszystkich plików pobieranych przez przeglądarkę.

Ten domyślny wzorzec jest znany jako „pół dupleksu”. Niektóre implementacje, takie jak fetch w języku Deno, domyślnie przy pobieraniu strumieniowym mają ustawiony tryb „full duplex” (pełny dupleks). Oznacza to, że odpowiedź może być dostępna, zanim żądanie zostanie ukończone.

Aby obejść ten problem ze zgodnością, w przeglądarkach w przypadku żądań ze strumieniem trzeba określić parametr duplex: 'half'.

W przyszłości duplex: 'full' może być obsługiwany w przeglądarkach w przypadku żądań strumieniowania i nie-strumieniowych.

Tymczasem następną najlepszą rzeczą w przypadku komunikacji dwustronnej jest wykonanie jednego pobierania za pomocą żądania strumieniowego, a następnie kolejnego, aby otrzymać odpowiedź w ramach przesyłania strumieniowego. Serwer będzie potrzebować sposobu na powiązanie tych dwóch żądań, np. identyfikatora w adresie URL. Tak działa wersja demonstracyjna.

Ograniczone przekierowania

Niektóre formy przekierowań HTTP wymagają, aby przeglądarka ponownie wysłała treść żądania pod inny adres URL. W tym celu przeglądarka musi buforować zawartość strumienia, co w ogóle nie sprawdza się w tym przypadku.

Jeśli natomiast żądanie ma treść strumieniową, a odpowiedź to przekierowanie HTTP inne niż 303, pobieranie zostanie odrzucone, a przekierowanie nie będzie śledzone.

Przekierowania 303 są dozwolone, ponieważ jawnie zmieniają metodę na GET i odrzucają treść żądania.

Wymaga CORS i aktywuje proces wstępny

Żądania strumieniowania mają treść, ale nie mają nagłówka Content-Length. To nowy rodzaj żądania, więc wymagany jest CORS, który zawsze wywołuje proces wstępny.

Żądania strumieniowego przesyłania danych no-cors są niedozwolone.

Nie działa w protokole HTTP/1.x

Pobieranie będzie odrzucane, jeśli połączenie będzie wyglądać tak: HTTP/1.x.

Wynika to z faktu, że zgodnie z regułami HTTP/1.1 treść żądania i odpowiedzi musi wysyłać nagłówek Content-Length, aby druga strona wiedziała, ile danych otrzyma, lub zmienić format wiadomości na fragmenty kodu. Przy fragmentowym kodowaniu treść jest dzielona na części, z których każda ma inną długość.

Podzielone na fragmenty kodowanie jest dość powszechne w przypadku odpowiedzi HTTP/1.1, ale bardzo rzadko w przypadku żądań. Takie ryzyko związane z kompatybilnością jest zbyt powszechne.

Potencjalne problemy

To nowa funkcja, która jest dziś niedostatecznie wykorzystywana w internecie. Oto kilka kwestii, na które należy uważać:

Niezgodność po stronie serwera

Niektóre serwery aplikacji nie obsługują żądań strumieniowania i zamiast tego oczekują na otrzymanie pełnego żądania, zanim zobaczą jakiekolwiek żądania. W ten sposób przesądzono o problemie. Zamiast tego skorzystaj z serwera aplikacji, który obsługuje strumieniowanie, takiego jak NodeJS lub Deno.

Ale jeszcze nie wychodzisz z lasu! Serwer aplikacji, taki jak NodeJS, znajduje się zwykle za innym serwerem, często nazywanym „serwerem frontowym”, który z kolei może znajdować się za siecią CDN. Jeśli którykolwiek z tych podmiotów zdecyduje się zbuforować żądanie przed przekazaniem go do następnego serwera w łańcuchu, stracisz korzyści ze strumieniowego przesyłania żądań.

Niezgodność poza Twoją kontrolą

Ta funkcja działa tylko przez HTTPS, więc nie musisz się martwić o serwery proxy między Tobą a użytkownikiem, ale użytkownik może korzystać z serwera proxy na swoim komputerze. Niektóre programy do ochrony internetu umożliwiają mu monitorowanie wszystkiego, co dzieje się między przeglądarką a siecią. Może się zdarzyć, że oprogramowanie buforuje treść żądań.

Jeśli chcesz się przed tym uchronić, możesz utworzyć „test funkcji” podobny do powyższej prezentacji, w ramach którego próbujesz przesyłać strumieniowo niektóre dane bez zamykania strumienia. Jeśli serwer otrzyma dane, może odpowiedzieć, używając innego pobierania. Dzięki temu wiesz, że klient w pełni obsługuje żądania strumieniowego przesyłania danych.

Wykrywanie funkcji

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 {
  // …
}

Jeśli Cię to ciekawi, oto jak działa wykrywanie funkcji:

Jeśli przeglądarka nie obsługuje określonego typu body, wywołuje w obiekcie toString() i używa wyniku jako treści. Jeśli więc przeglądarka nie obsługuje strumieni żądań, treść żądania będzie ciągiem znaków "[object ReadableStream]". W przypadku użycia ciągu znaków jako treści można w wygodny sposób ustawić nagłówek Content-Type na text/plain;charset=UTF-8. Jeśli więc ustawisz nagłówek, wiemy, że przeglądarka nie obsługuje strumieni w obiektach żądań i możemy wyjść wcześniej.

Safari obsługuje strumienie w obiektach żądań, ale nie zezwala na ich używanie z fetch, więc testowana jest opcja duplex, której Safari obecnie nie obsługuje.

Korzystanie ze strumieniami z możliwością zapisu

Czasami łatwiej jest pracować nad transmisjami, jeśli masz WritableStream. Możesz to zrobić za pomocą strumienia „tożsamości”, czyli pary czytelnej i możliwej do zapisu, która pobiera wszystkie dane przekazywane do końca możliwego do zapisu i wysyła je na czytelny koniec. Możesz to zrobić, tworząc TransformStream bez argumentów:

const {readable, writable} = new TransformStream();

const responsePromise = fetch(url, {
  method: 'POST',
  body: readable,
});

Teraz wszystko, co wyślesz do strumienia z możliwością zapisu, będzie częścią żądania. Umożliwia to wspólne tworzenie strumieni. Oto zabawny przykład, w którym dane są pobierane z jednego adresu URL, skompresowane i przesyłane pod inny adres URL:

// 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,
});

W powyższym przykładzie użyto strumieni kompresji do skompresowania dowolnych danych za pomocą narzędzia gzip.