PHP ビルトインサーバーの通信・解析・I/O 待機を分けて考える
前回の記事では、embed SAPIとPHP Stream APIを使い、PHPの既存機能だけでTLS接続を受け付けられることを確認しました。
これで「PHPでTLS対応のHTTPサーバーを作れるか」という問いには答えられます。
しかし、固定レスポンスを返す PoC (Proof of Concept) と、既存の php -S へ TLS を組み込む作業は同じではありません。
PHPビルトインサーバーは、接続の待ち受け、HTTPリクエストの解析、PHPスクリプトの実行、静的ファイルの配信、レスポンスの送信まで担当しています。
TLS対応を進めるには、まず現在の実装が抱えている仕事を分ける必要があります。
動く部品を作ることと、その部品を既存のシステムへ組み込むことは別です。
対象読者
この記事は、php-src の既存実装を読み、段階的な改善を提案したい中級以上のPHP開発者を対象としています。
TLS、HTTPパーサー、Polling API の役割だけでなく、変更を小さなPRへ分け、メンテナーとの合意形成やPHP RFCへつなげる方法を扱います。
C言語や OpenSSL の専門知識は前提にしませんが、既存コードやテストを読みながら設計上の責務を追えることを想定しています。
現在のビルトインサーバーを一本の流れで見る
PHPビルトインサーバーの処理を単純化すると、次のようになります。
接続を待つ
↓
読み書き可能な接続を探す
↓
データを受信する
↓
HTTPリクエストを解析する
↓
PHPまたは静的ファイルを処理する
↓
レスポンスを送信する
現在の実装では、select()を使うpollerが読み書き可能なソケットを探します。
クライアントごとの構造体には、ソケット、HTTPパーサー、解析途中のヘッダー、完成したリクエスト、レスポンスの送信状態などがまとめて保持されています。
一つの構造体に多くの情報が入ること自体が、ただちに問題になるわけではありません。
問題になるのは、通信方法を変更すると、HTTP解析やレスポンス送信にも変更が広がることです。
たとえばソケットをPHP Streamへ置き換える場合、データを読む部分だけでなく、書き込み、切断、エラー、待機処理にも影響します。
一つのクライアント情報に、「どこから読むか」「どう解釈するか」「何を返すか」が集まっています。
三つの仕事へ分けて考える
TLS、llhttp、epoll をまとめて考えると、巨大なサーバー改修に見えます。
しかし、それぞれが変更する対象は異なります。
通信する
通信層は、クライアントからデータを読み、レスポンスを書き込む仕事を担当します。
現在のビルトインサーバーは、ソケットを直接扱っています。
TLS対応では、ここをPHP Stream APIへ移すことが候補になります。
現在
raw socket
↓
読み書き
変更後
PHP Stream
├─ TCP
└─ TLS
HTTPかHTTPSかという判断は、HTTPリクエストを解析する部分へ漏らさないほうがよいでしょう。
上位層から見れば、TCPでもTLSでも「データを読み書きできる接続」として扱える構造を目指します。
HTTPを解析する
HTTPパーサーは、受信したバイト列から次の情報を取り出します。
- HTTPメソッド
- URL
- HTTPバージョン
- ヘッダー
- メッセージ本文
TLSが担当するのは暗号化と復号です。
TLSハンドシェイクが終われば、HTTPパーサーが受け取るのは通常のHTTP/1.xメッセージです。
したがって、TLS対応に llhttp は必須ではありません。
I/O を待つ
サーバーは、一つのクライアントだけを相手にするわけではありません。
待ち受けソケットや複数のクライアント接続のうち、どれが読み書き可能になったのかを調べる必要があります。
現在のビルトインサーバーは、この処理に select() を使っています。
PHP 8.6 開発版で導入された Polling API を利用すれば、Linux では epoll、macOSや BSD では kqueue、Windows では WSAPoll などを、共通のインターフェースから扱えるようになります。
ここまでを整理すると、次のようになります。
| 改善対象 | 変更する責務 |
|---|---|
| TLS | 通信方法 |
| llhttp | HTTPメッセージの解析 |
| Polling API | 読み書き可能な接続の待機 |
三つは相互に関係しますが、変更する理由は別です。
HTTP パーサーを交換する前に、解析結果の行き先を分ける
PHPビルトインサーバーのHTTPパーサーは、受信データを解析しながら、URL、ヘッダー、本文などをコールバックで通知します。
通知された断片は、ビルトインサーバー側で保持され、最終的にPHPが利用するリクエスト情報へ変換されます。
ここでHTTPパーサーを交換すると、パーサー本体だけでなく、ヘッダーの連結やリクエスト構築にも変更が広がります。
そのため、最初に次の境界を作ることが重要です。
HTTPパーサー
↓
URL、ヘッダー、本文などの解析イベント
↓
リクエスト構築
↓
PHPビルトインサーバーのリクエスト
HTTPパーサーの仕事は、HTTPメッセージを解釈するところまでです。
解析結果をどの構造体へ保存し、PHPの$_SERVERやリクエスト本文へどう渡すかは、ビルトインサーバー側の仕事です。
この二つが分かれていれば、現在のパーサーとllhttpを同じ入口から利用できます。
llhttpを導入する前に、HTTP解析を交換可能にする
PHPビルトインサーバーのHTTPパーサーは、受信したデータからURL、ヘッダー、本文などを取り出します。
しかし、現在の実装では、HTTPメッセージを解析する処理と、PHPビルトインサーバー用のリクエストを組み立てる処理が強く結びついています。
この状態でパーサーを交換すると、解析部分だけでなく、ヘッダーの保持やリクエスト構造の生成まで同時に変更することになります。
そのため、最初の目標はllhttpを導入することではありません。
HTTPメッセージの解析と、PHP用リクエストの構築を分けることです。
受信データ
↓
HTTP Parser
↓
メソッド、URL、ヘッダー、本文
↓
Request Builder
↓
PHPビルトインサーバーのリクエスト
Parserは、受信したバイト列をHTTPとして解釈するところまでを担当します。
解析結果をどのように保持し、PHPのリクエスト情報へ変換するかは、Request Builder側が担当します。
この境界を先に作れば、現在のHTTPパーサーを利用したまま、既存動作を維持できます。その後、Parser部分だけをllhttpへ交換し、解析結果が変わっていないことをテストできます。
llhttpが候補になるのは、旧Node.jsのhttp-parserの後継として継続的に開発され、分割して到着するHTTPメッセージをコールバック形式で解析できるためです。
ただし、llhttpへの移行そのものが目的ではありません。
目的は、HTTPパーサーの更新が、通信処理やPHP実行部分へ波及しない構造を作ることです。
llhttpは完成形ではなく、Parser境界を実際に交換できるか確かめる候補です。
PHP 8.6 開発版で I/O 待機が共通 API になった
PHP 8.6 開発版で、PHPが初めてepollへ対応したわけではありません。
PHP-FPMには以前から独自のイベント機構があり、Linuxではepoll、macOSやBSDではkqueueなどを利用していました。ただし、その実装はPHP-FPMの内部にあり、CLI ServerやPHP拡張から共通部品として利用できるものではありませんでした。
PHP 8.6 開発版のPolling APIが変えたのは、epollの有無ではありません。
epoll、kqueue、WSAPoll、pollなどのI/O待機処理を、PHPコアの共通APIから利用できるようにしたことです。
従来
PHP-FPM
└─ FPM専用のイベント機構
PHP 8.6
PHPコア
└─ Polling API
├─ epoll
├─ kqueue
├─ WSAPoll
└─ poll
PHPビルトインサーバーがPolling APIへ移行すれば、OSごとの待機処理を独自に管理する必要が減ります。
PHP 8.6 開発版の変化は、epollの追加ではなく、I/O待機を共有できる境界の追加です。
Polling API は何をしてくれるのか
Polling API は、複数の接続を登録し、どの接続で読み書きが可能になったかを待つ仕組みです。
RFC ドキュメントに記載されている 内部 C APIには、次のような役割の関数があります。
| API | 役割 |
|---|---|
php_poll_create() |
Pollingコンテキストを作る |
php_poll_add() |
監視対象を追加する |
php_poll_modify() |
待機するイベントを変更する |
php_poll_remove() |
監視対象を外す |
php_poll_wait() |
イベントが起きるまで待つ |
php_poll_destroy() |
コンテキストを破棄する |
通常は自動選択を利用し、実行環境に合ったバックエンドをPHP側へ任せられます。
Linux用のコード、macOS用のコード、Windows用のコードをCLI Serverがそれぞれ抱える必要はありません。
Polling APIは、タイマー、シグナル、子プロセス管理まですべて備えたイベントループではありません。
I/O資源が読み書き可能になるのを待つための、低水準な共通基盤です。
epollの価値は、接続数が増えたときの速さだけではありません。
OSごとの判断を、CLI Serverの独自実装からPHPコアへ移せることにあります。
TLSでは「読みたい」と「READを待つ」が一致しない
TCP通信では、読み取りを進めたい場合、基本的にはソケットが読み取り可能になるのを待ちます。
TLSでは、呼び出した操作と、次に待つべきI/Oの方向が一致するとは限りません。
たとえば、アプリケーションがデータを読もうとしても、TLSハンドシェイクや暗号化処理の都合により、OpenSSLが先に書き込み可能になることを要求する場合があります。
上位の処理
「続きを読みたい」
↓
TLS処理
「先に書き込み可能になるまで待つ」
↓
Polling API
「WRITEイベントを監視する」
従来のPHP Stream APIでは、ノンブロッキングTLS操作が完了しなかったとき、次にREADとWRITEのどちらを待つべきかを外部から判別できませんでした。
PHP 8.6 開発版では、この判断に利用できるstream_socket_get_crypto_status()が追加されました。
この関数は、直前のTLS操作について次の状態を返します。
| 状態 | 意味 |
|---|---|
| STREAM_CRYPTO_STATUS_NONE | READまたはWRITEの待機要求が残っていない |
| STREAM_CRYPTO_STATUS_WANT_READ | 処理を続けるには読み取り可能になるのを待つ |
| STREAM_CRYPTO_STATUS_WANT_WRITE | 処理を続けるには書き込み可能になるのを待つ |
PHPビルトインサーバーはCで実装されているため、対応する内部APIのphp_stream_xport_crypto_get_status() を利用できます。状態定数とC APIはphp_stream_transport.h へ追加されています。
これにより、TLS Transportはノンブロッキング操作が完了しなかった直後にcrypto statusを確認し、Polling APIへ次に監視するイベントを伝えられます。
TLS Transport
↓ TLS操作を試す
crypto status
├─ NONE
│ └─ 処理を続ける
├─ WANT_READ
│ └─ READを監視する
└─ WANT_WRITE
└─ WRITEを監視する
ここで、Polling APIがTLSの内部状態を理解する必要はありません。
TLS Transportが「何を待つべきか」を判断し、Pollerは指定されたイベントを待つことだけを担当します。
stream_socket_get_crypto_status() は、PHP Stream APIとPolling APIのあいだを接続する判断材料です。
ただし、これらの値はTLS接続全体の状態を表すものではありません。
NONEは、必ずしもハンドシェイク完了を意味しません。通常のTCP StreamでもNONEが返ります。また、crypto statusは各操作の前にリセットされ、未完了の操作でOpenSSLがREADまたはWRITEを要求した場合に更新されます。
そのため、サーバー側ではcrypto statusとは別に、次の状態を管理する必要があります。
- TLSハンドシェイク中
- HTTPリクエスト受信中
- レスポンス送信中
- 接続終了処理中
crypto statusは、未完了になったTLS操作の直後に確認し、イベント発生後に同じ操作を再試行するために使います。
TLS対応では、通信状態を推測するのではなく、Transportが公式APIから次に必要なI/O方向を取得できます。
完成形ではなく、変更の順序を設計する
PHPビルトインサーバーの最終的な改善案には、PHP Stream、TLS、llhttp、Polling APIが含まれます。
しかし、これらを一度に変更すると、不具合の原因を特定しにくくなり、レビューでも複数の技術判断が混ざります。
まず必要なのは、完成形を実装することではなく、現在の挙動を維持したまま部品を交換できる構造を作ることです。
現在の挙動をテストで固定する
↓
Parser、Transport、Pollerの境界を作る
↓
既存の部品を新しい境界から利用する
↓
部品を一つずつ交換する
最初の段階では、TLSやllhttpを導入しません。
ParserはHTTP解析、Transportは通信、PollerはI/O待機を担当するという境界だけを作り、外部から見た動作は変えないようにします。
その後、select()からPolling API、現在のHTTPパーサーからllhttp、raw socketからPHP Streamというように、部品を段階的に交換します。
実際の順序は検証によって変わる可能性がありますが、基本方針は変わりません。
一つの変更では、一つの前提だけを変えます。
内部リファクタリングとRFCを分ける
内部構造の改善と、php -SへHTTPS機能を追加することは、別の判断です。
外部動作を変えないリファクタリングは、小さなPRへ分割できます。既存動作をテストで保証しながら責務を分けることで、メンテナーは変更単位ごとに妥当性を判断できます。
一方、HTTPS対応は利用者から見える新機能です。PHPとして提供するかどうかは、PHP RFCを通じて合意する必要があります。
内部リファクタリング
└─ 変更できる構造を作る
PHP RFC
└─ その構造を使って何を提供するかを決める
技術的には一つの完成形でも、実装、レビュー、仕様の合意まで一つにまとめる必要はありません。
大きな改善では、コードの境界だけでなく、変更と議論の単位も設計する必要があります。
まとめ
PHPビルトインサーバーへTLS、llhttp、Polling APIを導入する場合、三つを一度に変更する必要はありません。
それぞれが担当する責務は異なります。
- PHP Stream と TLS は通信方法を変える
- llhttp は HTTP メッセージの解析方法を変える
- Polling API は I/O 待機の方法を変える
-
stream_socket_get_crypto_status()は TLS Transport が次に待つ I/O 方向を判断できるようにする
まず現在の挙動をテストで固定し、Parser、Transport、Pollerの境界を作ります。その後、部品を一つずつ交換すれば、変更理由と不具合の所在を明確にできます。
内部リファクタリングをレビュー可能なPRへ分ける戦略と、利用者向け仕様を決める PHP RFC の両方が必要です。
技術的に可能な最終形を描くことと、その変更をプロジェクトで合意できる形へ分解することは別です。
PHP ビルトインサーバーの改善で設計するべきなのは、コードの構造だけではありません。
どの問題から合意し、どの前提をどの順番で変更するかという、改善そのものの進め方です。