📖

令和5年になったのでそろそろUnityでHTTP/3通信したい

2023/03/21に公開

Qiita で Unity で HTTP/3 通信してみる記事を書いてから約三年の月日が経過しました。

Re: C#(Unity)でHTTP/3通信してみる その壱 ~OSSの選定からビルドまで~
Re: C#(Unity)でHTTP/3通信してみる その弐 ~Windowsでquicheのサンプルを動かしてみる~
Re: C#(Unity)でHTTP/3通信してみる その参 ~Unityから使ってみる~

記事を書いていた当時は、「三年後には HTTP/3 の RFC も発行されているだろうし、 Unity のデフォルトの API で HTTP/3 や QUIC 通信できているだろうな」と思っていました。
しかし、現実はそこまで甘くなかったようで、まだ Unity で HTTP/3 通信を気軽に行うことはできません……。

そこで、当記事では Unity で HTTP/3 通信してみる令和5年版として、上記の記事をまとめ直してみようと思います。
内容の特性上上記記事と重複するような内容もありますが、なるべく最新の情報にブラシュアップして掲載していますのでご容赦ください。

本題に入る前に: nhh3 について

今回の記事で紹介している内容を基に作成した Unity で HTTP/3 通信を行う実験的実装を nhh3 (Ver: 0.3.0) として公開しています。
まずはサクッと Unity 上で HTTP/3 通信を行ってみたいんだ、という方は途中の記載をスキップして、当記事最後の
Unity 上で HTTP/3 通信を行う実験的実装 nhh3 の紹介
に進んでください。

HTTP/3 (QUIC) の仕様について

当記事では HTTP/3 及びそのベースプロトコルである QUIC の仕様については理解している前提で話を進めます。
仕様の理解に不安がある方は以下で予習を行ってみてください。

  • 後藤ゆきさん著の HTTP/3入門 を読む (無料)
    • HTTP の成り立ちから HTTP/2 の解説まで含まれており、非常に分かり易くお勧めです
  • 筆者著の 『くいっく』 HTTP/3 編 を読む (1,500 円)
    • 『くいっく』は対話形式で気軽にプロトコルについて学ぶことを目的とした技術同人誌シリーズです
    • 対話パートと解説パートが分かれているので、気楽にざっと仕様を把握することも、仕様を深掘りすることもできます

ゴールを決める

今回記事を書くにあたって、(色々な大人の都合により)ゴールを
 Unity で HTTP/3 通信を気軽に試すことができる実験的実装を完成させる
に変更しました。
利用者はこの実装で HTTP/3 の検証を行って、導入したい場合は各自で製品利用に耐えうるものを実装頂ければと思います。
対応プラットフォームについては気軽に試し易い(+個人的に開発し易い) Windows/Android 限定としました。
Windows については、 MsQuic が Windows 10 に標準対応していない[1]関係で、将来的に使えるようになるであろう Unity (もしくは .Net) 標準 API での HTTP/3 動作も Windows 11 以降限定となる可能性がある[2]ので、特に需要があるのではないかと思います。

利用する OSS を決める

(こちらも大人の事情で)以前の記事から継続して Cloudflare の quiche を使うものとします。

というだけだと記事としては不完全なので、現段階で Unity で HTTP/3 を実装したい場合に良さそうなものを以下にピックアップします。

Cloudflare quiche

今回採用している quiche は CDN ベンダーの CloudFlare が主導して開発を進めている Rust 製の OSS です。
Rust API の上に被せる C 言語製のラッパも提供しているのも特徴で、 C/C++ のアプリケーションやライブラリからも簡単に呼び出すことができます。
関数の実装がプリミティブで socket も隠蔽されていないので、ゲーム業界的には使い易いと思います。
ただし、その分実装に HTTP/3 及び QUIC の知識を求められる点には注意が必要です。

libcurl

libcurl は CURL の内部実装である C 言語製の OSS です。
2023 春にリリースが予定されている 8 系ではついに HTTP/3 の experimental が外れ、正式対応となるそうです
また、 HTTP/3 の接続確立までに想定より時間が掛かっている場合に、下位バージョンの HTTP で並列してネゴシエーションを行う happy eyeballs[3] も実装されている等、従来の HTTP にも対応した上で HTTP の機能が非常に豊富なのが強みです。
libcurl 自体には QUIC, HTTP/3 層の実装は含まれておらず、他の OSS (quiche/nghttp3/msh3) に依存しています。

LiteSpeed QUIC (LSQUIC)

LSQUIC は Web サーバである LiteSpeed の開発を手掛ける LiteSpeed Technologies が主導する C 言語製の OSS です。
LiteSpeed Web サーバー、LiteSpeed ADC 、OpenLiteSpeed 等の LiteSpeed Technologies の製品・サービスにて利用されているようです。
対応プラットフォームが広く、Linux, FreeBSD, MacOS, Windows に加えてモバイル系もサポートしています。
拡張機能の実装にかなり意欲的で、先行して様々な機能を試したい場合にはとてもありがたい存在です。
専用のメモリマネージャも搭載されているので、メモリが何かと気になるゲームとの相性も悪くないと思います。

補足 : おすすめはどれか

Unity が HTTP/3 が使えるようになった .Net 7 以降に対応するのはまだまだ先な気配なので、 Unity の HTTP/3 対応を待つ、というのは厳しい状況かと思います(HTTP/2 すらまだですし……)。
その上で、今から新規に実装を始めるのであれば、 HTTP/2 へのフォールバックも可能で HTTP/3 正式対応間近の libcurl 一択かな、と思います。
ただし、 libcurl はパラメータ設定においてかなりの部分が隠蔽されてしまうので、細かい制御があまり効かない点には注意してください[4]
通常のゲームやアプリにおける HTTP/3 通信で QUIC 関連のパラメータをいじりたいケースは稀なのであまり気にしないで良いとは思いますが、利用する際は心にとめておいた方が良いと思います。

quiche を使用する

使用する OSS が quiche に決まったので、次はその使い方について解説します。

  1. quiche をビルドする
  2. quiche の使い方に関する Tips
  3. quiche のアドバンスドな解説
  4. ハマった問題の共有(未解決問題含む)
  5. 過去の記事で共有した問題
  6. Unity 上で HTTP/3 通信を行う実験的実装 nhh3 の紹介

また、今回の記事では、以前の記事同様に以下の点について重点的に説明します

  • quiche の組み込み方法・注意事項
  • HTTP/3 通信を行うライブラリ実装時の注意事項

以下の内容については紹介しない、または軽く触れるに留めるのでご注意ください。

  • Unity からの Native ライブラリの使い方、及びマーシャリング
  • 最適化

quiche をビルドする

