なぜ Indy を拡張せずにカスタム HTTP/2 スタックを構築したか

· レビュー

もっともよく聞かれる質問

sgcWebSockets で新しい HTTP/2 機能 — サーバープッシュ、HPACK 動的テーブル調整、SETTINGS フレームネゴシエーション — を公開するたびに、誰かがフォーラムで丁寧に同じ質問をします: 「なぜ Indy の TIdHTTP を拡張しなかったのですか?すでに TCP、TLS、HTTP/1.1 を扱っているのだから、HTTP/2 はその上のもう 1 つのフレーミング形式に過ぎないでしょう。」

もっともな質問です。短い答えは、HTTP/2 は HTTP/1.1 クライアントに接ぎ木できる層ではない — Indy が行うほぼすべての前提を壊す、根本的に異なるトランスポートだ、というものです。本稿では、HTTP/2 実装をゼロから構築した具体的な理由、その実装にかかったコスト、そしてそれが sgcWebSockets の他の部分にどう影響したかを解説します。

HTTP/1.1 はテキスト、HTTP/2 はバイナリフレーム

Indy の HTTP 実装は、あらゆる古典的な HTTP/1.1 クライアントと同様、CRLF 終端の要求行、次にヘッダー、次に content-length ボディまたはチャンクボディを読みます。行指向のテキストです。トランスポートは「1 要求 1 応答」のあとに切断またはパイプライン(実際に誰もパイプラインしません)。

HTTP/2 はバイナリフレーミングプロトコルです。接続プリフェイス以降のすべてのバイトがフレームに属します: DATAHEADERSPRIORITYRST_STREAMSETTINGSPUSH_PROMISEPINGGOAWAYWINDOW_UPDATECONTINUATION。フレームは要求と整合していません — 単一要求のヘッダーは複数の HEADERS / CONTINUATION フレームにまたがる可能性があり、ボディは多くの交互配置された DATA フレームとして届くことがあり、異なる要求のフレームは同じソケット上で混在します。読み手は行パーサーではなく、固定長フレームヘッダーと可変長ペイロードに対する状態機械です。

これを TIdHTTP.ReadHeaderFromStream の上に後付けすることは、読み取りループを完全に書き直すことを意味します。その時点であなたは Indy を拡張しているのではなく — 偶然ソケットクラスを共有する並列実装を書いています。

多重化が「1 接続 1 要求」の前提を壊す

Indy の HTTP クライアントモデルは単一の進行中要求を中心に構築されています。Get を呼ぶとメソッドがブロックし、応答が届き、処理する。TCP ソケットはその呼び出しの間その呼び出しに属します。HTTP/2 はこれを逆転させます: 単一の接続が、それぞれストリーム ID で識別される多数の並行ストリームを多重化します。典型的な Apple Push Notification 接続は、同じ TCP ソケット上で数千の独立した POST 要求を送信し、応答は順不同で届きます。

Indy のブロッキングモデルでそれをサポートするには、(a) HTTP/2 ストリームごとに 1 つの Indy クライアント — HTTP/2 の意義を全否定 — か、(b) ソケットを所有しフレームを読み、ストリームごとのキューにディスパッチし、呼び出し元に futures/promises API を公開する別個のディスパッチャースレッド、が必要になります。オプション (b) は実質、新しい HTTP/2 クライアントを書き、その下に Indy を薄いソケットラッパーとして駐車させるものです。私たちはこれを明示的に、各プラットフォームに最適なトランスポートで行うことを選びました。

HPACK は文字列ではなく状態機械

HTTP/1.1 ヘッダーは名前と値のテキストです。HTTP/2 ヘッダーは HPACK(RFC 7541)で圧縮され、よくあるヘッダーの静的テーブル、接続が進むにつれて成長する動的テーブル、Huffman 符号化されたリテラルを組み合わせます。ヘッダーセットをエンコードすることは WriteLn(Stream, 'Content-Type: application/json') ではなく — 2 つのテーブルへのルックアップ、indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed 表現の選択、任意の Huffman 圧縮、両エンドポイントが同期して保持しなければならない動的テーブルへの更新です。

