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

Jake Archibald
Jake Archibald

W Chromium 105 możesz za pomocą Streams API wysyłać żądania, zanim cała treść Twojej witryny będzie dostępna.

Można to wykorzystać do:

  • Rozgrzej serwer. Innymi słowy, możesz rozpocząć wysyłanie żądania, gdy użytkownik zaznaczy pole do wprowadzania tekstu i ukryć wszystkie nagłówki, a następnie poczekać, aż użytkownik naciśnie „Wyślij”. przed wysłaniem wprowadzonych danych.
  • Stopniowo wysyłaj dane wygenerowane po stronie klienta, na przykład dane audio, wideo czy dane wejściowe.
  • Odtwórz gniazda sieciowe przez HTTP/2 lub HTTP/3.

Jest to jednak funkcja platformy internetowej niskiego poziomu, więc nie ograniczaj się do moich pomysłów. To może być ciekawszy przypadek użycia dla żądań strumieniowania.

Prezentacja

Pokazuje, jak można strumieniować dane od użytkownika na serwer, a potem wysyłać z powrotem dane, które można przetworzyć w czasie rzeczywistym.

To nie jest najbardziej pomysłowy przykład, ale zależy mi na tym, żeby było proste.

Jak to działa?

Wcześniej w ekscytującej przygodzie ze strumieniami pobierania

Strumienie odpowiedzi są już od pewnego czasu dostępne we wszystkich nowoczesnych przeglądarkach. Dzięki nim masz dostęp do fragmentów odpowiedzi, które pochodzą 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 typu value to Uint8Array bajtów. Liczba i rozmiar tablic zależą od szybkości sieci. Jeśli używasz szybkiego połączenia, otrzymasz mniej, ale większe fragmenty. danych. Jeśli używasz wolnego połączenia, otrzymasz więcej, w mniejszych fragmentach.

Jeśli chcesz przekonwertować bajty na tekst, możesz użyć strumienia TextDecoder lub nowszego strumienia przekształcenia, jeśli Twoje docelowe przeglądarki go obsługują:

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

TextDecoderStream to strumień przekształcenia, który pobiera wszystkie te fragmenty (Uint8Array) i przekształca je w ciągi tekstowe.

Strumienie są bardzo przydatne, ponieważ możesz zacząć podejmować działania na podstawie nowych danych. Na przykład w przypadku listy 100 „wyników” możesz wyświetlić pierwszy wynik od razu po jego otrzymaniu, zamiast czekać, aż pojawi się ich cała liczba.

Tak czy inaczej, to strumienie odpowiedzi. Nową rzeczą, o której chciałem wspomnieć, są strumienie żądań.

Treści żądań strumieniowania

Żądania mogą mieć treść:

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

Wcześniej przed wysłaniem żądania trzeba było przygotować całą ciało, ale teraz w Chromium 105 możesz podać własne dane (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',
});

Powyższy kod spowoduje wysłanie komunikatu „To żądanie jest powolne”. do serwera, po jednym słowie po słowie, z jedną sekundową przerwą między każdym słowem.

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

Ograniczenia

Żądania strumieniowania to nowe możliwości internetu, więc wiąże się z nimi kilka ograniczeń:

Połowa dupleksu?

Aby zezwolić na użycie strumieni w żądaniu, opcja duplex musi być ustawiona na 'half'.

Mało znanym elementem protokołu HTTP (jednak to, czy jest to standardowe działanie, zależy od tego, do kogo pytasz), możesz zacząć otrzymywać odpowiedź jeszcze podczas wysyłania żądania. Jest jednak tak mało znana, że nie jest również 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 ją wcześniej. Dotyczy to wszystkich pobierania danych z przeglądarki.

Ten domyślny wzorzec jest nazywany „półdupleksem”. Jednak niektóre implementacje, takie jak fetch w Deno, domyślnie używają trybu „full duplex” w przypadku pobierania strumieniowego, co oznacza, że odpowiedź może stać się dostępna, zanim żądanie zostanie ukończone.

