La domanda che ci viene fatta più spesso
Ogni volta che pubblichiamo una nuova funzionalità HTTP/2 in sgcWebSockets — server push, tuning della tabella dinamica HPACK, negoziazione del frame SETTINGS — qualcuno fa educatamente la stessa domanda sul forum: “Perché non avete semplicemente esteso TIdHTTP di Indy? Fa già TCP, TLS e HTTP/1.1, sicuramente HTTP/2 è solo un altro formato di framing in cima.”
È una domanda ragionevole. La risposta breve è che HTTP/2 non è un layer che puoi innestare su un client HTTP/1.1 — è un trasporto fondamentalmente diverso che rompe quasi ogni assunzione che Indy fa. Questo post passa in rassegna le ragioni specifiche per cui abbiamo costruito la nostra implementazione HTTP/2 da zero, cosa è servito e come ha influenzato il resto di sgcWebSockets.
HTTP/1.1 è testo, HTTP/2 sono frame binari
L'implementazione HTTP di Indy, come ogni client HTTP/1.1 classico, legge una request line terminata da CRLF, poi gli header, poi un body con content-length o un body chunked. È testo orientato alle righe. Il trasporto è una richiesta, una risposta, poi o chiusura o pipeline (e nessuno fa effettivamente pipelining nella pratica).
HTTP/2 è un protocollo di framing binario. Ogni byte dopo il preface della connessione appartiene a un frame: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. I frame non sono allineati alle richieste — gli header di una singola richiesta possono spannarsi su più frame HEADERS / CONTINUATION, il body può arrivare come molti frame DATA interleaved, e frame di richieste diverse sono mescolati sullo stesso socket. Il reader non è un parser di righe, è una macchina a stati su header di frame di lunghezza fissa e payload di lunghezza variabile.
Retrofittare ciò sopra a TIdHTTP.ReadHeaderFromStream significherebbe riscrivere completamente il loop di lettura. A quel punto non stai estendendo Indy — stai scrivendo un'implementazione parallela che si dà il caso condivida una classe di socket.
Il multiplexing rompe l'assunzione di una-richiesta-per-connessione
Il modello client HTTP di Indy è costruito attorno a una singola richiesta in volo. Chiami Get, il metodo blocca, la response arriva, la processi. Il socket TCP appartiene a quella chiamata per tutta la durata. HTTP/2 ribalta questo: una singola connessione multiplexa molti stream concorrenti, ciascuno identificato da uno stream id. Una tipica connessione Apple Push Notification invia migliaia di POST indipendenti attraverso lo stesso socket TCP, con response che arrivano fuori ordine.
Per supportare ciò col modello bloccante di Indy avresti bisogno o di (a) un client Indy per stream HTTP/2 — vanificando tutto il senso di HTTP/2 — o di (b) un thread dispatcher separato che possiede il socket, legge i frame, li smista in code per stream ed espone un'API di future/promise ai chiamanti. L'opzione (b) è essenzialmente scrivere un nuovo client HTTP/2 e parcheggiarci sotto Indy come sottile wrapper di socket. Abbiamo scelto di farlo esplicitamente con il trasporto più appropriato per la piattaforma invece.
HPACK è una macchina a stati, non una stringa
Gli header HTTP/1.1 sono testo nome-valore. Gli header HTTP/2 sono compressi con HPACK (RFC 7541), che combina una tabella statica di header comuni, una tabella dinamica che cresce con l'avanzamento della connessione e literal codificati con Huffman. Codificare un set di header non è WriteLn(Stream, 'Content-Type: application/json') — è un lookup contro due tabelle, una scelta tra rappresentazioni indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed, compressione Huffman opzionale e un aggiornamento alla tabella dinamica che entrambi gli endpoint devono mantenere in sync.
Sbaglia la dimensione della tabella e la connessione muore con un COMPRESSION_ERROR. Dimentica di applicare l'aggiornamento SETTINGS per la dimensione massima della tabella header e un server prima funzionante inizia a rifiutare le tue richieste. Non ci sono scorciatoie — o implementi HPACK per intero o non hai HTTP/2. Abbiamo implementato la tabella statica, la tabella dinamica con evizione, l'encode/decode Huffman e le quattro forme di rappresentazione in circa 1.200 righe di Pascal. Quel codice non esiste da nessuna parte in Indy e non avrebbe avuto alcuna casa naturale lì.
Il controllo di flusso è per-stream e per-connessione
HTTP/2 ha due finestre stratificate: una finestra a livello di connessione e una per stream. Entrambe partono a 65.535 byte e crescono solo quando il peer invia frame WINDOW_UPDATE. Un'implementazione ingenua che scrive DATA senza controllare la finestra appende la connessione dopo i primi 64 KB. Un'implementazione corretta ha bisogno di un event loop che mette in pausa gli scrittori quando la finestra è esaurita, divide i body grandi in chunk grandi quanto la finestra e riprende quando arrivano gli aggiornamenti.
Niente di tutto ciò esiste in HTTP/1.1 e quindi niente di tutto ciò esiste in Indy. È un vero pezzo di lavoro che deve vivere da qualche parte — lo abbiamo messo nello stesso posto del frame reader e del codec HPACK.
Come appare il nostro writer loop
Ecco una piccola fetta del frame writer, semplificata per leggibilità. Nota il check esplicito sul controllo di flusso e il loop che divide il payload in chunk vincolati dalla finestra:
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;
Sono poche decine di righe ma catturano il cuore del perché HTTP/2 non può essere un sottile add-on. Non esiste alcun cammino di codice equivalente in nessun client HTTP/1.1 perché il concetto semplicemente non esiste. Innestalo su TIdHTTP.Get e scopri immediatamente che l'intera convenzione di chiamata — una chiamata bloccante per richiesta — ha la forma sbagliata.
ALPN, negoziazione delle settings e ciclo di vita della connessione
HTTP/2 su TLS è selezionato via ALPN (Application-Layer Protocol Negotiation), che deve essere configurato sul contesto TLS prima dell'handshake. L'IO handler OpenSSL di Indy non ha esposto ALPN per anni — alla fine è stato aggiunto — ma ALPN è solo il primo passo. Dopo l'handshake TLS il client deve inviare il preface di connessione di 24 byte, poi un frame SETTINGS, poi attendere il SETTINGS del server e ackizzarlo. Solo allora la connessione è pronta per le richieste. SChannel su Windows richiede un percorso di setup ALPN diverso da OpenSSL. msquic ne ha ancora un altro. Astrarre tutto questo in modo pulito è stato più facile in un modulo separato che intrecciato attraverso il codice di handshake TLS esistente di Indy.
Performance: i numeri
Il payoff per tutto questo lavoro è misurabile. Su un workload rappresentativo — un client Delphi che invia un milione di Apple Push Notification ad APNs (apns2 in modalità HTTP/2) su un moderno server Windows:
- HTTP/1.1 con
TIdHTTP, una connessione TCP per richiesta: ~110 richieste/sec, limitato dal costo dell'handshake TLS. - HTTP/1.1 con
TIdHTTP, riuso della connessione: ~850 richieste/sec, limitato dalla regola di un-in-volo-per-connessione. - HTTP/2 con sgcWebSockets, singola connessione, multiplexata: ~9.500 richieste/sec, limitato dal throttling lato server di APNs piuttosto che dal client.
Più o meno un miglioramento di 10x sullo stesso hardware e sulla stessa rete, senza cambiare la logica applicativa. È questo il valore che il multiplexing HTTP/2 consegna quando costruisci lo stack nativamente invece di provare a falsificarlo con connessioni HTTP/1.1 parallele.
Cosa abbiamo riusato da Indy
Non tutto è stato riscritto. Usiamo TIdSSLIOHandlerSocketOpenSSL di Indy come uno dei provider TLS sottostanti (con la nostra patch ALPN), TIdTCPClient di Indy per il layer di socket raw nel backend di default, e TIdHTTPServer di Indy resta in scena per il fallback HTTP/1.1. Il layer HTTP/2 sta sopra, possedendo il read loop, il parser di frame, HPACK, il controllo di flusso e il ciclo di vita degli stream. La separazione ci permette di condividere le tubature TLS mantenendo la semantica HTTP/2 indipendente dal modello di chiamata bloccante di Indy.
Lezioni per altri protocolli
Lo stesso pattern si ripete in tutto sgcWebSockets. WebSocket è un protocollo di framing binario innestato su un upgrade HTTP — abbiamo scritto il framer da zero. MQTT 5 ha la propria codifica di interi a lunghezza variabile, tabella delle property e controllo di flusso — scritto da zero. AMQP 1.0 ha un sistema di tipi completo e flusso di link credit — scritto da zero. HTTP/3 è su QUIC e non su TCP affatto — incapsuliamo msquic / ngtcp2. Il filo conduttore è che ogni protocollo moderno ha la propria macchina a stati e fingere che sia “solo un'altra modalità di HTTP/1.1” porta a codice fragile e lento.
Considerazioni finali
Estendere Indy è l'istinto ovvio — è la libreria di cui ogni sviluppatore Delphi si fida già. Ma HTTP/2 non è un'estensione di HTTP/1.1, è una riprogettazione. Multiplexing dei frame, HPACK, controllo di flusso e response out-of-order richiedono ciascuno un'infrastruttura che semplicemente non esiste in un client HTTP/1.1 bloccante. Costruirla nativamente è costato sforzo ingegneristico reale ma è l'unico modo di consegnare la performance di un ordine di grandezza che HTTP/2 promette — e ha impostato il template per ogni altro protocollo moderno che sgcWebSockets ha distribuito da allora.