PHPのstream_socket_serverでテスト用HTTPサーバーを作る

Posted by Hiraku on 2014-07-21

これで何人目か知りませんが、PHP用のライセンスクリーンなオブジェクト指向ベースcURLライブラリが欲しくて車輪の再発明をしました。(結構昔に作ったんですが、書き溜めたスクリプト集を整理しようと思って名前を変えていっています)

HTTPのクライアントライブラリなので、ユニットテストするにはHTTP Serverを用意して、実際にリクエストを投げる必要があります。phpunitコマンドを実行すると、その場で適当なテスト用HTTPサーバーを立てて、そこへ向かってテストを実行して、終わったらHTTPサーバーを破棄する。そんな感じにしたいと思いました。

curl_multiによる多重リクエストも試したかったので、PHP5.4以降に組み込まれているビルトインウェブサーバーは使うことができません。ビルトインウェブサーバーは、どうも一度に一つのリクエストしか処理できないようで、sleepとか使って多重リクエスト状態を再現するのには不都合でした。

当初はnode.jsで適当なWebAPIっぽいものを立ててテストしていたのですが、PHPのテストをするのにnode.jsが必要なのは不健全だろうと思い、PHPでHTTPサーバーを書いてみることにしました。

PHPではC言語風のソケットプログラミングが可能で、標準状態でもstreamを使えばHTTPサーバーを立てることができます。しかし情報が乏しく、少々手こずりました。ここでは得られた知見を雑多にメモしておきます。

書いたソースコード

spindle-httpclient/tests/sampleapi.php at master ・ spindle/spindle-httpclient

php sampleapi.phpで実行すると http://localhost:1337/ でサーバープロセスが立ち上がります。(ポート番号決め打ち) waitパラメータを付けると、その秒数だけレスポンスが遅延します。

  • http://localhost:1337/?wait=1 にリクエストすると、1秒待ってからレスポンスが返ってくる。
  • http://localhost:1337/?wait=2 にリクエストすると、2秒待ってからレスポンスが返ってくる。

HTTPヘッダの解析は適当です。たぶん脆弱性いっぱいありそう。 あと、ネットワークが超遅い(ヘッダがじわじわ送られてくるとか)状態を考慮していません。

テスト用なので http://localhost:1337/?exit=1 へリクエストを投げるとHTTPサーバープロセスが終了します(!) 後始末に便利。

以下、雑多なメモ。

HTTPサーバーの色々なモデル

HTTPサーバーは同時に複数のリクエストを受け、それぞれに順次対応していく必要があります。処理の中にはとても時間のかかるケース(通信が遅いとか)もあるので、必然的に並列プログラミングをする必要があり、どのようなモデルで対応するのか色々種類があります。

マルチプロセス

リクエストを処理するための専用のプロセスを用意し、任せていく方法。1リクエスト処理し終わったらプロセスは破棄するイメージになります。 PHP + Apacheや、PHP-FPMはこのタイプで動作しています。

長所としてはプログラムが単純でわかりやすくなることですね。伝統的なプログラミングスタイルで書けますし、競合状態も(プログラム本体としては)発生しないので、問題も起きにくいです。

ただしプロセスの生成にはコストがかかりますし、メモリ効率も悪いので、あまり同時に多数のコネクションを捌くことはできません。即座にレスポンスを返し、スパッとコネクションを切断していく前提なら、それなりにスケールします。

プロセスの生成コストは、事前にプロセスを生成しておいたり、プロセスを使いまわしたりすることで軽減できます。これを行っているのがApacheのprefork MPMです。

マルチスレッド

1リクエスト1プロセスではなく、1リクエスト1スレッドで処理を行うこともできます。プロセスよりスレッドの方が生成コストが低いので、マルチプロセスよりも性能が向上します。

ただし、マルチスレッドプログラミングは大変難しく、原理的に競合状態を起こしやすいので、問題が起きたときのデバッグに苦労しがちです。

