La question qu'on nous pose le plus
Chaque fois que nous publions une nouvelle fonctionnalité HTTP/2 dans sgcWebSockets — server push, ajustement de la table dynamique HPACK, négociation des frames SETTINGS — quelqu'un pose poliment la même question sur le forum : « Pourquoi n'avez-vous pas simplement étendu TIdHTTP d'Indy ? Il gère déjà TCP, TLS et HTTP/1.1, HTTP/2 n'est sûrement qu'un autre format de framing par-dessus. »
C'est une question raisonnable. La réponse courte est que HTTP/2 n'est pas une couche qu'on peut greffer sur un client HTTP/1.1 — c'est un transport fondamentalement différent qui casse presque toutes les hypothèses d'Indy. Ce billet passe en revue les raisons précises pour lesquelles nous avons construit notre implémentation HTTP/2 de zéro, ce que cela a impliqué, et comment cela a influencé le reste de sgcWebSockets.
HTTP/1.1 est du texte, HTTP/2 est des frames binaires
L'implémentation HTTP d'Indy, comme tout client HTTP/1.1 classique, lit une ligne de requête terminée par CRLF, puis les en-têtes, puis un corps avec content-length ou un corps chunked. C'est du texte orienté ligne. Le transport est une requête, une réponse, puis fermeture ou pipeline (et personne ne pipeline en pratique).
HTTP/2 est un protocole de framing binaire. Chaque octet après le préambule de connexion appartient à une frame : DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. Les frames ne sont pas alignées avec les requêtes — les en-têtes d'une seule requête peuvent s'étendre sur plusieurs frames HEADERS / CONTINUATION, le corps peut arriver sous forme de nombreuses frames DATA entrelacées, et les frames de différentes requêtes sont mélangées sur le même socket. Le lecteur n'est pas un parseur de lignes, c'est une machine à états sur des en-têtes de frames de longueur fixe et des charges utiles de longueur variable.
Greffer cela sur TIdHTTP.ReadHeaderFromStream reviendrait à réécrire complètement la boucle de lecture. À ce stade, vous n'étendez pas Indy — vous écrivez une implémentation parallèle qui partage simplement une classe socket.
Le multiplexage casse l'hypothèse une-requête-par-connexion
Le modèle client HTTP d'Indy est construit autour d'une seule requête en vol. Vous appelez Get, la méthode bloque, la réponse arrive, vous la traitez. Le socket TCP appartient à cet appel pour la durée. HTTP/2 inverse cela : une seule connexion multiplexe plusieurs flux concurrents, chacun identifié par un stream id. Une connexion Apple Push Notification typique envoie des milliers de requêtes POST indépendantes sur le même socket TCP, avec des réponses arrivant dans le désordre.
Pour supporter cela avec le modèle bloquant d'Indy, vous auriez besoin soit (a) d'un client Indy par flux HTTP/2 — ce qui anéantit tout l'intérêt de HTTP/2 — soit (b) d'un thread répartiteur séparé qui possède le socket, lit les frames, les dispatche vers des files par flux et expose une API futures/promesses aux appelants. L'option (b) revient essentiellement à écrire un nouveau client HTTP/2 et à parquer Indy en dessous comme un mince wrapper de socket. Nous avons choisi de le faire explicitement avec le transport le plus approprié pour la plateforme.
HPACK est une machine à états, pas une chaîne
Les en-têtes HTTP/1.1 sont des paires nom-valeur en texte. Les en-têtes HTTP/2 sont compressés avec HPACK (RFC 7541), qui combine une table statique d'en-têtes communs, une table dynamique qui croît à mesure que la connexion progresse, et des littéraux codés Huffman. Encoder un ensemble d'en-têtes ce n'est pas WriteLn(Stream, 'Content-Type: application/json') — c'est une recherche dans deux tables, un choix entre les représentations indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed, une compression Huffman optionnelle, et une mise à jour de la table dynamique que les deux extrémités doivent maintenir synchronisée.
Trompez-vous sur la taille de la table et la connexion meurt avec une COMPRESSION_ERROR. Oubliez d'appliquer la mise à jour SETTINGS pour la taille maximale de la table d'en-têtes et un serveur précédemment fonctionnel commence à rejeter vos requêtes. Il n'y a pas de raccourci — soit vous implémentez HPACK complètement, soit vous n'avez pas HTTP/2. Nous avons implémenté la table statique, la table dynamique avec éviction, l'encode/decode Huffman et les quatre formes de représentation en environ 1 200 lignes de Pascal. Ce code n'existe nulle part dans Indy et n'aurait pas eu de place naturelle là-bas.
Le contrôle de flux est par-flux et par-connexion
HTTP/2 a deux fenêtres en couches : une fenêtre niveau connexion et une par flux. Les deux commencent à 65 535 octets et ne croissent que quand le pair envoie des frames WINDOW_UPDATE. Une implémentation naïve qui écrit DATA sans vérifier la fenêtre va bloquer la connexion après les premiers 64 Ko. Une implémentation correcte nécessite une boucle d'événements qui met en pause les écrivains quand la fenêtre est épuisée, divise les gros corps en chunks de la taille de la fenêtre, et reprend quand les mises à jour arrivent.
Rien de tout cela n'existe en HTTP/1.1 et donc rien de tout cela n'existe dans Indy. C'est un vrai travail qui doit vivre quelque part — nous l'avons mis au même endroit que le lecteur de frames et le codec HPACK.
À quoi ressemble notre boucle d'écriture
Voici une petite tranche du writer de frames, simplifié pour la lisibilité. Notez la vérification explicite du contrôle de flux et la boucle qui divise la charge utile en chunks bornés par la fenêtre :
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;
C'est quelques dizaines de lignes mais cela capture le cœur de pourquoi HTTP/2 ne peut pas être un mince add-on. Il n'y a aucun chemin de code équivalent dans un client HTTP/1.1 parce que le concept n'existe simplement pas. Greffez-le sur TIdHTTP.Get et vous découvrirez immédiatement que toute la convention d'appel — un appel bloquant par requête — a la mauvaise forme.
ALPN, négociation des settings et cycle de vie de la connexion
HTTP/2 sur TLS est sélectionné via ALPN (Application-Layer Protocol Negotiation), qui doit être configuré sur le contexte TLS avant le handshake. L'IO handler OpenSSL d'Indy n'a pas exposé ALPN pendant des années — il a finalement été ajouté — mais ALPN n'est que la première étape. Après le handshake TLS, le client doit envoyer le préambule de connexion de 24 octets, puis une frame SETTINGS, puis attendre les SETTINGS du serveur et les acquitter. Ce n'est qu'alors que la connexion est prête pour les requêtes. SChannel sous Windows nécessite un chemin de configuration ALPN différent d'OpenSSL. msquic en a encore un autre. Abstraire cela proprement était plus facile dans un module propre plutôt que disséminé dans le code de handshake TLS existant d'Indy.
Performance : les chiffres
Le bénéfice de tout ce travail est mesurable. Sur une charge représentative — un client Delphi envoyant un million d'Apple Push Notifications à APNs (apns2 en mode HTTP/2) sur un serveur Windows moderne :
- HTTP/1.1 avec
TIdHTTP, une connexion TCP par requête : ~110 requêtes/sec, limité par le coût du handshake TLS. - HTTP/1.1 avec
TIdHTTP, réutilisation de connexion : ~850 requêtes/sec, limité par la règle une-en-vol-par-connexion. - HTTP/2 avec sgcWebSockets, connexion unique, multiplexée : ~9 500 requêtes/sec, limité par la limitation côté serveur APNs plutôt que par le client.
Une amélioration d'environ 10x sur le même matériel et le même réseau, sans changer la logique applicative. C'est la valeur que le multiplexage HTTP/2 délivre quand vous construisez la pile nativement plutôt que d'essayer de la simuler avec des connexions HTTP/1.1 parallèles.
Ce que nous avons réutilisé d'Indy
Tout n'a pas été réécrit. Nous utilisons TIdSSLIOHandlerSocketOpenSSL d'Indy comme l'un des fournisseurs TLS sous-jacents (avec notre propre patch ALPN), le TIdTCPClient d'Indy pour la couche socket brute dans le backend par défaut, et le TIdHTTPServer d'Indy reste dans le tableau pour le fallback HTTP/1.1. La couche HTTP/2 s'assoit dessus, possédant la boucle de lecture, le parseur de frames, HPACK, le contrôle de flux et le cycle de vie des flux. Cette séparation nous permet de partager la plomberie TLS tout en gardant la sémantique HTTP/2 indépendante du modèle d'appel bloquant d'Indy.
Leçons pour d'autres protocoles
Le même schéma se répète dans tout sgcWebSockets. WebSocket est un protocole de framing binaire greffé sur un upgrade HTTP — nous avons écrit le framer de zéro. MQTT 5 a son propre encodage d'entiers de longueur variable, sa table de propriétés et son contrôle de flux — écrit de zéro. AMQP 1.0 a un système de types complet et un flux de crédit de lien — écrit de zéro. HTTP/3 est sur QUIC et pas du tout sur TCP — nous wrappons msquic / ngtcp2. Le fil commun est que chaque protocole moderne a sa propre machine à états et prétendre que c'est « juste un autre mode de HTTP/1.1 » mène à du code fragile et lent.
Pour conclure
Étendre Indy est le premier instinct évident — c'est la bibliothèque à laquelle chaque développeur Delphi fait déjà confiance. Mais HTTP/2 n'est pas une extension de HTTP/1.1, c'est une refonte. Le multiplexage de frames, HPACK, le contrôle de flux et les réponses dans le désordre nécessitent chacun une infrastructure qui n'existe simplement pas dans un client HTTP/1.1 bloquant. Le construire nativement a coûté un vrai effort d'ingénierie mais c'est la seule façon de livrer la performance d'un ordre de grandeur que HTTP/2 promet — et cela a posé le modèle pour chaque autre protocole moderne que sgcWebSockets a livré depuis.