Warum wir einen eigenen HTTP/2-Stack gebaut haben statt Indy zu erweitern

· Reviews

Die Frage, die uns am häufigsten gestellt wird

Sobald wir ein neues HTTP/2-Feature in sgcWebSockets veröffentlichen — Server Push, HPACK-Dynamic-Table-Tuning, SETTINGS-Frame-Aushandlung — stellt jemand höflich dieselbe Frage im Forum: „Warum habt ihr nicht einfach Indys TIdHTTP erweitert? Es macht doch schon TCP, TLS und HTTP/1.1; HTTP/2 ist sicher nur ein weiteres Framing-Format obendrauf.“

Eine vernünftige Frage. Die kurze Antwort lautet: HTTP/2 ist keine Schicht, die du auf einen HTTP/1.1-Client aufpfropfen kannst — es ist ein fundamental anderer Transport, der fast jede Annahme von Indy bricht. Dieser Beitrag geht durch die konkreten Gründe, warum wir unsere HTTP/2-Implementierung von Grund auf gebaut haben, was das gekostet hat und wie es den Rest von sgcWebSockets geprägt hat.

HTTP/1.1 ist Text, HTTP/2 sind binäre Frames

Die HTTP-Implementierung von Indy liest, wie jeder klassische HTTP/1.1-Client, eine CRLF-terminierte Request-Zeile, dann Header, dann entweder einen Content-Length- oder einen Chunked-Body. Es ist zeilenorientierter Text. Der Transport ist: ein Request, eine Response, dann entweder Close oder Pipeline (und Pipelining nutzt in der Praxis niemand).

HTTP/2 ist ein binäres Framing-Protokoll. Jedes Byte nach dem Connection Preface gehört zu einem Frame: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. Frames sind nicht mit Requests ausgerichtet — die Header eines einzelnen Requests können sich über mehrere HEADERS-/CONTINUATION-Frames erstrecken, der Body kann als viele verschachtelte DATA-Frames eintreffen, und Frames verschiedener Requests laufen auf demselben Socket gemischt. Der Reader ist kein Zeilen-Parser, sondern eine State-Machine über Frame-Header fester Länge und Payloads variabler Länge.

Das auf TIdHTTP.ReadHeaderFromStream nachzurüsten hieße, die Read-Schleife komplett neu zu schreiben. Spätestens dann erweiterst du Indy nicht mehr — du schreibst eine parallele Implementierung, die zufällig dieselbe Socket-Klasse teilt.

Multiplexing bricht die Ein-Request-pro-Verbindung-Annahme

Indys HTTP-Client-Modell ist um einen einzelnen laufenden Request aufgebaut. Du rufst Get auf, die Methode blockiert, die Antwort kommt, du verarbeitest sie. Der TCP-Socket gehört für die Dauer diesem Aufruf. HTTP/2 dreht das um: Eine einzige Verbindung multiplext viele parallele Streams, jeder durch eine Stream-ID identifiziert. Eine typische Apple-Push-Notification-Verbindung sendet Tausende unabhängige POST-Requests über denselben TCP-Socket, mit Antworten in beliebiger Reihenfolge.

Um das mit Indys blockierendem Modell zu unterstützen, bräuchtest du entweder (a) einen Indy-Client pro HTTP/2-Stream — was den ganzen Sinn von HTTP/2 zunichtemacht — oder (b) einen separaten Dispatcher-Thread, der den Socket besitzt, Frames liest, sie auf Per-Stream-Queues verteilt und Aufrufern eine Futures-/Promises-API freigibt. Option (b) ist im Grunde, einen neuen HTTP/2-Client zu schreiben und Indy als dünnen Socket-Wrapper darunter zu parken. Wir haben das stattdessen explizit mit dem für die Plattform am besten passenden Transport gemacht.

HPACK ist eine State-Machine, kein String

HTTP/1.1-Header sind Name-Wert-Text. HTTP/2-Header werden mit HPACK (RFC 7541) komprimiert — eine Kombination aus statischer Tabelle gebräuchlicher Header, dynamischer Tabelle, die mit der Verbindung wächst, und Huffman-kodierten Literalen. Eine Header-Menge zu kodieren ist nicht WriteLn(Stream, 'Content-Type: application/json') — es ist ein Lookup gegen zwei Tabellen, eine Wahl zwischen Indexed-/Literal-with-Indexing-/Literal-without-Indexing-/Literal-Never-Indexed-Darstellungen, optionale Huffman-Kompression und ein Update der dynamischen Tabelle, das beide Endpunkte synchron halten müssen.

Stimmt die Tabellengröße nicht, stirbt die Verbindung mit COMPRESSION_ERROR. Vergisst du, ein SETTINGS-Update für die maximale Header-Tabellengröße anzuwenden, lehnt ein bisher funktionierender Server deine Requests plötzlich ab. Es gibt keine Abkürzung — entweder du implementierst HPACK vollständig oder du hast kein HTTP/2. Wir haben die statische Tabelle, die dynamische Tabelle mit Eviction, Huffman-Encode/-Decode und die vier Darstellungsformen in etwa 1.200 Zeilen Pascal implementiert. Dieser Code existiert nirgendwo in Indy und hätte dort auch keinen natürlichen Platz gehabt.

Flow Control ist pro Stream und pro Verbindung