Apacheのworker MPMに対応します。

1プロセス1スレッド+ノンブロッキング

実際のサーバーサイドプログラムでは、CPUが暇な時間が結構あるものです。リクエストヘッダが全部到着するのを待っている間とか、他のWebAPIにリクエストしている間とか、データベースにSQLを投げて結果が返ってくるまでの間とか。 これらの待ち時間を徹底的に有効活用してやると、1スレッドであっても同時並行に処理を行うことができます。

例えるならコンビニのレジで、お弁当をレンジで温めている間に、「お次にお並びのお客様〜」と次々処理していくような感じです。

長所はスケールすることと、競合状態が起きないため、マルチスレッドより理解しやすいことです。最近のWebサーバープログラムは多かれ少なかれこのモデルを取り入れています。

HTTPサーバーの部分だけでなく、もうサーバーサイドのアプリケーションは全部このモデルで書いちゃえばいいじゃん!としたのがnode.jsですね。 難点としては徹底的な非同期プログラミングが求められるので、既存のプログラムとはかなり発想を変えて書かなくてはならないことが挙げられます。

今回作ったのはこのパターンです。 ただ、使っているイベント通知の仕組みが伝統的なselectシステムコールなので、同時接続できるコネクション数はnode.jsなどに比べれば全然少ないと思われます。

実装で詰まったところ

backlogの設定はストリームコンテキストで指定できる

同時に大量のアクセスが来たとき、backlogを設定してあれば、処理しきれないリクエストを待たせることができます。 PHP: stream_socket_server - Manualの説明を見ているとbacklogの設定項目がなさそうに見えるのですが、stream_context_createで作ったコンテキストを渡せば指定できるようです。

$context = stream_context_create(array(
    'socket' => array(
        'backlog' => 128
    )
));

$server = stream_socket_server(
    'tcp://127.0.0.1:1337',
    $errno,
    $errmsg,
    STREAM_SERVER_LISTEN | STREAM_SERVER_BIND,
    $context
);

selectの挙動

stream_selectで、何かソケットに変化(リクエストが来たとか)が起きるまで待つことができます。 たぶん元となったシステムコールの挙動そのままだと思うのですが、selectは監視してほしいストリームリソースの配列を渡すと、その配列自体を加工し、変化があったものだけ残します。

//...
for (;;) {
    $read = $readOrigin;
    $write = $writeOrigin;

    stream_select($read, $write, $except, 1); //block
//...

selectの手前で配列をコピーしているのは、この挙動のせいで元々監視してほしかった対象がわからなくなってしまうからです。

リソース型の高速な特定

PHPのリソース型はintにキャストすることができ、キャスト結果はプログラム中で唯一です。連想配列の添え字に使うと、ストリームリソースをO(1)で見つけることができ、効率がよくなる気がします。

Apache+PHPよりパフォーマンスが出た

当たり前なんですが、HTTPヘッダの解析も何もしていないので、普通のApacheよりやってることが少なく、その分高速なはずです。ab -c10 -n100で試したらApache+PHP(チューニングしてない)のHelloWorldに比べて2倍程度のリクエストを捌けるようでした。

オレオレサーバー:

Requests per second:    7880.22 [#/sec] (mean)
Time per request:       1.269 [ms] (mean)
Time per request:       0.127 [ms] (mean, across all concurrent requests)

Apache+PHP(echo 'Hello World'してるだけ):

Requests per second:    3274.18 [#/sec] (mean)
Time per request:       3.054 [ms] (mean)
Time per request:       0.305 [ms] (mean, across all concurrent requests)

2倍程度というのがまあPHPだからなんでしょうけど、それでもnode.js的な作り方には夢を感じますね。


まとめ

合ってるのかどうかわからんけど、PHPでもHTTPサーバーを作れました。 特に拡張モジュールとか入れなくても標準関数だけで作れるみたいです。

必要な知識・参考になるかもしれないコード

PHPの最新記事