Aby rozwiązać ten problem ze zgodnością, w przeglądarkach duplex: 'half' musi do określania w żądaniach mających treść strumienia.

W przyszłości usługa duplex: 'full' może być obsługiwana w przeglądarkach w przypadku żądań strumieniowania i żądań niestrumieniowych.

Tymczasem najlepszym sposobem na komunikację dwukierunkową jest wykonanie jednego pobierania z żądaniem strumieniowania, a następnie wykonanie kolejnego pobierania w celu otrzymania odpowiedzi na żądanie. Serwer potrzebuje sposobu, w jaki będzie powiązać te 2 żądania, np. identyfikatora w adresie URL. Tak działa demonstracja.

Ograniczone przekierowania

Niektóre formy przekierowania HTTP wymagają, aby przeglądarka ponownie wysłała treść żądania na inny adres URL. Aby to obsłużyć, przeglądarka musi buforować zawartość strumienia, co jest niekorzystne, więc tego nie robi.

Zamiast tego, jeśli żądanie ma treść strumieniową, a odpowiedzią jest 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 CORS jest wymagany, a te żądania zawsze wywołują proces wstępny.

Strumieniowe przesyłanie żądań no-cors jest niedozwolone.

Nie działa w przypadku HTTP/1.x.

W przypadku połączenia HTTP/1.x pobieranie zostanie odrzucone.

Wynika to z tego, że zgodnie z regułami HTTP/1.1 treści żądań i odpowiedzi muszą wysyłać nagłówek Content-Length, aby druga strona wiedziała, ile danych otrzyma, lub zmienić format wiadomości na kodowanie fragmentowe. W przypadku kodowania fragmentami treść filmu jest dzielona na części, z których każda ma własną długość.

Kodowanie fragmentaryczne jest dość powszechne w przypadku odpowiedzi HTTP/1.1, ale bardzo rzadko w przypadku żądań. Z tego względu ryzyko związane ze zgodnością jest zbyt duże.

Potencjalne problemy

To nowa funkcja, która jest obecnie rzadko używana w internecie. Oto kilka kwestii, na które trzeba uważać:

Niezgodność po stronie serwera

Niektóre serwery aplikacji nie obsługują żądań strumieniowania. Zamiast tego czekają na otrzymanie pełnego żądania, zanim wyświetlą się treści, co bywa niekorzystne. Zamiast tego użyj serwera aplikacji, który obsługuje strumieniowanie, np. NodeJS lub Deno.

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

Niezgodność jest poza Twoją kontrolą

Ponieważ ta funkcja działa tylko przez HTTPS, 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 oprogramowanie do ochrony internetu umożliwia monitorowanie wszystkiego, co dzieje się między przeglądarką a siecią. W niektórych przypadkach takie oprogramowanie buforuje treść żądań.

Jeśli chcesz się przed tym uchronić, możesz utworzyć „test funkcji” podobnie jak w prezentacji powyżej, w której próbujesz przesyłać strumieniowo część danych bez zamykania strumienia. Jeśli serwer otrzyma dane, może użyć innego pobrania. Gdy to zrobisz, będziesz wiedzieć, że klient w pełni obsługuje żądania strumieniowania.

Wykrywanie cech

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 ciekawi Cię, jak działa wykrywanie funkcji:

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

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

Użycie w strumieniach z możliwością zapisu

Czasami łatwiej jest pracować ze strumieniami, gdy masz WritableStream. Możesz to zrobić za pomocą „tożsamości” , czyli możliwą do odczytania i zapisy parę, która przenosi wszystko, co jest przekazywane do zapisu, i wysyła to do czytelnego końca. Można to zrobić, tworząc element TransformStream bez żadnych 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. Dzięki temu będzie można tworzyć strumienie razem. Oto śmieszny przykład, w którym dane są pobierane z jednego adresu URL, skompresowane i wysyłane na inny adres:

// 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 przykładzie powyżej użyto strumieni kompresji do skompresowania dowolnych danych za pomocą programu gzip.