Waarom we een eigen HTTP/2-stack bouwden in plaats van Indy uit te breiden

· Reviews

De vraag die we het vaakst krijgen

Telkens wanneer we een nieuwe HTTP/2-feature publiceren in sgcWebSockets — server push, HPACK dynamic table tuning, SETTINGS frame negotiation — stelt iemand op het forum beleefd dezelfde vraag: “Waarom hebben jullie niet gewoon Indy’s TIdHTTP uitgebreid? Het doet al TCP, TLS en HTTP/1.1, dan is HTTP/2 toch gewoon een ander framing-formaat eroverheen?”

Het is een redelijke vraag. Het korte antwoord is dat HTTP/2 geen laag is die je op een HTTP/1.1-client kunt enten — het is een fundamenteel ander transport dat bijna elke aanname die Indy maakt breekt. Dit bericht doorloopt de specifieke redenen waarom we onze HTTP/2-implementatie vanaf de grond opbouwden, wat dat vergde, en hoe het de rest van sgcWebSockets beinvloedde.

HTTP/1.1 is tekst, HTTP/2 is binaire frames

De HTTP-implementatie van Indy leest, zoals elke klassieke HTTP/1.1-client, een door CRLF afgesloten request-regel, dan headers, dan ofwel een content-length-body of een chunked body. Het is regelgeorienteerde tekst. Het transport is een request, een response, dan ofwel sluiten of pipelinen (en niemand pipelinet in de praktijk).

HTTP/2 is een binair framing-protocol. Elke byte na de connection preface hoort bij een frame: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. Frames zijn niet uitgelijnd met requests — de headers van een enkel request kunnen meerdere HEADERS/CONTINUATION-frames beslaan, de body kan binnenkomen als veel interleaved DATA-frames, en frames van verschillende requests zijn op dezelfde socket gemengd. De reader is geen regelparser, maar een toestandsmachine over frame-headers van vaste lengte en payloads van variabele lengte.

Dat bovenop TIdHTTP.ReadHeaderFromStream retrofitten zou betekenen dat de read-loop volledig herschreven moet worden. Op dat punt breid je Indy niet uit — je schrijft een parallelle implementatie die toevallig een socket-klasse deelt.

Multiplexing breekt de aanname van een-request-per-verbinding

Het HTTP-clientmodel van Indy is gebouwd rond een enkel in-flight request. Je roept Get aan, de methode blokkeert, het antwoord komt binnen, je verwerkt het. De TCP-socket hoort gedurende die aanroep bij die call. HTTP/2 keert dit om: een enkele verbinding multiplexet veel gelijktijdige streams, elk geidentificeerd door een stream-id. Een typische Apple Push Notification-verbinding stuurt duizenden onafhankelijke POST-requests over dezelfde TCP-socket, met antwoorden die out of order arriveren.

Om dat met het blokkerende model van Indy te ondersteunen heb je ofwel (a) een Indy-client per HTTP/2-stream nodig — wat het hele punt van HTTP/2 tenietdoet — of (b) een aparte dispatcher-thread die de socket bezit, frames leest, ze naar per-stream queues dispatcht en een futures/promises-API blootstelt aan callers. Optie (b) is in wezen het schrijven van een nieuwe HTTP/2-client en Indy eronder parkeren als een dunne socket-wrapper. We hebben er expliciet voor gekozen dat te doen met het meest geschikte transport per platform.

HPACK is een toestandsmachine, geen string

HTTP/1.1-headers zijn naam-waarde-tekst. HTTP/2-headers worden gecomprimeerd met HPACK (RFC 7541), dat een statische tabel met veelvoorkomende headers, een dynamische tabel die groeit naarmate de verbinding voortduurt, en Huffman-gecodeerde literals combineert. Het encoden van een headerset is niet WriteLn(Stream, 'Content-Type: application/json') — het is een lookup tegen twee tabellen, een keuze tussen indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed representaties, optionele Huffman-compressie, en een update aan de dynamische tabel die beide endpoints synchroon moeten houden.

Krijg je de tabelgrootte verkeerd dan sterft de verbinding met een COMPRESSION_ERROR. Vergeet de SETTINGS-update voor max header table size toe te passen en een eerder werkende server begint je requests te weigeren. Er is geen shortcut — je implementeert HPACK volledig of je hebt geen HTTP/2. We implementeerden de statische tabel, de dynamische tabel met evictie, Huffman encode/decode en de vier representatievormen in ongeveer 1.200 regels Pascal. Die code bestaat nergens in Indy en zou daar ook geen natuurlijke plek hebben.

Flow control is per-stream en per-verbinding

