Konrad Kowalski (rootsher)Principal Platform & Reliability Architect000100010011110011110011010111001000110111101001

HTTP Streaming: jedna odpowiedź, wiele fragmentów danych

data
kategoria
Backend
także w
Frontend · Networking
czytanie
2 min / 344 słów

Long polling pozwala serwerowi opóźnić odpowiedź.

Ale kiedy event już się pojawi, response się kończy:

text
request
<- event
EOF

Następny event wymaga następnego requestu.

HTTP nie wymaga jednak, aby całe body było dostępne przed rozpoczęciem odpowiedzi.

Response może powstawać w czasie

Model może wyglądać tak:

text
client -> request

client <- headers
client <- bytes
client <- bytes
client <- bytes
client <- EOF

Najważniejsza zmiana:

text
response received

nie jest pojedynczym momentem.

Headers mogą zostać odebrane, gdy body nadal jest produkowane.

Fetch wystawia strumień

js
const response = await fetch("/events");

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();

  if (done) break;

  const chunk = decoder.decode(value, {
    stream: true
  });

  process(chunk);
}

response.body jest ReadableStream.

To pozwala aplikacji konsumować dane bez czekania na zakończenie response.

Chunk nie jest wiadomością

To jedna z najważniejszych rzeczy w streamingu.

Jeżeli serwer logicznie wysłał:

json
{"id":42,"status":"completed"}

klient może otrzymać bytes jako:

text
{"id":42,

a potem:

text
"status":"completed"}

albo kilka logicznych wiadomości w jednym odczycie.

Granice chunków transportowych nie są granicami rekordów aplikacyjnych.

Dlatego potrzebujemy framingu.

NDJSON

Jednym z prostych formatów jest Newline Delimited JSON:

text
{"id":1,"status":"processing"}
{"id":2,"status":"processing"}
{"id":1,"status":"completed"}

Każdy rekord kończy newline.

Klient buforuje dane do momentu znalezienia separatora:

text
bytes
  |
  v
buffer
  |
  v
newline found
  |
  v
parse one JSON object

Alternatywą może być length-prefix framing, własny format binarny albo inny protokół aplikacyjny.

HTTP streaming daje bytes.

Znaczenie bytes należy do nas.

Streaming nie oznacza HTTP/1.1 chunked encoding

Historycznie streaming HTTP/1.1 często kojarzył się z:

http
Transfer-Encoding: chunked

Ale to szczegół framingu HTTP/1.1.

W HTTP/2 i HTTP/3 body również może być dostarczane stopniowo, mimo że protokół nie używa tego samego mechanizmu Transfer-Encoding: chunked.

Dlatego poprawny model to:

text
streaming HTTP response

a nie:

text
chunked encoding = streaming

Backpressure

Streaming tworzy nowy problem.

Co jeśli backend produkuje dane szybciej niż klient je przetwarza?

text
producer >>> consumer

Bufor nie może rosnąć bez końca.

Streams mają pojęcie backpressure: wolniejszy konsument powinien wpływać na tempo, w jakim dane są czytane i propagowane przez pipeline.

Ale na poziomie całego systemu nadal trzeba zdecydować:

  • buforować,
  • spowolnić producenta,
  • odrzucać dane,
  • zamknąć połączenie.

To nie jest detal implementacyjny. Dla realtime może być częścią semantyki produktu.

Gdzie używamy raw HTTP streaming?

Dobre przypadki to:

  • token streaming z modelu językowego,
  • log output,
  • progres długiego procesu,
  • duże wyniki produkowane inkrementalnie,
  • NDJSON feeds.

Jeżeli komunikacja jest po prostu:

text
one request
-> incremental response
-> done

nie potrzebujemy WebSocketa.

Czego nam nadal brakuje?

Mamy trwały response i możemy wysyłać kolejne rekordy.

Ale sami musimy ustalić:

text
How is an event framed?
Does it have an ID?
How does reconnect work?
How does the client resume?

Dla jednokierunkowego strumienia z serwera do przeglądarki platforma webowa ma gotową odpowiedź.

Server-Sent Events nie tworzy nowego transportu.

Standaryzuje event stream nad HTTP.