Dlaczego zbudowaliśmy własny stos HTTP/2 zamiast rozszerzać Indy

· Recenzje

Pytanie, które dostajemy najczęściej

Kiedy publikujemy nową funkcję HTTP/2 w sgcWebSockets — server push, strojenie tablicy dynamicznej HPACK, negocjacja ramki SETTINGS — ktoś grzecznie zadaje to samo pytanie na forum: „Dlaczego po prostu nie rozszerzyliście TIdHTTP z Indy? Już obsługuje TCP, TLS i HTTP/1.1, na pewno HTTP/2 to po prostu kolejny format ramek na wierzchu.”

To rozsądne pytanie. Krótka odpowiedź brzmi: HTTP/2 to nie warstwa, którą można doszczepić do klienta HTTP/1.1 — to fundamentalnie inny transport, który łamie prawie każde założenie, jakie robi Indy. Ten wpis omawia konkretne powody, dla których zbudowaliśmy naszą implementację HTTP/2 od podstaw, czego to wymagało i jak wpłynęło to na resztę sgcWebSockets.

HTTP/1.1 to tekst, HTTP/2 to ramki binarne

Implementacja HTTP Indy, jak każdy klasyczny klient HTTP/1.1, czyta linię żądania zakończoną CRLF, potem nagłówki, potem ciało z content-length lub ciało chunked. To tekst zorientowany liniowo. Transport to jedno żądanie, jedna odpowiedź, potem albo zamknięcie albo pipeline (a nikt nie robi pipeliningu w praktyce).

HTTP/2 to binarny protokół ramkowania. Każdy bajt po preface połączenia należy do ramki: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. Ramki nie są wyrównane z żądaniami — nagłówki pojedynczego żądania mogą rozciągać się na kilka ramek HEADERS / CONTINUATION, ciało może przybyć jako wiele przeplatanych ramek DATA, a ramki z różnych żądań są wymieszane na tym samym gnieździe. Czytnik to nie parser linii, to maszyna stanów nad nagłówkami ramek o stałej długości i ładunkami o zmiennej długości.

Doszczepienie tego na wierzch TIdHTTP.ReadHeaderFromStream oznaczałoby całkowite przepisanie pętli odczytu. W tym momencie nie rozszerzasz Indy — piszesz równoległą implementację, która przypadkiem dzieli klasę gniazda.

Multipleksowanie łamie założenie jedno-żądanie-na-połączenie

Model klienta HTTP Indy zbudowany jest wokół pojedynczego żądania w locie. Wywołujesz Get, metoda blokuje, odpowiedź przybywa, przetwarzasz ją. Gniazdo TCP należy do tego wywołania na czas trwania. HTTP/2 odwraca to: pojedyncze połączenie multipleksuje wiele jednoczesnych strumieni, każdy identyfikowany identyfikatorem strumienia. Typowe połączenie Apple Push Notification wysyła tysiące niezależnych żądań POST przez to samo gniazdo TCP, z odpowiedziami przybywającymi poza kolejnością.

Aby to wesprzeć w blokującym modelu Indy, potrzebowałbyś albo (a) jednego klienta Indy na strumień HTTP/2 — co przeczy całemu sensowi HTTP/2 — albo (b) oddzielnego wątku dispatchera, który posiada gniazdo, czyta ramki, wysyła je do kolejek per-strumień i udostępnia API futures/promises wywołującym. Opcja (b) to w zasadzie napisanie nowego klienta HTTP/2 i zaparkowanie Indy pod spodem jako cienki wrapper gniazda. Wybraliśmy zrobić to wprost z najbardziej odpowiednim transportem dla platformy.

HPACK to maszyna stanów, nie string