テーブルサイズを誤ると接続は COMPRESSION_ERROR で死にます。max header table size の SETTINGS 更新を適用し忘れると、これまで動いていたサーバーが要求を拒否し始めます。近道はありません — HPACK を完全に実装するか、HTTP/2 を持たないかです。私たちは静的テーブル、退避付き動的テーブル、Huffman エンコード/デコード、4 つの表現形式をおよそ 1,200 行の Pascal で実装しました。そのコードは Indy のどこにも存在せず、Indy 内に自然な居場所もなかったはずです。

フロー制御はストリームごと・接続ごと

HTTP/2 には 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 の上にボルト留めしようとすると、要求あたり 1 ブロッキング呼び出しという呼び出し規約全体が間違った形であることに即座に気づきます。

ALPN、設定ネゴシエーション、接続ライフサイクル

TLS 上の HTTP/2 は ALPN(Application-Layer Protocol Negotiation)で選択され、ALPN はハンドシェイク前に TLS コンテキストで設定する必要があります。Indy の OpenSSL IO ハンドラーは長年 ALPN を公開していませんでした — 最終的に追加されました — が、ALPN は最初のステップにすぎません。TLS ハンドシェイク後、クライアントは 24 バイトの接続プリフェイス、次に SETTINGS フレームを送り、サーバーの SETTINGS を待って ACK しなければなりません。それでようやく接続は要求の準備が整います。Windows の SChannel は OpenSSL とは異なる ALPN セットアップ経路を要求します。msquic はさらに別です。これらをきれいに抽象化するのは、Indy の既存 TLS ハンドシェイクコードを通すよりも、クリーンなモジュールで行う方が容易でした。

パフォーマンス: 数値

これらすべての作業の見返りは測定可能です。代表的なワークロード — Delphi クライアントがモダンな Windows サーバー上で APNs(HTTP/2 モードの apns2)に 100 万件の Apple Push Notification を送信:

同じハードウェアと同じネットワークで、アプリケーションロジックを変更せずに約 10 倍の改善です。これが、並列 HTTP/1.1 接続で偽装するのではなく、スタックをネイティブに構築したときに HTTP/2 多重化が提供する価値です。

Indy から再利用したもの

すべてを書き直したわけではありません。Indy の OpenSSL TIdSSLIOHandlerSocketOpenSSL を基盤 TLS プロバイダーの 1 つとして使用し(独自の ALPN パッチ付き)、デフォルトバックエンドの生ソケット層には Indy の TIdTCPClient、HTTP/1.1 フォールバックには Indy の TIdHTTPServer を残します。HTTP/2 層はその上に乗り、読み取りループ、フレームパーサー、HPACK、フロー制御、ストリームライフサイクルを所有します。この分割により、TLS 配管を共有しつつ HTTP/2 セマンティクスを Indy のブロッキング呼び出しモデルから独立して保てます。

他のプロトコルへの教訓

同じパターンは sgcWebSockets 全体で繰り返されます。WebSocket は HTTP アップグレードに付属するバイナリフレーミングプロトコル — フレーマーをゼロから書きました。MQTT 5 は独自の可変長整数エンコーディング、プロパティテーブル、フロー制御を持ちます — ゼロから記述。AMQP 1.0 には完全な型システムとリンククレジットフローがあります — ゼロから記述。HTTP/3 は QUIC 上で TCP ではありません — msquic / ngtcp2 をラップ。共通の糸は、あらゆるモダンプロトコルは独自の状態機械を持ち、それを「HTTP/1.1 のもう 1 つのモード」と装うと脆く遅いコードに陥るということです。

まとめ

Indy を拡張するのは自然な第一直感です — すべての Delphi 開発者がすでに信頼するライブラリだからです。しかし HTTP/2 は HTTP/1.1 の拡張ではなく、再設計です。フレーム多重化、HPACK、フロー制御、順不同応答のそれぞれが、ブロッキング HTTP/1.1 クライアントには存在しないインフラを必要とします。これをネイティブに構築するには実体のあるエンジニアリング努力が必要でしたが、それが HTTP/2 が約束する桁違いのパフォーマンスを届ける唯一の方法であり — それ以来 sgcWebSockets が出荷したすべての他のモダンプロトコルの雛形を設定しました。