我们被问得最多的问题
每当我们在 sgcWebSockets 中发布新的 HTTP/2 功能——服务器推送、HPACK 动态表调优、SETTINGS 帧协商——总有人在论坛上礼貌地问同样的问题:"你们为什么不直接扩展 Indy 的 TIdHTTP?它已经处理 TCP、TLS 和 HTTP/1.1,HTTP/2 不就是顶上的另一个帧格式吗?"
这是一个合理的问题。简短的答案是 HTTP/2 不是您可以嫁接到 HTTP/1.1 客户端上的一层——它是一种根本不同的传输,打破了 Indy 的几乎所有假设。本文介绍我们从头构建 HTTP/2 实现的具体原因、需要做什么,以及它如何影响 sgcWebSockets 的其余部分。
HTTP/1.1 是文本,HTTP/2 是二进制帧
Indy 的 HTTP 实现与每个经典的 HTTP/1.1 客户端一样,读取以 CRLF 结尾的请求行,然后是标头,然后是 content-length 主体或分块主体。它是面向行的文本。传输是一个请求、一个响应,然后是关闭或流水线(实际上没有人真正使用流水线)。
HTTP/2 是一种二进制帧协议。连接序言之后的每个字节都属于一个帧:DATA、HEADERS、PRIORITY、RST_STREAM、SETTINGS、PUSH_PROMISE、PING、GOAWAY、WINDOW_UPDATE、CONTINUATION。帧不与请求对齐——单个请求的标头可能跨越多个 HEADERS / CONTINUATION 帧,主体可能作为许多交错的 DATA 帧到达,来自不同请求的帧在同一套接字上混合。读取器不是行解析器,它是基于固定长度帧头和可变长度负载的状态机。
在 TIdHTTP.ReadHeaderFromStream 之上改装这一点将意味着完全重写读取循环。到那时您不是在扩展 Indy——您正在编写一个恰好共享套接字类的并行实现。
多路复用打破了每连接一个请求的假设
Indy 的 HTTP 客户端模型围绕单个进行中的请求构建。您调用 Get,该方法阻塞,响应到达,您处理它。在该调用期间,TCP 套接字属于该调用。HTTP/2 颠倒了这一点:单个连接复用许多并发流,每个流由流 id 标识。一个典型的 Apple Push Notification 连接在同一 TCP 套接字上发送数千个独立的 POST 请求,响应乱序到达。
要在 Indy 的阻塞模型中支持这一点,您需要 (a) 每个 HTTP/2 流一个 Indy 客户端——这违背了 HTTP/2 的全部意义——或者 (b) 一个单独的调度程序线程,它拥有套接字、读取帧、将它们调度到每流队列,并向调用者公开 futures/promises API。选项 (b) 本质上是编写一个新的 HTTP/2 客户端,并将 Indy 停放在底下作为薄套接字包装器。我们选择明确地使用最适合平台的传输来做到这一点。
HPACK 是一个状态机,不是字符串
HTTP/1.1 标头是名称-值文本。HTTP/2 标头使用 HPACK (RFC 7541) 压缩,该 RFC 结合了常见标头的静态表、随连接进行而增长的动态表以及 Huffman 编码的字面值。编码标头集不是 WriteLn(Stream, 'Content-Type: application/json')——它是针对两个表的查找、indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed 表示之间的选择、可选的 Huffman 压缩,以及两个端点都必须保持同步的动态表更新。
表大小错了,连接就会因 COMPRESSION_ERROR 而死亡。忘记应用最大标头表大小的 SETTINGS 更新,以前工作的服务器就开始拒绝您的请求。没有捷径——您要么完整实现 HPACK,要么没有 HTTP/2。我们用大约 1,200 行 Pascal 实现了静态表、带驱逐的动态表、Huffman 编码/解码和四种表示形式。该代码在 Indy 中不存在,并且在那里没有自然的归宿。
流量控制是每流和每连接
HTTP/2 有两层窗口:一个连接级窗口和每流一个。两者都从 65,535 字节开始,仅当对等方发送 WINDOW_UPDATE 帧时才增长。在不检查窗口的情况下写入 DATA 的天真实现将在前 64 KB 之后挂起连接。正确的实现需要一个事件循环,在窗口耗尽时暂停写入器、将大主体拆分为窗口大小的块,并在更新到达时恢复。
HTTP/1.1 中没有这些,因此 Indy 中也没有。这是一项真正的工作,必须在某个地方进行——我们把它放在与帧读取器和 HPACK 编解码器相同的地方。
我们的写入器循环是什么样的
这是帧写入器的一小部分,为可读性而简化。请注意显式的流量控制检查和将负载拆分为受窗口限制的块的循环:
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;
这是几十行,但它捕捉到了 HTTP/2 不能是薄附加组件的核心原因。在任何 HTTP/1.1 客户端中都没有等效的代码路径,因为这个概念根本不存在。将其加在 TIdHTTP.Get 之上,您立即会发现整个调用约定——每个请求一个阻塞调用——是错误的形状。
ALPN、设置协商和连接生命周期
基于 TLS 的 HTTP/2 是通过 ALPN(应用层协议协商)选择的,这必须在握手之前在 TLS 上下文上配置。Indy 的 OpenSSL IO 处理程序多年来没有公开 ALPN——最终添加了——但 ALPN 只是第一步。TLS 握手之后,客户端必须发送 24 字节的连接序言,然后是 SETTINGS 帧,然后等待服务器的 SETTINGS 并确认它。只有那时连接才能用于请求。Windows 上的 SChannel 需要与 OpenSSL 不同的 ALPN 设置路径。msquic 又是另一种。在干净的模块中干净地抽象这些比通过 Indy 现有的 TLS 握手代码线程化要容易。
性能:数字
所有这些工作的回报是可衡量的。在一个代表性工作负载上——一个在现代 Windows 服务器上向 APNs 发送一百万个 Apple Push Notifications(HTTP/2 模式下的 apns2)的 Delphi 客户端:
- 使用
TIdHTTP的 HTTP/1.1,每请求一个 TCP 连接:约 110 个请求/秒,受 TLS 握手成本限制。 - 使用
TIdHTTP的 HTTP/1.1,连接复用:约 850 个请求/秒,受每连接一个进行中规则的限制。 - 使用 sgcWebSockets 的 HTTP/2,单个连接,多路复用:约 9,500 个请求/秒,受 APNs 服务器端节流限制,而不是客户端。
在相同硬件和相同网络上大约提高了 10 倍,无需更改应用程序逻辑。这就是 HTTP/2 多路复用在您原生构建栈而不是尝试用并行 HTTP/1.1 连接伪造它时所提供的价值。
我们从 Indy 重用了什么
并非一切都被重写了。我们使用 Indy 的 OpenSSL TIdSSLIOHandlerSocketOpenSSL 作为底层 TLS 提供程序之一(带我们自己的 ALPN 补丁),使用 Indy 的 TIdTCPClient 作为默认后端中的原始套接字层,Indy 的 TIdHTTPServer 留在画面中以支持 HTTP/1.1 回退。HTTP/2 层位于顶部,拥有读取循环、帧解析器、HPACK、流量控制和流生命周期。这种拆分让我们可以共享 TLS 管道,同时保持 HTTP/2 语义独立于 Indy 的阻塞调用模型。
其他协议的经验教训
相同的模式在整个 sgcWebSockets 中重复。WebSocket 是一个嫁接到 HTTP 升级上的二进制帧协议——我们从头编写了帧器。MQTT 5 有自己的变长整数编码、属性表和流量控制——从头编写。AMQP 1.0 有完整的类型系统和链接信用流——从头编写。HTTP/3 基于 QUIC 而不是 TCP——我们包装了 msquic / ngtcp2。共同的主线是,每个现代协议都有自己的状态机,假装它是"HTTP/1.1 的另一种模式"会导致脆弱、缓慢的代码。
结束思考
扩展 Indy 是显而易见的第一直觉——它是每个 Delphi 开发者已经信任的库。但 HTTP/2 不是 HTTP/1.1 的扩展,它是一次重新设计。帧多路复用、HPACK、流量控制和乱序响应每个都需要在阻塞 HTTP/1.1 客户端中根本不存在的基础设施。原生构建它需要真正的工程努力,但这是交付 HTTP/2 承诺的数量级性能的唯一方式——并且它为 sgcWebSockets 此后发布的每个其他现代协议树立了模板。