Nagłówki HTTP/1.1 to tekst nazwa-wartość. Nagłówki HTTP/2 są kompresowane z HPACK (RFC 7541), który łączy tablicę statyczną popularnych nagłówków, tablicę dynamiczną rosnącą wraz z postępem połączenia i literały kodowane Huffmanem. Kodowanie zestawu nagłówków to nie WriteLn(Stream, 'Content-Type: application/json') — to wyszukiwanie w dwóch tablicach, wybór między reprezentacjami indeksowanymi/literalnymi-z-indeksowaniem/literalnymi-bez-indeksowania/literalnymi-nigdy-nieindeksowanymi, opcjonalna kompresja Huffmana i aktualizacja tablicy dynamicznej, którą oba endpointy muszą trzymać zsynchronizowane.

Pomyl rozmiar tablicy, a połączenie umiera z COMPRESSION_ERROR. Zapomnij zastosować aktualizację SETTINGS dla maksymalnego rozmiaru tablicy nagłówków, a wcześniej działający serwer zaczyna odrzucać Twoje żądania. Nie ma skrótu — albo implementujesz HPACK w całości, albo nie masz HTTP/2. Zaimplementowaliśmy tablicę statyczną, tablicę dynamiczną z eksmisją, kodowanie/dekodowanie Huffmana i cztery formy reprezentacji w mniej więcej 1200 linii Pascala. Ten kod nie istnieje nigdzie w Indy i nie miałby tam naturalnego miejsca.

Kontrola przepływu jest per-strumień i per-połączenie

HTTP/2 ma dwa warstwowe okna: okno na poziomie połączenia i jedno na strumień. Oba zaczynają się od 65 535 bajtów i rosną tylko wtedy, gdy peer wysyła ramki WINDOW_UPDATE. Naiwna implementacja, która zapisuje DATA bez sprawdzania okna, zawiesi połączenie po pierwszych 64 KB. Poprawna implementacja potrzebuje pętli zdarzeń, która wstrzymuje pisarzy, gdy okno jest wyczerpane, dzieli duże ciała na kawałki rozmiaru okna i wznawia, gdy przybywają aktualizacje.

Nic z tego nie istnieje w HTTP/1.1 i dlatego nic z tego nie istnieje w Indy. To prawdziwy kawałek pracy, który musi gdzieś żyć — umieściliśmy go w tym samym miejscu co czytnik ramek i kodek HPACK.

Jak wygląda nasza pętla pisarza

Oto mały fragment pisarza ramek, uproszczony dla czytelności. Zwróć uwagę na jawne sprawdzenie kontroli przepływu i pętlę, która dzieli ładunek na kawałki ograniczone oknem:

procedure TsgcHTTP2Stream.WriteData(const aData: TBytes; aEndStream: Boolean);
var
  vOffset, vChunk, vMax: Integer;
  vFlags: Byte;
begin
  vOffset := 0;
  while vOffset < Length(aData) do
  begin
    // Respect both connection and stream flow-control windows.
    vMax := Min(FConnection.SendWindow, Self.SendWindow);
    vMax := Min(vMax, FConnection.PeerSettings.MaxFrameSize);
    if vMax <= 0 then
    begin
      // Block until a WINDOW_UPDATE wakes us.
      FWindowEvent.WaitFor(INFINITE);
      Continue;
    end;
    vChunk := Min(Length(aData) - vOffset, vMax);
    vFlags := 0;
    if aEndStream and ((vOffset + vChunk) = Length(aData)) then
      vFlags := FLAG_END_STREAM;
    FConnection.SendFrame(FRAME_DATA, vFlags, Self.StreamId,
      Copy(aData, vOffset, vChunk));
    Dec(FConnection.SendWindow, vChunk);
    Dec(Self.SendWindow, vChunk);
    Inc(vOffset, vChunk);
  end;
end;

To kilkadziesiąt linii, ale uchwytuje sedno tego, dlaczego HTTP/2 nie może być cienkim dodatkiem. Nie ma odpowiedniej ścieżki kodu w żadnym kliencie HTTP/1.1, ponieważ koncepcja po prostu nie istnieje. Doszczep to na TIdHTTP.Get i od razu odkryjesz, że cała konwencja wywoływania — jedno blokujące wywołanie na żądanie — ma niewłaściwy kształt.