HTTP/2 heeft twee gelaagde windows: een verbindingsniveau-window en een per stream. Beide beginnen op 65.535 bytes en groeien alleen wanneer de peer WINDOW_UPDATE-frames stuurt. Een naieve implementatie die DATA schrijft zonder het window te controleren laat de verbinding hangen na de eerste 64 KB. Een correcte implementatie heeft een event loop nodig die writers pauzeert wanneer het window uitgeput is, grote bodies in window-grootte chunks splitst, en hervat wanneer updates arriveren.

Niets daarvan bestaat in HTTP/1.1 en dus niets daarvan bestaat in Indy. Het is een echt stuk werk dat ergens moet wonen — we hebben het op dezelfde plek gezet als de frame reader en de HPACK-codec.

Hoe onze writer-loop eruitziet

Hier is een klein stukje van de frame writer, vereenvoudigd voor leesbaarheid. Let op de expliciete flow-control-check en de loop die de payload in window-begrensde chunks splitst:

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;

Dit zijn enkele tientallen regels maar het vat het hart samen van waarom HTTP/2 geen dunne add-on kan zijn. Er is geen equivalent code-pad in welke HTTP/1.1-client dan ook omdat het concept simpelweg niet bestaat. Schroef het bovenop TIdHTTP.Get en je ontdekt onmiddellijk dat de hele calling convention — een blokkerende aanroep per request — de verkeerde vorm is.

ALPN, settings-onderhandeling en verbindings-lifecycle

HTTP/2 over TLS wordt geselecteerd via ALPN (Application-Layer Protocol Negotiation), dat geconfigureerd moet worden op de TLS-context voor de handshake. De OpenSSL IO handler van Indy ondersteunde ALPN jarenlang niet — het werd uiteindelijk toegevoegd — maar ALPN is slechts de eerste stap. Na de TLS-handshake moet de client de 24-byte connection preface sturen, dan een SETTINGS-frame, dan wachten op de SETTINGS van de server en deze bevestigen. Pas dan is de verbinding klaar voor requests. SChannel op Windows vereist een ander ALPN-setup-pad dan OpenSSL. msquic heeft weer een ander. Dit netjes abstraheren was makkelijker in een schone module dan rondgevlochten door Indy’s bestaande TLS-handshake-code.

Prestaties: de getallen

De opbrengst voor al dit werk is meetbaar. Op een representatieve workload — een Delphi-client die een miljoen Apple Push Notifications naar APNs stuurt (apns2 in HTTP/2-modus) op een moderne Windows-server:

Ruwweg een 10x verbetering op dezelfde hardware en hetzelfde netwerk, zonder de applicatielogica te veranderen. Dat is de waarde die HTTP/2-multiplexing levert wanneer je de stack native bouwt in plaats van te proberen het na te bootsen met parallelle HTTP/1.1-verbindingen.

Wat we van Indy hergebruikten

Niet alles werd herschreven. We gebruiken Indy’s OpenSSL TIdSSLIOHandlerSocketOpenSSL als een van de onderliggende TLS-providers (met onze eigen ALPN-patch), Indy’s TIdTCPClient voor de ruwe socket-laag in de standaard-backend, en Indy’s TIdHTTPServer blijft in beeld voor HTTP/1.1-fallback. De HTTP/2-laag zit erbovenop en bezit de read-loop, de frame-parser, HPACK, flow control en stream-lifecycle. De splitsing laat ons TLS-bedrading delen terwijl we de HTTP/2-semantiek onafhankelijk van Indy’s blokkerende calling-model houden.

Lessen voor andere protocollen

Hetzelfde patroon herhaalt zich door sgcWebSockets heen. WebSocket is een binair framing-protocol vastgeschroefd op een HTTP-upgrade — we schreven de framer vanaf nul. MQTT 5 heeft een eigen variable-length integer encoding, properties-tabel en flow control — vanaf nul geschreven. AMQP 1.0 heeft een volledig typesysteem en link credit flow — vanaf nul geschreven. HTTP/3 loopt over QUIC en helemaal niet over TCP — we wrappen msquic / ngtcp2. De rode draad is dat elk modern protocol een eigen toestandsmachine heeft, en doen alsof het “gewoon weer een modus van HTTP/1.1” is, leidt tot kwetsbare, trage code.

Afsluitende gedachten

Indy uitbreiden is het voor de hand liggende eerste instinct — het is de bibliotheek die elke Delphi-ontwikkelaar al vertrouwt. Maar HTTP/2 is geen uitbreiding van HTTP/1.1, het is een redesign. Frame-multiplexing, HPACK, flow control en out-of-order responses vereisen elk infrastructuur die simpelweg niet bestaat in een blokkerende HTTP/1.1-client. Het native bouwen kostte echte engineering-inspanning maar het is de enige manier om de orde-van-grootte-prestaties te leveren die HTTP/2 belooft — en het zette de mal voor elk ander modern protocol dat sgcWebSockets sindsdien heeft geleverd.