HTTP/2 hat zwei geschichtete Fenster: ein Verbindungs-Fenster und eines pro Stream. Beide starten bei 65.535 Bytes und wachsen nur, wenn der Peer WINDOW_UPDATE-Frames sendet. Eine naive Implementierung, die DATA schreibt ohne das Fenster zu prüfen, hängt die Verbindung nach den ersten 64 KB. Eine korrekte Implementierung braucht eine Event-Loop, die Writer pausiert, wenn das Fenster erschöpft ist, große Bodies in fenstergroße Chunks teilt und fortsetzt, wenn Updates eintreffen.

All das existiert nicht in HTTP/1.1 und damit auch nicht in Indy. Es ist echte Arbeit, die irgendwo leben muss — wir haben sie an dieselbe Stelle gepackt wie den Frame-Reader und den HPACK-Codec.

Wie unsere Writer-Schleife aussieht

Hier ein kleiner Ausschnitt aus dem Frame-Writer, vereinfacht für die Lesbarkeit. Achte auf die explizite Flow-Control-Prüfung und die Schleife, die den Payload in fenstergebundene Chunks teilt:

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;

Das sind ein paar Dutzend Zeilen, aber sie zeigen den Kern, warum HTTP/2 kein dünnes Add-on sein kann. Es gibt keinen äquivalenten Codepfad in irgendeinem HTTP/1.1-Client, weil das Konzept schlicht nicht existiert. Pflanze es auf TIdHTTP.Get auf und du merkst sofort, dass die ganze Aufrufkonvention — ein blockierender Aufruf pro Request — die falsche Form hat.

ALPN, Settings-Aushandlung und Connection-Lebenszyklus

HTTP/2 über TLS wird per ALPN (Application-Layer Protocol Negotiation) gewählt, das vor dem Handshake auf dem TLS-Kontext konfiguriert werden muss. Indys OpenSSL-I/O-Handler gab ALPN jahrelang nicht frei — es kam später dazu — aber ALPN ist nur der erste Schritt. Nach dem TLS-Handshake muss der Client den 24-Byte-Connection-Preface senden, dann einen SETTINGS-Frame, dann auf die SETTINGS des Servers warten und sie quittieren. Erst dann ist die Verbindung für Requests bereit. SChannel unter Windows verlangt einen anderen ALPN-Setup-Pfad als OpenSSL. msquic noch einen weiteren. Das sauber zu abstrahieren war in einem eigenen Modul einfacher als durch Indys bestehenden TLS-Handshake-Code durchgefädelt.

Performance: die Zahlen

Der Lohn für all diese Arbeit ist messbar. Auf einer repräsentativen Last — ein Delphi-Client sendet eine Million Apple Push Notifications an APNs (apns2 im HTTP/2-Modus) auf einem modernen Windows-Server:

Grob 10× mehr auf derselben Hardware und im selben Netz — ohne die Anwendungslogik zu ändern. Das ist der Wert, den HTTP/2-Multiplexing liefert, wenn du den Stack nativ baust statt ihn mit parallelen HTTP/1.1-Verbindungen vorzutäuschen.

Was wir von Indy wiederverwendet haben

Nicht alles wurde neu geschrieben. Wir nutzen Indys OpenSSL-TIdSSLIOHandlerSocketOpenSSL als einen der TLS-Anbieter (mit unserem eigenen ALPN-Patch), Indys TIdTCPClient als rohe Socket-Schicht im Standard-Backend, und Indys TIdHTTPServer bleibt für den HTTP/1.1-Fallback im Bild. Die HTTP/2-Schicht sitzt darüber und besitzt die Read-Schleife, den Frame-Parser, HPACK, Flow Control und den Stream-Lebenszyklus. Die Aufteilung erlaubt uns, die TLS-Plumbing zu teilen und gleichzeitig die HTTP/2-Semantik unabhängig vom blockierenden Aufrufmodell von Indy zu halten.

Lehren für andere Protokolle

Dasselbe Muster wiederholt sich in sgcWebSockets. WebSocket ist ein binäres Framing-Protokoll, das auf einen HTTP-Upgrade aufgesattelt ist — den Framer haben wir von Grund auf geschrieben. MQTT 5 hat eigene Variable-Length-Integer-Kodierung, Properties-Tabelle und Flow Control — von Grund auf geschrieben. AMQP 1.0 hat ein vollständiges Typsystem und Link-Credit-Flow — von Grund auf geschrieben. HTTP/3 läuft über QUIC und gar nicht über TCP — hier wrappen wir msquic / ngtcp2. Der rote Faden: Jedes moderne Protokoll hat seine eigene State-Machine, und es als „nur ein weiterer Modus von HTTP/1.1“ zu behandeln führt zu fragilem, langsamem Code.

Abschluss

Indy zu erweitern ist der naheliegende erste Reflex — es ist die Bibliothek, der jeder Delphi-Entwickler bereits vertraut. Aber HTTP/2 ist keine Erweiterung von HTTP/1.1, sondern ein Redesign. Frame-Multiplexing, HPACK, Flow Control und Out-of-Order-Antworten erfordern jeweils Infrastruktur, die in einem blockierenden HTTP/1.1-Client schlicht nicht existiert. Das nativ zu bauen kostete echten Engineering-Aufwand, ist aber der einzige Weg, die versprochene Performance-Größenordnung von HTTP/2 zu liefern — und es setzte die Vorlage für jedes andere moderne Protokoll, das sgcWebSockets seitdem ausgeliefert hat.