ALPN, negocjacja ustawień i cykl życia połączenia

HTTP/2 nad TLS jest wybierane przez ALPN (Application-Layer Protocol Negotiation), które musi być skonfigurowane na kontekście TLS przed handshake. IO handler OpenSSL Indy przez lata nie udostępniał ALPN — ostatecznie zostało to dodane — ale ALPN to dopiero pierwszy krok. Po handshake TLS klient musi wysłać 24-bajtowy preface połączenia, potem ramkę SETTINGS, potem poczekać na SETTINGS serwera i potwierdzić. Dopiero wtedy połączenie jest gotowe na żądania. SChannel na Windows wymaga innej ścieżki konfiguracji ALPN niż OpenSSL. msquic ma jeszcze inną. Eleganckie abstrahowanie tych rzeczy było łatwiejsze w czystym module niż przeplatane przez istniejący kod handshake TLS Indy.

Wydajność: liczby

Nagrodą za wykonanie całej tej pracy jest mierzalny wynik. W reprezentatywnym obciążeniu — klient Delphi wysyłający milion Apple Push Notifications do APNs (apns2 w trybie HTTP/2) na nowoczesnym serwerze Windows:

Z grubsza 10-krotna poprawa na tym samym sprzęcie i tej samej sieci, bez zmiany logiki aplikacji. To wartość, którą dostarcza multipleksowanie HTTP/2, kiedy budujesz stos natywnie zamiast próbować udawać go równoległymi połączeniami HTTP/1.1.

Co ponownie wykorzystaliśmy z Indy

Nie wszystko zostało przepisane. Używamy OpenSSL TIdSSLIOHandlerSocketOpenSSL z Indy jako jednego z bazowych dostawców TLS (z naszą własną łatką ALPN), TIdTCPClient z Indy dla warstwy surowych gniazd w domyślnym backendzie, a TIdHTTPServer z Indy pozostaje w grze dla fallbacku HTTP/1.1. Warstwa HTTP/2 siedzi na wierzchu, posiadając pętlę odczytu, parser ramek, HPACK, kontrolę przepływu i cykl życia strumienia. Podział pozwala nam dzielić hydraulikę TLS, zachowując jednocześnie semantykę HTTP/2 niezależną od modelu wywołania blokującego Indy.

Lekcje dla innych protokołów

Ten sam wzorzec powtarza się w całym sgcWebSockets. WebSocket to binarny protokół ramkowania doszczepiony na upgrade HTTP — napisaliśmy framer od podstaw. MQTT 5 ma własne kodowanie zmiennej długości int, tablicę właściwości i kontrolę przepływu — napisane od podstaw. AMQP 1.0 ma pełny system typów i przepływ kredytów linku — napisane od podstaw. HTTP/3 jest nad QUIC, a nie TCP — owijamy msquic / ngtcp2. Wspólnym wątkiem jest to, że każdy nowoczesny protokół ma własną maszynę stanów, a udawanie, że to „po prostu inny tryb HTTP/1.1”, prowadzi do kruchego, wolnego kodu.

Podsumowanie

Rozszerzanie Indy to oczywisty pierwszy instynkt — to biblioteka, której każdy deweloper Delphi już ufa. Ale HTTP/2 to nie rozszerzenie HTTP/1.1, to redesign. Multipleksowanie ramek, HPACK, kontrola przepływu i odpowiedzi poza kolejnością wymagają infrastruktury, która po prostu nie istnieje w blokującym kliencie HTTP/1.1. Zbudowanie tego natywnie kosztowało prawdziwy wysiłek inżynieryjny, ale to jedyny sposób, by dostarczyć obiecaną przez HTTP/2 wydajność o rząd wielkości większą — i wyznaczyło wzorzec dla każdego innego nowoczesnego protokołu, który sgcWebSockets dostarczył od tego czasu.