La pregunta que más nos hacen
Cada vez que publicamos una nueva función HTTP/2 en sgcWebSockets — server push, ajuste de la tabla dinámica HPACK, negociación de SETTINGS — alguien hace educadamente la misma pregunta en el foro: “¿Por qué no extendisteis simplemente el TIdHTTP de Indy? Ya hace TCP, TLS y HTTP/1.1, seguro que HTTP/2 es sólo otro formato de framing encima.”
Es una pregunta razonable. La respuesta corta es que HTTP/2 no es una capa que puedas injertar sobre un cliente HTTP/1.1 — es un transporte fundamentalmente distinto que rompe casi todas las asunciones que hace Indy. Este post recorre las razones específicas por las que construimos nuestra implementación HTTP/2 desde cero, qué costó eso y cómo influyó en el resto de sgcWebSockets.
HTTP/1.1 es texto, HTTP/2 son frames binarios
La implementación HTTP de Indy, como cualquier cliente HTTP/1.1 clásico, lee una línea de petición terminada en CRLF, luego cabeceras, luego un cuerpo content-length o un cuerpo chunked. Es texto orientado a línea. El transporte es una petición, una respuesta y luego close o pipeline (y nadie hace pipeline en la práctica).
HTTP/2 es un protocolo de framing binario. Cada byte tras el preámbulo de conexión pertenece a un frame: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. Los frames no se alinean con las peticiones — las cabeceras de una sola petición pueden ocupar varios frames HEADERS / CONTINUATION, el cuerpo puede llegar como muchos frames DATA entrelazados y frames de distintas peticiones se intermezclan en el mismo socket. El lector no es un parser de líneas, es una máquina de estados sobre cabeceras de frame de longitud fija y payloads de longitud variable.
Retrofitar eso sobre TIdHTTP.ReadHeaderFromStream significaría reescribir el bucle de lectura por completo. En ese punto no estás extendiendo Indy — estás escribiendo una implementación paralela que casualmente comparte una clase de socket.
La multiplexación rompe la asunción de una petición por conexión
El modelo de cliente HTTP de Indy se construye en torno a una sola petición en vuelo. Llamas a Get, el método bloquea, la respuesta llega, la procesas. El socket TCP pertenece a esa llamada durante toda su duración. HTTP/2 invierte esto: una sola conexión multiplexa muchos streams concurrentes, cada uno identificado por un stream id. Una conexión Apple Push Notification típica envía miles de peticiones POST independientes a través del mismo socket TCP, con respuestas que llegan fuera de orden.
Para soportar eso con el modelo bloqueante de Indy necesitarías o bien (a) un cliente Indy por stream HTTP/2 — arruinando todo el sentido de HTTP/2 — o bien (b) un hilo dispatcher separado que posea el socket, lea frames, los reparta a colas por stream y exponga una API de futures/promises a los llamadores. La opción (b) es esencialmente escribir un cliente HTTP/2 nuevo y aparcar Indy debajo como un envoltorio fino de socket. Elegimos hacer eso explícitamente con el transporte más apropiado para la plataforma.
HPACK es una máquina de estados, no un string
Las cabeceras HTTP/1.1 son texto nombre-valor. Las cabeceras HTTP/2 se comprimen con HPACK (RFC 7541), que combina una tabla estática de cabeceras comunes, una tabla dinámica que crece según avanza la conexión y literales codificados con Huffman. Codificar un conjunto de cabeceras no es WriteLn(Stream, 'Content-Type: application/json') — es una búsqueda contra dos tablas, una elección entre representaciones indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed, compresión Huffman opcional y una actualización de la tabla dinámica que ambos extremos deben mantener sincronizada.
Equivoca el tamaño de tabla y la conexión muere con COMPRESSION_ERROR. Olvida aplicar la actualización SETTINGS para el tamaño máximo de tabla de cabeceras y un servidor que antes funcionaba empieza a rechazar tus peticiones. No hay atajo — o implementas HPACK por completo o no tienes HTTP/2. Implementamos la tabla estática, la tabla dinámica con desalojo, encode/decode Huffman y las cuatro formas de representación en aproximadamente 1.200 líneas de Pascal. Ese código no existe en ningún sitio de Indy y no habría tenido un hogar natural ahí.
El control de flujo es por stream y por conexión
HTTP/2 tiene dos ventanas en capas: una ventana a nivel de conexión y una por stream. Ambas empiezan en 65.535 bytes y sólo crecen cuando el par envía frames WINDOW_UPDATE. Una implementación naíf que escriba DATA sin comprobar la ventana colgará la conexión tras los primeros 64 KB. Una implementación correcta necesita un bucle de eventos que pause a los escritores cuando la ventana se agota, divida cuerpos grandes en chunks del tamaño de la ventana y reanude cuando lleguen las actualizaciones.
Nada de eso existe en HTTP/1.1 y por tanto nada de eso existe en Indy. Es un trabajo real que tiene que vivir en algún sitio — lo pusimos en el mismo lugar que el lector de frames y el codec HPACK.
Cómo se ve nuestro bucle escritor
Aquí va un pequeño fragmento del escritor de frames, simplificado para legibilidad. Fíjate en la comprobación explícita de control de flujo y el bucle que divide el payload en chunks acotados por la ventana:
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;
Son unas decenas de líneas pero capturan el corazón de por qué HTTP/2 no puede ser un add-on fino. No hay una ruta de código equivalente en ningún cliente HTTP/1.1 porque el concepto simplemente no existe. Atornílalo encima de TIdHTTP.Get y descubres inmediatamente que toda la convención de llamada — una llamada bloqueante por petición — es la forma equivocada.
ALPN, negociación de settings y ciclo de vida de conexión
HTTP/2 sobre TLS se selecciona vía ALPN (Application-Layer Protocol Negotiation), que tiene que configurarse en el contexto TLS antes del handshake. El IO handler OpenSSL de Indy no expuso ALPN durante años — finalmente se añadió — pero ALPN es sólo el primer paso. Tras el handshake TLS el cliente debe enviar el preámbulo de conexión de 24 bytes, luego un frame SETTINGS, luego esperar el SETTINGS del servidor y confirmarlo. Sólo entonces la conexión está lista para peticiones. SChannel en Windows requiere una ruta de configuración ALPN distinta a OpenSSL. msquic tiene otra más. Abstraer esto limpiamente fue más fácil en un módulo limpio que ensartado en el código de handshake TLS existente de Indy.
Rendimiento: los números
La recompensa por hacer todo este trabajo es medible. Con una carga de trabajo representativa — un cliente Delphi enviando un millón de Apple Push Notifications a APNs (apns2 en modo HTTP/2) en un servidor Windows moderno:
- HTTP/1.1 con
TIdHTTP, una conexión TCP por petición: ~110 peticiones/s, limitado por el coste del handshake TLS. - HTTP/1.1 con
TIdHTTP, reutilizando conexión: ~850 peticiones/s, limitado por la regla de una-en-vuelo-por-conexión. - HTTP/2 con sgcWebSockets, una sola conexión, multiplexada: ~9.500 peticiones/s, limitado por el throttling del lado servidor de APNs y no por el cliente.
Aproximadamente 10x de mejora en el mismo hardware y la misma red, sin cambiar la lógica de la aplicación. Ese es el valor que entrega la multiplexación HTTP/2 cuando construyes la pila nativamente en vez de intentar simularla con conexiones HTTP/1.1 paralelas.
Lo que reutilizamos de Indy
No todo se reescribió. Usamos el TIdSSLIOHandlerSocketOpenSSL OpenSSL de Indy como uno de los proveedores TLS subyacentes (con nuestro propio parche ALPN), el TIdTCPClient de Indy para la capa de socket en crudo en el backend por defecto, y el TIdHTTPServer de Indy sigue en escena para el fallback HTTP/1.1. La capa HTTP/2 se asienta encima, asumiendo el bucle de lectura, el parser de frames, HPACK, control de flujo y ciclo de vida de streams. La división nos deja compartir la fontanería TLS mientras mantenemos la semántica HTTP/2 independiente del modelo de llamada bloqueante de Indy.
Lecciones para otros protocolos
El mismo patrón se repite a lo largo de sgcWebSockets. WebSocket es un protocolo de framing binario atornillado a un upgrade HTTP — escribimos el framer desde cero. MQTT 5 tiene su propia codificación de enteros de longitud variable, tabla de propiedades y control de flujo — escrito desde cero. AMQP 1.0 tiene un sistema de tipos completo y flujo de crédito por link — escrito desde cero. HTTP/3 va sobre QUIC y no sobre TCP — envolvemos msquic / ngtcp2. El hilo común es que cada protocolo moderno tiene su propia máquina de estados y pretender que es “sólo otro modo de HTTP/1.1” lleva a código frágil y lento.
Reflexiones finales
Extender Indy es el primer instinto obvio — es la librería en la que todo desarrollador Delphi ya confía. Pero HTTP/2 no es una extensión de HTTP/1.1, es un rediseño. La multiplexación de frames, HPACK, control de flujo y respuestas fuera de orden requieren cada uno infraestructura que simplemente no existe en un cliente HTTP/1.1 bloqueante. Construirlo nativamente costó esfuerzo de ingeniería real pero es la única forma de entregar el orden de magnitud de rendimiento que promete HTTP/2 — y sentó la plantilla para cada otro protocolo moderno que ha distribuido sgcWebSockets desde entonces.