はじめに
PHPからnghttp2を扱うための拡張モジュールとして、ext-nghttp2を試作しました。
この拡張は、完成したHTTPサーバーを提供するものではありません。nghttp2が持つHTTP/2の状態機械を、PHPから操作できるようにするための低水準なプロトコルエンジンです。
この記事では、C言語による拡張モジュールの実装方法や、HTTP/2フレームの細かな仕様には踏み込みません。
主に扱うのは、次の二つです。
- Sans-I/Oやイベントクラスの設計から何を学べるか
- その設計をPHPの利用者へ届ける際に、どのような配布上の問題があるか
設計として成立することと、PHPのパッケージとして普及することは別問題です。この二つを分けて考えることが、この記事の目的です。
想定読者
この記事は、次のような読者を想定しています。
- PHPの拡張モジュールや内部実装に関心がある人
- ReactPHP、Amp、Revoltなどのイベント駆動処理を学んでいる人
- HTTP/2の詳細な仕様より、プロトコルライブラリの責務分離に関心がある人
- Pure PHP、FFI、C拡張の配布上の違いを整理したい人
- PHPにおけるイベントループやHTTPサーバーの標準化を考えたい人
一般的なWebアプリケーション開発から一歩進んで、イベントループ、プロトコル、HTTPサーバーの関係を理解したい中級者以上を対象にしています。
1. nghttp2の状態機械をPHPから扱う
ext-nghttp2の中心となるAPIは、次の三つです。
$session->receive($bytes);
foreach ($session->drainOutput() as $chunk) {
// ソケットへ書き込む
}
while (($event = $session->nextEvent()) !== null) {
// HTTP/2上で発生した出来事を処理する
}
それぞれの役割は、次のように整理できます。
| API | 役割 |
|---|---|
receive() |
受信したバイト列をHTTP/2エンジンへ入力する |
drainOutput() |
送信すべきバイト列を取り出す |
nextEvent() |
HTTP/2上で発生した出来事を取得する |
ext-nghttp2自身は、ソケットの読み書きを行いません。
アプリケーションが受信したバイト列をreceive()へ渡し、ext-nghttp2が生成した出力をdrainOutput()から取り出します。HTTP/2上で発生した状態変化は、nextEvent()からイベントオブジェクトとして取得します。
つまり、ext-nghttp2はHTTPサーバーではなく、HTTP/2のプロトコルエンジンです。
2. Sans-I/OでI/Oとプロトコルを分離する
ext-nghttp2は、Sans-I/Oという考え方を採用しています。
Sans-I/Oは、単に「ライブラリがソケットを開かない」という意味ではありません。
本質は、プロトコルエンジンが、入力されたバイト列をどのソケットやイベントループから受け取ったのかを知らないことにあります。
責務を分けると、次のようになります。
PHP・HTTPサーバーランタイム側
├─ TCP・TLS
├─ ALPN
├─ ソケットの読み書き
├─ イベントループ
├─ タイムアウト
├─ バックプレッシャー
└─ 接続ライフサイクル
ext-nghttp2側
├─ HTTP/2フレームの処理
├─ ストリーム状態の管理
├─ フロー制御
└─ プロトコルイベントの生成
ext-nghttp2のREADMEでも、TCPやTLS、イベントループ、バックプレッシャー、接続ライフサイクルをアプリケーション側の責務としています。
一方で、フレームのエンコードとデコード、ストリーム状態、フロー制御、イベント生成は拡張モジュール側の責務です。
この分離によって、ext-nghttp2は特定のイベントループに固定されません。
ReactPHP ─┐
Amp ─┼─ Transport Adapter ─ ext-nghttp2 ─ libnghttp2
Polling ─┤
独自Loop ─┘
ReactPHP、Amp、独自のポーリングループ、将来のPHP Polling APIなど、異なる実行基盤と組み合わせられます。
ただし、Sans-I/Oが解決するのは責務分離です。
HTTPサーバー全体を誰が組み立て、誰が運用するのかまでは解決しません。
3. nghttp2のコールバックをイベントへ変換する
nghttp2は、HTTP/2上で発生した出来事をCのコールバックとして通知します。
このコールバックを、そのままPHP利用者へ公開する設計も考えられます。
しかし、CライブラリからPHPコードを直接呼び戻す方式には、いくつかの問題があります。
- nghttp2の処理中にPHPコードが再入する
- PHP例外をどこまで伝播させるかが複雑になる
- Cライブラリの制御構造がPHP側へ漏れる
- イベントループとの統合方法が固定されやすい
- イベントの発生順序をテストしにくい
そこでext-nghttp2では、nghttp2のコールバックをPHPのイベントオブジェクトへ変換し、内部キューへ保存します。
nghttp2 callback
↓
PHPイベントオブジェクトを生成
↓
内部イベントキュー
↓
nextEvent()
↓
PHPアプリケーション
利用者は、nghttp2の処理中に突然呼び出されるのではなく、nextEvent()を呼び出した時点でイベントを処理できます。
READMEでも、PHP利用者へコールバック登録を公開せず、コールバックを内部イベントキューへ変換する方針を明示しています。
これは単にイベント駆動へ変更したという話ではありません。
Cライブラリが持っていた制御権を、PHP側へ戻すための設計です。
4. ストリームと接続を別のイベントとして扱う
ext-nghttp2のイベントは、ストリーム単位と接続単位に分けています。
Event
├─ StreamEvent
│ ├─ HeadersReceived
│ ├─ DataReceived
│ ├─ StreamReset
│ └─ StreamClosed
└─ ConnectionEvent
├─ SettingsReceived
├─ SettingsAcked
└─ GoawayReceived
このイベント階層は、READMEにも公開されています。
HTTP/2では、一つのTCP接続の中に複数のストリームが存在します。
そのため、一つのリクエストに相当するストリームの問題と、接続全体に影響する問題を区別する必要があります。
StreamEvent
→ そのストリームをどう処理するか
ConnectionEvent
→ この接続をどう処理するか
たとえば、あるストリームがリセットされたとしても、ほかのストリームまで終了させる必要はありません。
一方、GOAWAYやSETTINGSは、一つのストリームではなく接続全体に関係します。
StreamResetとStreamClosedも似ていますが、意味は異なります。
-
StreamResetは、明示的な強制終了を通知する -
StreamClosedは、ストリームの最終的なライフサイクル終了を通知する
ext-nghttp2では、明示的なリセットの処理と、最終的な後始末を別のイベントとして扱います。
このクラス階層がHTTP/2 APIとしての完成形だと主張するつもりはありません。
プロトコルAPIの妥当性を評価するには、クラス図だけでなく、仕様との対応、イベントの順序、異常系、他実装との相互運用まで確認する必要があります。
ここで重要なのは、Cライブラリのコールバックをそのまま公開せず、PHP側が判断しやすい境界へ変換したことです。
5. プロトコルAPI設計から何を学べるか
プロトコルを扱うクラスは、一般的な業務アプリケーションのクラスとは評価軸が異なります。
通常のクラス設計では、次のような点が注目されます。
- クラス名が分かりやすいか
- 一つのクラスの責務が小さいか
- 継承関係が整理されているか
- 依存関係が一方向になっているか
プロトコルAPIでは、それだけでは十分ではありません。
追加で、次の点を確認する必要があります。
- フレームとイベントをどの粒度で対応させるか
- 一時的な通知と終端通知を分けられるか
- ストリームエラーと接続エラーを区別できるか
- イベントの順序を表現できるか
- ライブラリが通知する事実と、アプリケーションの方針を分けられるか
たとえば、GOAWAYを受信したという事実と、接続をいつ閉じるかという判断は同じではありません。
拡張モジュールはGOAWAYを受信したことをイベントとして通知します。
その後、新しいストリームを受け付けるのか、既存ストリームの完了を待つのか、ただちに接続を終了するのかは、HTTPサーバーランタイム側の方針です。
プロトコルエンジン
→ 何が起きたかを通知する
HTTPサーバーランタイム
→ それを受けてどう動くかを決める
プロトコルAPIでは、事実と方針を分けることが重要です。
この分離がなければ、サンプルコードで採用した単純な終了処理が、そのまま拡張モジュールの仕様として固定されてしまいます。
6. PHPでは実装方式が配布方式を決める
ここまでは、ext-nghttp2から学べる設計を扱いました。ここからは、それをPHP利用者へどう届けるかという別の問題です。
PHPでHTTP/2を提供する方法は、主に次の三つです。
| 方式 | 利点 | 課題 |
|---|---|---|
| Pure PHP | Composerだけで導入できる | HTTP/2の状態機械をPHP側で保守する |
| FFI | PHPコードとしてAPIを設計できる | libnghttp2とFFIの実行環境が必要 |
| C拡張 | HTTP/2処理をnghttp2へ委譲できる | Composerとは別に拡張を導入する必要がある |
Pure PHPパッケージは、Composerによってプロジェクト単位で管理できます。一方、C拡張はPHPランタイムへインストールし、php.iniで有効にします。
Pure PHP
Packagist → Composer → vendor/
C拡張
PIE・OSパッケージ・ビルド
↓
extension_dir・php.ini
FFIを使えばバインディングをComposerで配布できますが、libnghttp2の導入、FFIの有効化、共有ライブラリの探索は残ります。見た目はPHPライブラリでも、ネイティブ依存がなくなるわけではありません。
AMPHPのように、HTTP/2全体ではなくHPACKだけをFFIへ委譲する方法もあります。
HTTP/2の状態機械
└─ Pure PHP
HPACK
├─ Pure PHP
└─ nghttp2 FFI
この方式では、Composerだけで動く経路を維持しながら、利用可能な環境では一部をネイティブ実装へ差し替えられます。その代わり、フレーム処理、ストリーム状態、フロー制御は引き続きPHP側で保守します。
PHPでは、実装方式の選択が性能だけでなく、導入方法、依存関係の管理者、利用できる環境まで決めます。
7. 低水準の部品だけでは普及しない
Sans-I/Oによって、イベントループ、プロトコルエンジン、HTTPサーバーランタイムを別々に開発できます。
Application / Framework
────────────────────────
HTTP Server Runtime
────────────────────────
Event Loop Adapter
────────────────────────
ext-nghttp2
────────────────────────
libnghttp2
技術的には水平分業が可能です。しかし、利用者に各レイヤーの組み立てを要求すると、次の運用責務まで利用者へ流出します。
- TCP、TLS、ALPN
- イベントループとの接続
- タイムアウトとバックプレッシャー
- graceful shutdown
- ワーカー、ログ、メトリクス
- 常駐プロセスの状態管理
問題は、これらをPHPで実装することではありません。各利用者が個別に設計、検証、保守しなければならないことです。
そのため、内部ではSans-I/Oによる分離を維持しながら、利用者には動作するHTTPサーバーランタイムを提供する必要があります。
HTTPサーバーAPI
────────────────────
接続・終了・監視を扱うランタイム
────────────────────
選定されたイベントループ
────────────────────
HTTP/2プロトコルエンジン
Swooleと同じ構造の巨大な拡張を作る必要はありません。ただし、利用者が低水準部品を自分で組み立てなくても動く入口は必要です。
水平分業は開発者を増やすための設計であり、垂直統合は利用者を増やすための設計である。
8. きれいな分離と普及戦略は別の問題である
ext-nghttp2では、ソケットI/OとHTTP/2の状態機械をSans-I/Oによって分離しました。さらに、nghttp2のCコールバックをPHPのイベントキューへ変換し、ストリーム単位の出来事と接続全体の出来事を区別しています。
この設計からは、次のことを学べます。
- I/Oとプロトコル処理を分ける
- Cライブラリの制御構造をそのまま公開しない
- 状態変化の影響範囲をイベントとして表現する
- プロトコル上の事実と、HTTPサーバーの運用方針を分ける
- 特定のイベントループに依存しない状態機械を作る
一方、内部をきれいに分離できても、それだけでPHP利用者へ届くわけではありません。
Pure PHPならComposerだけで導入できますが、HTTP/2の状態機械をPHP側で保守する必要があります。FFIなら一部をネイティブ化できますが、共有ライブラリへの依存が残ります。C拡張ならnghttp2へプロトコル処理を委譲できますが、Composerとは別にPHPランタイムへの導入が必要です。
さらに、Sans-I/Oの部品だけを提供すると、TCP、TLS、イベントループ、タイムアウト、終了処理、監視などの組み立てが利用者側へ残ります。技術的には水平分業が可能でも、実用的なHTTPサーバーとして普及させるには、動作するランタイムまで統合した導入経路が必要です。
ext-nghttp2の教訓は、設計上の分離と普及のための統合を同じ基準で考えないことです。
内部アーキテクチャは責務ごとに分離する。一方、利用者には導入と運用が完結する形で提供する。きれいに分離できることと、普及戦略は別の問題である。