quiche は RUST 製の OSS ですが、C 製のラッパも提供してくれています。
今回はこの C ラッパ層を呼び出せるような Windows の動的/静的ライブラリを作成し、それを Unity から呼び出すことにします。
C# から直接 Rust 製の .dll の呼び出しも可能なので、慣れている人はこうしたラッパを作らずにそのまま Rust でビルドしても良いと思います。
(C# 層から使い易いようにどちらにせよ一段噛ませた方が良い印象はあります)

quiche のビルドには cargo build を用います。
cargo build は Rust のビルドシステム&パッケージマネージャである Cargo を使ったビルドコマンドです。
今回は Windows 環境でこの Cargo を使って Windows/Android 版の quiche をビルドする例を紹介します。

quiche の clone

まずは quiche のリポジトリを clone してくる必要があります。
リポジトリの URL は https://github.com/cloudflare/quiche です。
quiche のリポジトリが BoringSSL を submodule として取り込んでいるので、 --recursive 付きで clone しましょう。

$ git clone --recursive https://github.com/cloudflare/quiche

既に clone 済みの場合は

$ submodule update --init --recursive

等で BoringSSL を展開してください。

Rust 環境のセットアップ

前述した通り、 quiche のビルドには Rust のビルドシステム&パッケージマネージャである Cargo が必要です。
Cargo は Rust に付属されているので、まずは Rust をインストールします。
@euledge さんが Windows10でRustの開発環境を構築 をまとめてくださっているので、これを参考に環境を構築してください(詳細は割愛)。
現状 Rust のバージョンは 1.66 かそれ以降のものを利用する必要があるようです。
今回は 1.67.1 を使用しています。

Windows 版のビルド

quiche では依存関係モジュールも cargo build 一発で一緒にビルドしてくれるように整えてくれていますが、ビルド環境は自前で整える必要があります。
cargo build で Windows 版 quiche する際には以下のモジュールが必要です(BoringSSL が使用します)。

  • CMake
  • NASM
  • C/C++ コンパイラ

1つずつセットアップしていきましょう。

CMake のインストール

3.1 以上が必要です。
https://cmake.org/download/ から Windows 用のインストーラーをダウンロードして展開しましょう。
今回は CMake 3.26.0-rc2 を使用しています。

NASM のインストール

NASM は Netwide Assembler の略で Intel x86 系を対象としたアセンブラです。
BoringSSL が一部にアセンブリを使用しているようでこの NASM も必要です。
https://www.nasm.us/ からダウンロードしてパスを通しましょう。
今回は nasm 2.16.01 を使用しています。

C/C++ コンパイラ

Platform SDK 8.1 以降を含む MSVC 15(Visual Studio 2017) 以降が必要です。
GCC 6.1 以降や Clang でもいけるようですが、 BoringSSL のドキュメントの表記が maybe なので MSVC 側を使う方が無難そうです。
CMake と合わせて使う際には MSVC の cl.exe へのパスを通してあげる必要がありますが、単体でパスを通すのではなく関連の環境変数をまとめせて設定してくれる vcvars64.bat 等を使いましょう。
参照 コマンドラインからMicrosoft C ++ツールセットを使用する(MSDN)
今回は Visual Studio 2022 を使用しています。

quiche のビルド

上記モジュールの準備が整ったら(パスを通す必要があるので注意!)いよいよビルドです。
今回は C/C++ から呼び出すので、 C 言語から呼び出すのに必要な FFI API を有効にする必要があります。
オプションとして --features ffi を付けてビルドしましょう。
Debug ビルドは特にその他のオプションは不要です。

$ cargo build --features ffi

Release ビルドをしたい場合は --release をつけてビルドしましょう。

$ cargo build --features ffi --release

ビルドが成功すると quiche のカレントディレクトリ直下にある target\debug, target\release フォルダ内に .lib/.dll が生成されます。

また、この手順で生成されるのはランタイムライブラリは MD 、プラットフォームは x64 であることに注意してください(x86 ビルドの方法は未調査です)。
ログの詳細が欲しい場合は --verbose オプションを付けると若干詳しい内容が出てきます。
ビルドが上手くいかない時は cargo clean で一度一時ファイルを削除してから試してみてください。

おまけ: quiche を MT でビルドする

環境変数 RUSTFLAGS に MT であることを明示する以下のオプションを設定してから cargo build すればいけるようです。

RUSTFLAGS=-C target-feature=+crt-static

参考 : https://doc.rust-lang.org/reference/linkage.html

Android 版のビルド

Android 版の quiche の Windows 環境でのビルドはオフィシャルにはサポートされていません。
cargo build のみでなんとかビルドしたかったのですが、 boringssl のビルドがどうしても通らなかったので、 boringssl のみ cmake で個別にビルドを行いましょう。
(Linux 環境であれば quiche の Readme.md に書いてある方法で簡単にビルドできると思います)

手順としては以下の通りです。

  1. Rust NDK 環境を整える
    • Android NDK も必要
  2. BoringSSL のビルド
    • Ninja 環境が必要
  3. quiche のビルド
    • ビルドコンフィグ Cargo.toml を一部書き換えてビルドを実行する

Android NDK

Rust の NDK 環境を整える前にまずは Android NDK 環境を準備する必要があります。
Android Studio をインストールして同梱されている SDK Manager を使う手もありますが、今回は NDK のみで良いので単品でダウンロードを行います。
必要な NDK のバージョンは以下の通りです。

  • arm64-v8a
    • NDK 21 が必要です
  • armeabi-v7a
    • NDK 19 が必要です (BoringSSL の縛り)

アプリをビルドする際の Minimum API Level の設定は 21 以上で共通です。

NDK のダウンロードが終わったら、配置した個所に対して環境変数 ANDROID_NDK_HOME を設定してあげましょう。

ANDROID_NDK_HOME=G:\develop\android-ndk\android-ndk-r21e

また、今回は C/C++ コンパイラに Visual Studio を使うので、 VS にもこの NDK のパスを通してあげる必要があります。

Rust(Cargo) NDK

Android NDK 環境が整ったら次は Rust の NDK 環境を整えましょう。
quiche が要求する cargo-ndk のバージョンは v2.0 以上です。

まずは必要な環境の target を追加します。
以下をビルドしたい環境に合わせて改変して実行してください。

$ rustup target add \
    aarch64-linux-android \
    armv7-linux-androideabi \
    x86_64-linux-android \
    i686-linux-android

target を追加したら、あとは cargo install してあげれば OK です。

$ cargo install cargo-ndk

Ninja

Ninja は CMake の置き換えを目指して作られた高速なビルドシステムです。
BoringSSL をビルドするには、 この Ninja を使うか CMake のみで実施するかの二つの選択肢があるようです。
しかし、 Windows 環境での CMake のみでのビルドはメンテされていないようなので、現状では Ninja を使うしかなさそうです[5]
と言う訳で https://github.com/ninja-build/ninja/releases から Ninja を落としましょう。
ビルド実行時に CMake オプションの -DCMAKE_MAKE_PROGRAM にこの Ninja のパスを渡す為にシステム側にパスを通す必要はありません。
今回は Ninja 1.11.0 を使用しています。

BoringSSL のビルド

NDK 環境を整えたらあとは Ninja でビルドを実行するのみです。
quiche の Cargo に準じる形でオプションの設定を行いつつビルドを実行します。

$ cmake -DANDROID_ABI=arm64-v8a \
        -DANDROID_PLATFORM=android-21 \
        -DCMAKE_TOOLCHAIN_FILE=%ANDROID_NDK_HOME%/build/cmake/android.toolchain.cmake \
        -DANDROID_NATIVE_API_LEVEL=21 \
        -DANDROID_STL=c++_shared \
        -DCMAKE_BUILD_TYPE=Debug \
        -DCMAKE_MAKE_PROGRAM={ninja.exeのパス}
        -GNinja
$ call {ninja.exeのパス}

前述した通り、 -DCMAKE_MAKE_PROGRAM オプションには ninja.exe のフルパスを渡してください。
Release 版をビルドしたい際は -DCMAKE_BUILD_TYPE に Release を指定すれば OK です。

quiche のビルド

これでいよいよ Android 版 quiche のビルドに入れますが、 自前で BoringSSL をビルドしたいので、ビルドの前にコンフィグ Cargo.toml を一部書き換える必要があります。
quiche\Cargo.toml の以下の処理から

features = ["boringssl-boring-crate", "qlog"]

"boringssl-boring-crate" を取り除きます。

features = ["qlog"]

これで BoringSSL のビルドをスキップして、ビルド済みの BoringSSL バイナリをリンクする形で quiche をビルドできるようになります。

後は Windows 版ビルド同様に cargo build するだけです。
-t でアーキテクチャを、 -p で NDK バージョンを指定してあげる必要があるのでこの点だけ注意しましょう。

$ cargo ndk -t arm64-v8a -p 21 build --features ffi

Windows 同様にこのコマンドだと Debug 版がビルドされます。
Release 版のビルドをしたい場合は --release を追加してビルドを実行してください。

ビルド bat について

後述する nhh3 には quiche の Windows/Android をビルドする bat も含まれています。
必要なモジュールへのパスは自前で通す必要はありますが、その他の部分については自動化されているので参考にしてみてください。

quiche の使い方に関する Tips

quiche がビルドできたので次は簡単な使い方の解説です。
quiche\examples に Windows で動作する C 言語製のサンプルがありますので、この中の http3-client.c ベースで説明を行っていきます。
使用するサンプルのバージョンは 2a6ee1c5aca567cb12f215e69d0bb18a657769bf です。

注意事項

quiche には C 言語実装のリファレンスは用意されていません。
幸いなことに Rust 実装のリファレンスは存在しています ので、随時読み替えながら理解を進めていくのが良さそうです。
例えば Rust 側の quiche::h3::Config 名前空間のモジュールは C 言語側では quiche_h3_config_XXXX で定義されています。

また、この後の記事内容は HTTP/3, QUIC の仕様をある程度理解している前提で進めます。
以下の仕様概要を把握していないと実装の流れが理解できない所があるのでご注意ください。

  • コネクション管理の流れ
  • ハンドシェイクと初期化パラメータ設定の流れ
  • バージョンネゴシエーション
  • コネクションマイグレーション
  • 0-RTT/1-RTT
  • QPACK

QUIC コネクションの作成

まず quiche が QUIC コネクションを管理するためのオブジェクト quiche_conn を生成します。
流れとしては以下の通りです。

  1. L367 : UDP socket を作成
  2. L384 : quiche のコンフィグ設定(QUIC)を行う
  3. L411 : SCID を生成する
  4. L418 : socket の IP アドレスと Port 番号取得
  5. L438 : QUIC のコネクションを作成する(実際の通信開始はまだ)

UDP socket を作成, socket の IP アドレスと Port 番号取得

quiche はデータの送受信には各プラットフォームの socket を使用します。
quiche_conn を生成するための関数 quiche_connect() ではこのソケットの IP アドレスと Port を要求するので、先に UDP socket を作成し、getsockname() で値を取得しておく必要があります。

quiche のコンフィグ設定(QUIC)

quiche では QUIC のコンフィグと HTTP/3 のコンフィグを別々に設定します。
まずは quiche_config_new() で quiche のコンフィグを作成し、 quiche_config_xxx() で各種の設定を行います。

http3-client.c
quiche_config *config = quiche_config_new(0xbabababa);
if (config == NULL) {
    fprintf(stderr, "failed to create config\n");
    return -1;
}

quiche_config_set_application_protos(config,
    (uint8_t *) QUICHE_H3_APPLICATION_PROTOCOL,
    sizeof(QUICHE_H3_APPLICATION_PROTOCOL) - 1);

quiche_config_set_max_idle_timeout(config, 5000);
quiche_config_set_max_recv_udp_payload_size(config, MAX_DATAGRAM_SIZE);
quiche_config_set_max_send_udp_payload_size(config, MAX_DATAGRAM_SIZE);
quiche_config_set_initial_max_data(config, 10000000);
quiche_config_set_initial_max_stream_data_bidi_local(config, 1000000);
quiche_config_set_initial_max_stream_data_bidi_remote(config, 1000000);
quiche_config_set_initial_max_stream_data_uni(config, 1000000);
quiche_config_set_initial_max_streams_bidi(config, 100);
quiche_config_set_initial_max_streams_uni(config, 100);
quiche_config_set_disable_active_migration(config, true);

以下個別の補足です。
サンプルに記載されていないものについてもピックアップして解説してあります。

  • quiche_config_new()
    • サンプルだとバージョンネゴシエーションを行う 0xbabababa が渡されています
      • 0x?a?a?a?a の書式パターンに従うバージョンを入れると、バージョンネゴシエーションを強制することが可能です
      • バージョンネゴシエーションを行うとハンドシェイクが 1RTT 分増加するので注意しましょう
    • 実際に使う場合には quiche.h で定義されている QUICHE_PROTOCOL_VERSION を渡しましょう
  • quiche_config_set_application_protos()
    • quiche に HTTP/3 の ALPN token を設定します
    • quiche.h に定義されている QUICHE_H3_APPLICATION_PROTOCOL を渡せば OK です
  • quiche_config_verify_peer
    • 証明書の検証の設定を行います
  • quiche_config_set_cc_algorithm()
    • 輻輳制御アルゴリズムの指定します
  • quiche_config_set_max_idle_timeout()
    • トランスポートパラメータ max_idle_timeout (ミリ秒) を設定します
    • 0 を指定する事でタイムアウトが無制限になります
  • quiche_config_set_max_send_udp_payload_size()
    • トランスポートパラメータ max_udp_payload_size (送信時) を設定します
    • PMTU の実装を鑑みて設定を行ってください
  • quiche_config_set_initial_max_data()
    • トランスポートパラメータ initial_max_data を設定します
    • コネクションに対する上限サイズです
  • quiche_config_set_initial_max_stream_data_bidi_local()
    • トランスポートパラメータ initial_max_stream_data_bidi_local を設定します
    • ローカル始動の双方向ストリームの初期フロー制御値です
  • quiche_config_set_initial_max_stream_data_bidi_remote()
    • トランスポートパラメータ initial_max_stream_data_bidi_remote を設定します
    • ピア始動の双方向ストリームの初期フロー制御値です
  • quiche_config_set_initial_max_stream_data_uni()
    • トランスポートパラメータ initial_max_stream_data_uni を設定します
    • 単方向ストリームの初期フロー制御値です
  • quiche_config_set_initial_max_streams_bidi()
    • トランスポートパラメータ initial_max_streams_bidi を設定します
    • 作成可能な双方向ストリームの最大値です
  • quiche_config_set_initial_max_streams_uni()
    • トランスポートパラメータ initial_max_streams_uni を設定します
    • 作成可能な単方向ストリームの最大値です
  • quiche_config_set_disable_active_migration()
    • トランスポートパラメータ disable_active_migration を設定します
    • コネクションマイグレーションに対応していない場合は true にします
  • quiche_config_set_max_connection_window()
    • コネクションに用いる最大ウィンドウサイズを設定します
  • quiche_config_set_max_stream_window()
    • ストリームに用いる最大ウィンドウサイズを設定します
  • quiche_config_set_active_connection_id_limit()
    • アクティブなコネクション ID の上限値を設定します
  • quiche_config_log_keys()

SCID の生成

SCID はクライアント側で一意になるように値を生成する必要があります

http3-client.c
uint8_t scid[LOCAL_CONN_ID_LEN];
int rng = open("/dev/urandom", O_RDONLY);
if (rng < 0) {
    perror("failed to open /dev/urandom");
    return -1;
}

ssize_t rand_len = read(rng, &scid, sizeof(scid));
if (rand_len < 0) {
    perror("failed to create connection ID");
    return -1;
}

QUIC のコネクションを作成する

quiche_connect() で QUIC のコネクションを作成します。

http3-client.c
quiche_conn *conn = quiche_connect(host, (const uint8_t *) scid, sizeof(scid),
                                   (struct sockaddr *) &conn_io->local_addr,
                                   conn_io->local_addr_len,
                                   peer->ai_addr, peer->ai_addrlen, config);

ハンドシェイク(1-RTT)の流れ

QUIC のコネクションを作成したらいよいよハンドシェイクです。
ここでは 1-RTT のハンドシェイクについての解説を行います(0-RTT は後述します)。

  1. L74 : quiche_conn_send() で Initial Packet を生成する
  2. L87 : sendto() で上記で作成した Initial Packet を送信する
  3. L134 : recvfrom() でサーバからの応答を受け取る(Initial/Handshake/1-RTT Packet)
  4. L156 : quiche_conn_recv() で受信したデータを quiche 側に渡す
  5. L74 : 必要なデータを全て受信し終えるまで 3,4 を繰り返す

quiche_conn_send() は呼び出すだけで引数の quiche_conn のステータスに応じたデータを勝手に作成してくれます。
quiche_connect() 後の初の呼び出しでは Initial Packet のデータを作成してくれます。
送受信の実処理はユーザ側で実装する必要があるので、初期化時に作成した UDP socket を使って sendto()/recvfrom() を呼び出します。
5 の判定については quiche_conn_is_established() 等を使用します(詳細は後述)。

パケットの送信処理

前述したように、 quiche_conn_send()quiche_conn の内部ステータス(コネクションやストリーム状況)に応じて適切なデータを生成してくれるようです。
なので、実装は quiche_conn_send() で返ってきたデータを sendto() で送信するだけでよく、特に注意事項はありません。

http3-client.c
static uint8_t out[MAX_DATAGRAM_SIZE];

quiche_send_info send_info;

while (1) {
    ssize_t written = quiche_conn_send(conn_io->conn, out, sizeof(out),
                                       &send_info);

    if (written == QUICHE_ERR_DONE) {
        fprintf(stderr, "done writing\n");
        break;
    }

    if (written < 0) {
        fprintf(stderr, "failed to create packet: %zd\n", written);
        return;
    }

    ssize_t sent = sendto(conn_io->sock, out, written, 0,
                          (struct sockaddr *) &send_info.to,
                          send_info.to_len);

    if (sent != written) {
        perror("failed to send");
        return;
    }

    fprintf(stderr, "sent %zd bytes\n", sent);
}

double t = quiche_conn_timeout_as_nanos(conn_io->conn) / 1e9f;
conn_io->timer.repeat = t;
ev_timer_again(loop, &conn_io->timer);

パケットの受信処理

これまた recvfrom で受け取ったデータを quiche_conn_recv() へ受け流せば OK です。

static bool req_sent = false;
static bool settings_received = false;

struct conn_io *conn_io = w->data;

static uint8_t buf[65535];

while (1) {
    struct sockaddr_storage peer_addr;
    socklen_t peer_addr_len = sizeof(peer_addr);
    memset(&peer_addr, 0, peer_addr_len);

    ssize_t read = recvfrom(conn_io->sock, buf, sizeof(buf), 0,
                            (struct sockaddr *) &peer_addr,
                            &peer_addr_len);

    if (read < 0) {
        if ((errno == EWOULDBLOCK) || (errno == EAGAIN)) {
            fprintf(stderr, "recv would block\n");
            break;
        }

        perror("failed to read");
        return;
    }

    quiche_recv_info recv_info = {
        (struct sockaddr *) &peer_addr,
        peer_addr_len,

        (struct sockaddr *) &conn_io->local_addr,
        conn_io->local_addr_len,
    };

    ssize_t done = quiche_conn_recv(conn_io->conn, buf, read, &recv_info);

    if (done < 0) {
        fprintf(stderr, "failed to process packet: %zd\n", done);
        continue;
    }

    fprintf(stderr, "recv %zd bytes\n", done);
}

ハンドシェイク完了の確認

ハンドシェイクが完了したかどうかに関しては quiche_conn_is_established() で確認可能です。

http3-client.c
if (quiche_conn_is_established(conn_io->conn) && !req_sent) {
    const uint8_t *app_proto;
    size_t app_proto_len;

    quiche_conn_application_proto(conn_io->conn, &app_proto, &app_proto_len);

    fprintf(stderr, "connection established: %.*s\n",
            (int) app_proto_len, app_proto);

ハンドシェイクが完了した後に quiche_conn_application_proto() を呼び出すとハンドシェイクを行った ALPN の取得ができます。

HTTP/3 通信の流れ

ハンドシェイクが完了したら HTTP/3 のコネクションを作成し、 HTTP 通信を行うことができるようになります。
HTTP/3 の通信を行う流れは以下の通りです。

  1. L184 : HTTP/3 用のコンフィグの設定を生成する
  2. L190 : HTTP/3 管理用のオブジェクトを作成する
  3. L198 : HTTP フィールド(ヘッダ)を作成する
  4. L240 : ストリームの生成を行う(ここで初めてストリーム ID が付与)
  5. L74 : quiche_conn_send()/quiche_conn_recv() でデータの送受信を行う
  6. L253 : quiche 側のイベントを検出し、イベントにあった処理を実施する

1,2 については一度実施すれば良く、 3 以降はリクエスト毎に実施する必要があります。

quiche のコンフィグ設定(HTTP/3)

HTTP/3 のコンフィグには QPACK 関連設定とフィールド(ヘッダ)リストの最大登録数、拡張 CONNECT プロトコルの設定が可能です。

http3-client.c
quiche_h3_config *config = quiche_h3_config_new();
if (config == NULL) {
    fprintf(stderr, "failed to create HTTP/3 config\n");
    return;
}

以下各コンフィグの簡単な説明です。
QUIC のコンフィグ同様にサンプルに記載されていないものについても解説してあります。

  • quiche_h3_config_set_max_field_section_size()
    • SETTINGS パラメータ SETTINGS_MAX_HEADER_LIST_SIZE を設定します
    • フィールド(ヘッダ)リストに登録できるフィールド(ヘッダ)の最大数です
  • quiche_h3_config_set_qpack_max_table_capacity()
    • SETTINGS パラメータ SETTINGS_QPACK_MAX_TABLE_CAPACITY を設定します
    • QPACK の動的テーブルの最大値です
    • quiche はまだ QPACK の dynamic table への対応が完了していない[6]ので 0 を指定しておきましょう
  • quiche_h3_config_set_qpack_blocked_streams()
    • SETTINGS パラメータ SETTINGS_QPACK_BLOCKED_STREAMS を設定します
    • ブロックされる可能性のあるストリーム数です
  • quiche_h3_config_enable_extended_connect()
    • SETTINGS パラメータ SETTINGS_ENABLE_CONNECT_PROTOCOL を設定します
    • 拡張 CONNECT プロトコルの設定です

HTTP/3 管理用のオブジェクトを作成

HTTP/3 の各種通信を quiche が管理する為のオブジェクト quiche_h3_conn を作成します。
この quiche_h3_conn は QUIC のコネクションに紐付き、 HTTP 関連処理に使用されます。

http3-client.c
conn_io->http3 = quiche_h3_conn_new_with_transport(conn_io->conn, config);
if (conn_io->http3 == NULL) {
fprintf(stderr, "failed to create HTTP/3 connection\n");
return;
}

HTTP フィールド(ヘッダ)を作成する

ここからはリクエスト毎に実施する必要がある作業です。
まず HTTP のフィールド(ヘッダ)を quiche_h3_header 構造体に設定します。

http3-client.c
quiche_h3_header headers[] = {
    {
        .name = (const uint8_t *) ":method",
        .name_len = sizeof(":method") - 1,

        .value = (const uint8_t *) "GET",
        .value_len = sizeof("GET") - 1,
    },

    {
        .name = (const uint8_t *) ":scheme",
        .name_len = sizeof(":scheme") - 1,

        .value = (const uint8_t *) "https",
        .value_len = sizeof("https") - 1,
    },

    {
        .name = (const uint8_t *) ":authority",
        .name_len = sizeof(":authority") - 1,

        .value = (const uint8_t *) conn_io->host,
        .value_len = strlen(conn_io->host),
    },

    {
        .name = (const uint8_t *) ":path",
        .name_len = sizeof(":path") - 1,

        .value = (const uint8_t *) "/",
        .value_len = sizeof("/") - 1,
    },

    {
        .name = (const uint8_t *) "user-agent",
        .name_len = sizeof("user-agent") - 1,

        .value = (const uint8_t *) "quiche",
        .value_len = sizeof("quiche") - 1,
    },
};

ストリームの生成を行う

生成した quiche_h3_conn 及び quiche_h3_header を使って quiche_h3_send_request() を呼び出し、ストリームの生成を行います。
quiche_h3_send_request() はいかにも通信しそうな感じの関数名ですが、実際の通信は QUIC の時同様に sendto() 側で行われますので注意してください。

http3-client.c
int64_t stream_id = quiche_h3_send_request(conn_io->http3,
                                           conn_io->conn,
                                           headers, 5, true);

quiche のイベント処理

HTTP リクエストを送信したら、 quiche_h3_conn_poll() で HTTP に関するイベントが quiche から通知されるのを待ちます。

http3-client.c
quiche_h3_event *ev;

while (1) {
    int64_t s = quiche_h3_conn_poll(conn_io->http3,
                                    conn_io->conn,
                                    &ev);

    if (s < 0) {
        break;
    }

    if (!settings_received) {
        int rc = quiche_h3_for_each_setting(conn_io->http3,
                                            for_each_setting,
                                            NULL);

        if (rc == 0) {
            settings_received = true;
        }
    }

    switch (quiche_h3_event_type(ev)) {
        case QUICHE_H3_EVENT_HEADERS: {
            int rc = quiche_h3_event_for_each_header(ev, for_each_header,
                                                     NULL);

            if (rc != 0) {
                fprintf(stderr, "failed to process headers");
            }

            break;
        }

        case QUICHE_H3_EVENT_DATA: {
            for (;;) {
                ssize_t len = quiche_h3_recv_body(conn_io->http3,
                                                  conn_io->conn, s,
                                                  buf, sizeof(buf));

                if (len <= 0) {
                    break;
                }

                printf("%.*s", (int) len, buf);
            }

            break;
        }

        case QUICHE_H3_EVENT_FINISHED:
            if (quiche_conn_close(conn_io->conn, true, 0, NULL, 0) < 0) {
                fprintf(stderr, "failed to close connection\n");
            }
            break;

        case QUICHE_H3_EVENT_RESET:
            fprintf(stderr, "request was reset\n");

            if (quiche_conn_close(conn_io->conn, true, 0, NULL, 0) < 0) {
                fprintf(stderr, "failed to close connection\n");
            }
            break;

        case QUICHE_H3_EVENT_PRIORITY_UPDATE:
            break;

        case QUICHE_H3_EVENT_DATAGRAM:
            break;

        case QUICHE_H3_EVENT_GOAWAY: {
            fprintf(stderr, "got GOAWAY\n");
            break;
        }
    }

    quiche_h3_event_free(ev);
}

イベントには以下の種類があります。

  • QUICHE_H3_EVENT_HEADERS
    • フィールド(ヘッダ)を受信した際に発行されます
    • quiche_h3_event_for_each_header() で受信したフィールド(ヘッダ)を受け取ることが可能です
      • 第二引数にコールバック関数を、第三引数に任意のポインタを渡せるので、これを経由して値を保存します
        • コールバック関数の最後の引数として入ってきます
      • コールバック内で 非 0 を返すと通信を中断することが可能です
  • QUICHE_H3_EVENT_DATA
    • HTTP コンテンツ(ボディ)を受信した際に発行されます
    • quiche_h3_recv_body で複合化された HTTP コンテンツ(ボディ)を quiche から受け取ることが可能です
    • quiche_h3_event_for_each_header とデータの受け取り方が違うので注意が必要です
  • QUICHE_H3_EVENT_FINISHED
    • レスポンスの受信が完了した際に発行されます
    • ストリームのクローズ処理を行いましょう
  • QUICHE_H3_EVENT_RESET
    • HTTP リクエストがリセットされた際に発行されます
    • ストリームのクローズ処理を行いましょう
  • QUICHE_H3_EVENT_PRIORITY_UPDATE
    • PRIORITY_UPDATE フレームを受信した際に発行されます
    • アプリの都合に応じた処理を行いましょう
  • QUICHE_H3_EVENT_DATAGRAM
    • DATAGRAM フレームを受信した際に発行されます
    • アプリの都合に応じた処理を行いましょう
  • QUICHE_H3_EVENT_GOAWAY
    • GOAWAY フレームを受信した際に発行されます
    • アプリの都合に応じた処理を行いましょう

quiche アドバンスド

当項目では基本的な quiche の使い方に関する Tips 項より一歩進んだ、アドバンスドな注意事項や機能の使い方の解説を行います。
0-RTT や Connection Migration についても記載してありますので、興味のある方はご一読ください。
具体的な実装については後述する nhh3 の C/C++ 層である qwfs も参考にしてみてください。

qlog の出力

qlog は現在仕様策定中[7]の HTTP3, QUIC の標準ログ形式です。
また、 qlog を使って HTTP3, QUIC の通信結果の可視を行ってくれる qviz というツールも存在しています。
qviz は https://github.com/quiclog/qvis で公開されているソースコードを自分でビルドするか、 https://qvis.edm.uhasselt.be/#/files にて公開されているサービスかのいずれかにより利用可能です。
以下のような感じで多重化通信を可視化してくれる等、これがない開発は考えられないくらいに便利な代物なので HTTP/3, QUIC のライブラリを利用する際には対応を確認するのをお勧めします。

さて、そんな便利 qlog ですが quiche も勿論対応しています。
quiche_conn_set_qlog_path() を呼び出すことにより qlog が出力されるようになります。

// Enables qlog to the specified file path. Returns true on success.
bool quiche_conn_set_qlog_path(quiche_conn *conn, const char *path,
                          const char *log_title, const char *log_desc);

引数に quiche_conn という quiche の QUIC ハンドルを取ることから分かるように、出力される単位は QUIC のコネクション単位です。
path に既に存在するパスを指定してもファイルを上書き生成してくれます。

信頼された認証局リストの設定

quiche では何も指定しない場合は システムのデフォルト の信頼された認証局リストが使われます。
自前で信頼された認証局リストのファイルパスを指定したい場合は quiche_config_load_verify_locations_from_file() を使用します。
Windows で動作させる場合は当関数の設定は不要ですが、 Android で動作させる場合は設定が必須なのでご注意ください。
後述する nhh3 では Mozilla の 2023-01-10 版を使用しています。

タイムアウトについて

quiche にはタイムアウト関連と思しきモジュールが以下の 4 つあります。

// Sets the `max_idle_timeout` transport parameter.
void quiche_config_set_max_idle_timeout(quiche_config *config, uint64_t v);

// Returns the amount of time until the next timeout event, in nanoseconds.
uint64_t quiche_conn_timeout_as_nanos(quiche_conn *conn);

// Returns the amount of time until the next timeout event, in milliseconds.
uint64_t quiche_conn_timeout_as_millis(quiche_conn *conn);

// Processes a timeout event.
void quiche_conn_on_timeout(quiche_conn *conn);

quiche_config_set_max_idle_timeout() は前述した通りトランスポートパラメータの max_idle_timeout の設定です。
ここで設定した時間応答がない場合は quiche 内でタイムアウト処理が走り quiche_conn_is_closed() が true を返すようになるようです。

quiche_conn_timeout_as_millis() 及び quiche_conn_timeout_as_nanos() は loss detection 用の関数のようです。
0 が返ってきた際に quiche_conn_on_timeout() を呼ぶことで quiche 側で loss detection の処理を良しなにしてくれます。

ということで、タイムアウトを実装する際には cpp:http3-client.c の実装そのままに、 quiche_conn_timeout_as_millis() または quiche_conn_timeout_as_nanos() が 0 を返した際に以下の処理を行えば良さそうです。

  • quiche_conn_on_timeout() を呼び出す (無条件)
  • quiche_conn_is_closed() が true を返して来たらコネクションのクローズ処理を行う
http3-client.c
static void timeout_cb(EV_P_ ev_timer *w, int revents) {
    struct conn_io *conn_io = w->data;
    quiche_conn_on_timeout(conn_io->conn);

    fprintf(stderr, "timeout\n");

    flush_egress(loop, conn_io);

    if (quiche_conn_is_closed(conn_io->conn)) {
        quiche_stats stats;
        quiche_path_stats path_stats;

        quiche_conn_stats(conn_io->conn, &stats);
        quiche_conn_path_stats(conn_io->conn, 0, &path_stats);

        fprintf(stderr, "connection closed, recv=%zu sent=%zu lost=%zu rtt=%" PRIu64 "ns\n",
                stats.recv, stats.sent, stats.lost, path_stats.rtt);

        ev_break(EV_A_ EVBREAK_ONE);
        return;
    }
}

0-RTT を使う

quiche は 0-RTT に対応していますが、設定を有効にするだけではダメで、ある程度自前で関連関数を呼び出す必要があります。
以下に処理の流れを記載しますので参考にしてみてください。

まず、 1-RTT 接続時にセッション情報を保存します。
quiche_conn_session() でセッション情報を取得し、ファイルやメモリなどに保存しておきます。
quiche_conn_session() が 0 を返してきた場合は 0-RTT が有効なセッション情報ではない、もしくは保存がまだできないタイミングです。
quiche から NEW_TOKEN フレームを受信したタイミングを取得できないので、コネクションクローズ時に呼び出すのが無難かと思います。
1-RTT の時だけではなく、 0-RTT の際にもセッション情報を保存すると次の接続でも 0-RTT を有効にできます。

セッション情報の保存を行ったら次のコネクション時に 0-RTT の設定を有効にして、セッション情報を読み込みます。
後は通常通りハンドシェイクを進めて quiche_conn_is_in_early_data() が true を返すタイミングになったら HTTP リクエストを発行します。
具体的な 0-RTT 接続の流れは以下の通りです。

  1. QUIC コンフィグの設定時に quiche_config_enable_early_data() を呼び出す
  2. quiche_conn 生成後 quiche_conn_set_session() で保存しておいたセッション情報を設定する
    • セッション情報が古い等の判断は quiche 側で判断してくれるので、使う側としては quiche_conn_set_session() が 0 を返してきた時だけ 0-RTT の処理を行う
  3. ハンドシェイクを開始し、quiche_conn_is_in_early_data() が true を返すタイミングになるまで待つ
  4. quiche_conn_is_in_early_data() が true を返して来たら quiche_h3_conn_new_with_transport で HTTP/3 管理オブジェクトを作成して HTTP リクエストを発行する

0-RTT が成功すると以下のようにハンドシェイク完了前に 0-RTT でリクエストが送信できていることが確認できます。

Connection Migration を使う

0-RTT 同様に Connection Migration に関しても実装が必要です。
更に C ラッパ層の quiche.h に必要な関数がまだ公開されていないので、 C 言語から Connection Migration を使う場合は quiche の改変が必要です。
改変で持ってくる必要がある関数は以下の通りです。

ffi.rs
#[no_mangle]
pub extern fn quiche_new_source_cid(conn: &mut Connection, scid: *const u8, scid_len: size_t, v: *const u8, retire_if_needed: bool) -> bool {
    let scid = unsafe { slice::from_raw_parts(scid, scid_len) };
    let scid = ConnectionId::from_ref(scid);
    let reset_token = unsafe { slice::from_raw_parts(v, 16) };
    let reset_token = match reset_token.try_into() {
        Ok(rt) => rt,
        Err(_) => unreachable!(),
    };
    let reset_token = u128::from_be_bytes(reset_token);

    return match conn.new_source_cid(&scid, reset_token, retire_if_needed) {
        Ok(_) => true,
        Err(_) => false
    };
}

#[no_mangle]
pub extern fn quiche_probe_path(conn: &mut Connection, local_addr: &sockaddr, local_len: socklen_t, peer_addr: &sockaddr, peer_len: socklen_t) -> bool {
    let local = std_addr_from_c(local_addr, local_len);
    let peer = std_addr_from_c(peer_addr, peer_len);
    return match conn.probe_path(local, peer) {
        Ok(_) => true,
        Err(_) => false
    };
}

#[no_mangle]
pub extern fn quiche_available_dcids(conn: &mut Connection) -> usize {
    conn.available_dcids()
}

これを C から呼び出せるように quiche.h に以下の追加を行いましょう。

quiche.h
bool quiche_new_source_cid(quiche_conn* conn, const uint8_t* scid, size_t scid_len, const uint8_t* token, bool retire_if_needed);
bool quiche_probe_path(quiche_conn* conn, const struct sockaddr* local, size_t local_len, const struct sockaddr* peer, size_t peer_len);
size_t quiche_available_dcids(const quiche_conn* conn);

それぞれの関数は以下の処理を行います。

  • quiche_new_source_cid()
    • 新しい SCID を発行します
    • NEW_CONNECTION_ID フレームの発行を誘発します
  • quiche_probe_path()
    • Path Validation を開始します
    • PATH_CHALLENGE フレームの発行を誘発します
  • quiche_available_dcids()
    • 未使用の DCID を保有しているか
    • サーバから NEW_CONNECTION_ID フレームを受け取ると未使用の DCID がスタックされていきます

quiche 側への関数追加を行ったら、これを使って Connection Migaration を実装します。
完全に追い切れてはないのでちょっと自信はないですが、自分がソースを読んだ&動作確認した限り、 quiche は Path Validation を発動するquiche_probe_path() に以下の制限があるようです。

  • 事前にサーバから NEW_CONNECTION_ID フレームで新しい DCID を受け取っている[8]
  • quiche_new_source_cid() で新規の SCID を発行している

この条件を満たさない場合 quiche_probe_path() が非 0 を返します。

上記を踏まえた実装例は以下の通りです。

  1. QUIC コンフィグの設定時に quiche_config_set_disable_active_migration() に false を渡して呼び出す
  2. Wi-Fi → キャリア接続等のネットワークの切り替わりを検出
  3. UDP ソケットを作り直す
    • この際 quiche_conn 等の quiche の管理オブジェクトは一切変更しないで良い
  4. quiche_available_dcids() で新しく使える DCID が発行されているか確認
  5. quiche_new_source_cid() で新しい SCID を発行
  6. quiche_probe_path() で Path Validation を開始
  7. ハンドシェイク後と同様の通信処理に戻る
    • Connection Migration が成功した場合、途中だったリクエストが再開される

DCID の状況を確認せずに quiche_probe_path() を呼び出して失敗すると、事前に呼ぶ必要がある quiche_new_source_cid()NEW_CONNECTION_ID フレームが無駄に発行される為、事前に quiche_available_dcids() で事前に条件を満たしているか確認する必要があります。

ただし、この実装だとこちらの PATH_CHALLENGE への PATH_RESPONSE が以下のようにいつまで経っても返ってきません。

quiche の apps\src\client.rs の以下の実装の流れを見る限りほとんど同じだと思うのですが、どこかが間違っているようで、今後調査・修正を行う予定です。
原因が分かり次第当記事にも反映します。

client.rs
// Provides as many CIDs as possible.
while conn.source_cids_left() > 0 {
    let (scid, reset_token) = generate_cid_and_reset_token(&rng);

    if conn.new_source_cid(&scid, reset_token, false).is_err() {
        break;
    }

    scid_sent = true;
}

if args.perform_migration &&
    !new_path_probed &&
    scid_sent &&
    conn.available_dcids() > 0
{
    let additional_local_addr =
        migrate_socket.as_ref().unwrap().local_addr().unwrap();
    conn.probe_path(additional_local_addr, peer_addr).unwrap();

    new_path_probed = true;
}

Rust のデバッグ

Visual Studio 2019 や 2022 では Unity にデバッガをアタッチした状態でそのまま quiche の中にもぐることができます。
しかし、 quiche のデフォルトの設定では debug ビルドでも最適化が働いており、変数や関数そのものが削除されていたりすることが多くあります。

こんな感じでそもそもステップインできなかったりします。かなしい。

そこで、ビルド時に最適化オプションを変更してあげるとデバッグ可能になります。
最適化レベルは [profile.dev][profile.release]opt-level で設定できます。
参考 : https://doc.rust-jp.rs/book/second-edition/ch14-01-release-profiles.html

結果、かなり細かく見れるようになりました!

ハマった問題の共有

上記で紹介した Connection Migration 以外にも、実装中にハマった個所がいくつかあるので共有します。

Dynamic Table 対応しているサーバと通信を行うと失敗する

前述した通り qucihe は QPACK の Dynamic Table に対応していません。
この為、 quiche_h3_config_set_qpack_max_table_capacity() に 0 以外を指定した上で、 www.google.com 等の Dyanamic Table に対応しているサイトと HTTP/3 通信を行おうとすると、以下のように Error parsing headers エラーが発生し、 connection_close フレームが発行されます。

quiche が Dynamic Table に対応するまでは quiche_h3_config_set_qpack_max_table_capacity() は 0 で運用しましょう。

その他の QPACK 関連のパラメータ SETTINGS_MAX_HEADER_LIST_SIZESETTINGS_QPACK_BLOCKED_STREAMS も値が小さいと同様に通信失敗になることがるので設定には注意が必要です。
これは QUIC の各種トランスポートパラメータにも言えるので、何に用いるか、通信相手によって適切に値を設定するように心がけましょう。

Connection Migration 時に getaddrinfo() で失敗する

これは単純に「ネットワーク切り替え時に切り替わり先の準備がまだ整っておらず、 socket 関連の処理に失敗することがある」というだけの話なのですが、何も考えずに Connection Migration へ対応した実装を行うとハマりがちなので共有しておきます。
特に Windows で IPv4 → Ipv6 への切り替わりを試す際にはネットワーク不通期間が結構長いので注意が必要です。
また、ネットワークの切断の理由が根本的に不通になっているケースも多いと思く、そうしたケースではタイムアウトにしたいと思われるので、後述する nhh3 では Connection Migration 専用のタイムアウトを設定することによりこの問題を回避しています。
実際はゲーム性にあわせてここのタイムアウト値は変更するような運用になりそうです。

quiche_h3_recv_body に渡す一時バッファが小さい場合に進行不能になることがある

細かく原因を追えていないのですが、 quiche_h3_recv_body() に渡すバッファのサイズが小さいと、 quiche の内部的には HTTP のボディの受信が完了していても quiche 呼び出し側にはそれが通知されず、進行不能に陥ることがあります。
現在は大きめのバッファを渡すことにより当問題を回避していますが、私の実装ミスの可能性も高そうなので今後問題を追跡調査予定です。

過去の記事で共有した問題

Re: C#(Unity)でHTTP/3通信してみる その参 ~Unityから使ってみる~で挙げた「実装でハマった問題の共有(未解決問題含む)」について、どうなったかの続報を報告できていなかったので、この場を借りて共有します。

多重化が機能していない?

以前の記事では原因不明として取り上げましたが、これは単純にサイトによっては index.html のようなデータは多重化対象ではないことが多いだけでした(用途を考えれば当然でした……)。
画像データのようなものであれば多重化対象になっていることがほとんどなので、外部サイトで多重化を実験させて貰う場合はそういったもので試すと良いと思います。
自分は Cloudflare さんの https://blog-cloudflare-com-assets.storage.googleapis.com/2019/07/http3-toggle-1.png で実験させて頂きました。

ダメなケース

イケているケース

129 個目の以降のリクエストが発行できない

無事 129 以上のリクエストを発行できるようになっていました。ありがたや。

Unity Editor で再生 → 終了 → 再生すると Unity Editor ごと落ちる

こちらも問題なく動作するようになっていました。
quiche のログ出力を Unity.Log に繋げることにより、開発が非常に捗るので開発中は常時有効にする勢いで活用させて頂きました。ありがたや。
ちなみに、 Android でも以下のように Unity の Android Logcat 拡張で qwfs や quiche のログを確認できて非常に便利です。

Unity 上で HTTP/3 通信を行う

長い道のりでしたが、これでようやく Unity から HTTP/3 通信する為の基盤実装が完成しました。
後はこの quiche ラッパを Unity(C#) から呼び出せば目的を実現できそうです。

今回は Unity から呼び出す実装例として nhh3 を用意しました。
nhh3 はこれまでに紹介した内容をまとめた quiche のラッパ層である qwfs と、この qwfs を用いた Unity 向け HTTP/3 クライアントライブラリである nhh3 から成ります。
その他詳細な仕様は nhh3 の README.md を参照してください。

この nhh3 のサンプルとして以下を用意しています。

  • SingleRequestSample
    • リクエストを一つ送信し、受信したボディを画面にテキストで表示するサンプル
  • MultiRequestSample
    • 多重化を用いて同時に複数のリクエストを送信するサンプル

どちらのサンプルもパラメータを適切に設定してあげれば外部のサーバとも通信可能です。
実験的実装なのでエラー処理などの例外系がかなり甘いですが、令和5年にもなったのでそろそろ Unity で HTTP/3 通信試してみたいぜ、という方は是非触ってみてください。

サンプルで実験する際の HTTP/3 の通信先は以下のいずれかで選定刷るのがお勧めです。

SingleRequestSample

SingleRequestSample は任意の宛先に HTTP/3 リクエストを送信し、受信したレスポンスのボディを表示するサンプルです。

ゲームオブジェクトである Http3SharpHost にて宛先や証明書の検証の有無等のパラメータを設定可能です。

特に説明が必要なパラメータもないので、以下で実装の軽い解説をしたいと思います。

Http3SharpManager

Native ライブラリのインスタンス管理の都合上、 nhh3 では初期化・終了関数を必ず一度ずつ呼び出す必要があります。

  • 初期化関数 : Http3Sharp.Initialize()
  • 終了関数 : Http3Sharp.Uninitialize()

当サンプルでは上記関数を呼び出す Http3SharpManager 実装し、これを DontDestroyOnLoad 指定することで、シーン遷移時ではなくアプリ終了時に Http3Sharp.Uninitialize() を一度呼び出す一般的な作りとしています。

Http3SharpSampleCore

nhh3 の実処理を呼び出し・管理するクラスです。
各サンプルの Update()Http3SharpSampleCore.Http3.Update() を呼び出すことにより nhh3 のステータス及び完了したリクエストの取得を行います。
実装はシンプルなのであまり解説する個所もないですが、ステータス管理とプログレスについて補足します。

  • ステータス管理について
    • リトライ
      • nhh3 ではリトライ中かどうかというステータスを持っていない為、 Http3.Retry() を呼び出したかどうかのフラグを持たせる、若干煩雑な実装になっています
      • 実際のゲームではリトライボタンとスタートボタンは共通か、別途ウィンドウ表示することがほとんどなのであまり問題にならないかと思います
    • アボート
      • nhh3 は Http3.Abort() が呼び出されると内部的に保持しているリクエストを全て破棄し、コネクション切断後に再度コネクションを張り直します
      • Http3Sharp.Status.Aborting 中に Http3.Abort() を呼び出しても何も起きませんが、分かり易さの為に呼び出せない実装としてあります
  • プログレスについて
    • nhh3 では Http3.Update() 呼び出し間でダウンロードしたデータ量と、今まで通信したデータ量を取得できます
    • 今まで通信したデータ量は、 Http3.Status.Wait() に戻った状態でリクエストを発行すると初期化されます

ちなみに、このクラスは MultiRequestSample でも併せて利用しています。

Http3SharpSampleSingle

リクエストの作成とボディの表示を行うクラスです。
失敗時は Core 側でエラー原因の表示まで行うので、成功時の処理しか実装していません。
……特に解説することがありませんのでサクッと次にいきましょう。

MultiRequestSample

MultiRequestSample は、任意の宛先に複数の HTTP/3 リクエストを多重化して実行するサンプルです。

ゲームオブジェクト Http3SharpHost にて宛先や証明書の検証の有無等のパラメータを設定可能です。

Max Multiple Download Numinitial_max_streams_bidi とは別に nhh3 で独自に管理する同時にリクエスト可能な最大値です。
initial_max_streams_bidi とは異なり、累計ではなく現存リクエストしている数を指定できます。
また、多重化を試すサンプルなので、前述した Cloudflare さんの https://blog-cloudflare-com-assets.storage.googleapis.com/2019/07/http3-toggle-1.png のアドレスをデフォルトで設定しています。都合に応じて変更して試してみてください。

メインの処理は Http3SharpSampleCore に任せている為、実装面の解説は割愛します。

Android でサンプルを動かす場合の注意事項

前述したように、 Android では信頼された認証局リストを外部から与える必要があります。
nhh3 のサンプルではこの信頼された認証局リストとして Mozilla の 2023-01-10 版 を StreamingAssets に配置してあります。
apk からの展開と再配置については Nhh3SampleCore.cs - CreateHttp3() 内の実装を参考にしてください。

また、 MultiRequestSample のファイル保存パスは Application.temporaryCachePath}\save に固定しています。
変更したい場合は Nhh3SampleMulti.cs - OnStartClick() の実装を修正してください。

サンプルで 0-RTT を試す

nhh3 で 0-RTT を行うには以下の設定が必要です。

  • ConnectionOptions.WorkPath
    • 下層である qwfs のテンポラリフォルダ
    • 0-RTT 用のセッション情報を保存したファイルもここに格納される
  • QuicOptions.EnableEarlyData
    • 0-RTT を行うかどうか

上記を設定した上で、サーバが対応している場合にのみ 0-RTT による通信が行われます。
サンプルで 0-RTT 通信を行いたい場合は、 Single/Multi 共通でゲームオブジェクト Http3SharpHost に以下を設定する必要があります。

  • Enable Early Data にチェックを入れる
  • Work Path にセッション情報を保存するフォルダのパスを入れる
    • Android の場合は Application.temporaryCachePath が自動的に入ります
      • 変更したい場合は Nhh3SampleCore.cs - OnStartClick() の実装を修正してください

0-RTT が成功したかどうかは qlog/qviz を使うと確認し易いです。
サンプルで qlog を有効にしたい場合は Http3SharpHostQlog Path に qlog を保存したいフォルダのパスを入れてください。

こちらも Android の場合は Application.temporaryCachePath が自動的に入ります。

上手くいかない場合には ConnectionOptions.EnableQuicheLog を有効にすると quiche のログが nhh3.SetDebugLogCallback に設定したコールバックに出力されるので参考にしてみてください。

サンプルで Connection Migration を試す

nhh3 で Connection Migration を行うには以下の設定が必要です。

  • QuicOptions.DisableActiveMigration
    • Connection Migration を抑制するかどうか
    • false で Connection Migration が有効になります

上記を設定した上で、サーバが対応している場合にのみ IP/Port 変更時に Connection Migration による通信が行われます。
QuicOptions.DisableActiveMigration が有効にもかかわらずサーバが対応していない場合はハンドシェイクから自動で再実行されます。

サンプルで 0-RTT 通信を行いたい場合は、 Single/Multi 共通でゲームオブジェクト Http3SharpHost に以下を設定する必要があります。

  • Enable Connection Migration にチェックを入れる

上記を設定した上で以下の手順で Connection Migation を試すことが可能です。

  • Windows で動作を試す場合
    • IPv4 と IPv6 の有効無効を切り替える
      • IPv4/IPv6 両方に対応しているサイトに対してのみ有効な手段なのでご注意ください
    • nhh3.Recconect() を通信途中に呼び出す
      • 専用のボタンを追加し、そのボタン押下時に nhh3.Recconect() を呼び出すことで Connection Migration を誘発できます
  • Android で動作を試す場合
    • Wi-Fi とキャリアの通信の切り替えを行ってみてください

結び

以上で 令和5年になったのでそろそろUnityでHTTP/3通信したい は終了です。
長い記事になってしまいましたが、お付き合いいただきありがとうございました。
これを見て Unity で HTTP/3 使ってみたいという方が一人でも増えると嬉しく思います。

次の記事は何にするか決めてませんが、Unity で HTTP/3 を使ってみる記事も三回目の更新なので、さすがにこれが最後になると思います。
いつか WebTransport を動かしてみた系の記事を書ければと考えていますが、しばらくは技術同人誌の 『くいっく』 シリーズの執筆作業に戻ろうと思っています。

それではまた。

脚注
  1. MsQuic を Windows 10 で使う場合は標準の Schannel の代わりに OpenSSL を自前で組み合わせる必要があります ↩︎

  2. あくまで個人的な推測です ↩︎

  3. IPv4/v6 の happy eyeballs に近い挙動とのこと。詳細な仕様は https://curl.se/docs/http3.html の HTTPS eyeballing 参照 ↩︎

  4. 参考 : 【Unity】Unite Tokyo 2019 「大量のアセットも怖くない!~HTTP/2による高速な通信の実装例~」講演と壇上では語られなかった6つのこと。 - SEGA TECH BLOG ↩︎

  5. どっちにせよビルドが圧倒的に早いので Ninja の方が良いと思います ↩︎

  6. decoder が未実装 ↩︎

  7. Main logging schema for qlog にて仕様の策定が進められています ↩︎

  8. quiche\src\lib.rs - create_path_on_client() 参照 ↩︎

Discussion