PHP5.5で強化されたcURL拡張でHTTP Pipeliningを試す

Posted by Hiraku on 2013-06-23

先日、PHP5.5.0がリリースされましたね。さっそくビルドしてニヨニヨしているところです。

5.5の新機能と言えばGeneratorfinallyなどですが、個人的に注目しているのがcURLの機能強化です。詳しい内容がまだ公式ドキュメントに書かれていないのですが、結構おもしろいので紹介してみます。

cURLとは何か

かーると発音する人が多いようです。C言語で書かれたHTTPのclientライブラリであり、WebAPIやスクレイピング、クローラなどを扱うときに非常に便利です。PHP版のバインディングは標準でPHP本体にバンドルされているため、大抵のレンタルサーバーでも使えるようになっています。

ただ、オリジナルのlibcurlそのままの関数風インターフェースを踏襲しており、オブジェクト指向型のラッパーなどは用意されていないため、あまり使いやすくはありません。が、並列リクエストが可能であったり高速に動作するなど、他にはない特徴を持ちます。

PHP本体だけでもfsockopen()などのソケットを扱う関数があるので、頑張ってHTTPクライアントを作ることは可能なのですが、HTTPSであったりKeep-Aliveをうまく扱うなど、きちんとしたHTTPの実装を行うのはとても面倒なので、こういった既存のライブラリを使う方が建設的です。

cURLをベースにしたHTTPクライアントはGuzzleを始めとしてたくさんあるので、こういったラッパーを使うとcURLを生で使うより便利だと思います。

HTTP Pipeliningとは何か

HTTP Pipeliningとは、パフォーマンス向上を目的とするHTTP1.1のリクエスト方式です。通常のHTTPリクエストはリクエストを送って、レスポンスが返ってきて、次のリクエストを送って…のように一つずつ処理します。これでは待ち時間が無駄ですし、特に通信時間がダイレクトに響きます。

では複数のコネクションを張って、並列にリクエストすればどうでしょうか? 確かに待ち時間は減らせますが、コネクションを確立する作業にもコストがかかるため、まだ無駄があります。

Pipeliningでは一つのコネクション上でリクエストを一気にポンポンポンと連続して送り、レスポンスも一気に受け取る形式です。無駄な待ち時間が減りますし、一つのコネクション上で実現するので低コストです。

500px-HTTP_pipelining2.svg.png

ブラウザではいち早くFirefoxに実装され、IEやChromeにも搭載されました。今ではブラウザとサーバーの間では当たり前のように使われています。

参考:
HTTP pipelining - Wikipedia, the free encyclopedia
HTTP Pipelining FAQ | MDN
cURL MultiインターフェースでHTTP Pipeliningリクエストの送信 – Yoichi Kawasaki's Web

PHP5.5+cURLで試してみる

言ってみれば並列リクエストの亜種なので、curl_multiを使います。通常のcurl_multiと同じように書けばよく、加えてcurl_multi_setopt()でCURLMOPT_PIPELININGを指定するとPipeliningを試みるようになります。

<?php

const LOOP = 5;
const TIMEOUT = 10;


$mh = curl_multi_init();
curl_multi_setopt($mh, CURLMOPT_PIPELINING, true);
/↓/指定すると複数のコネクションを張ってくれるらしい
//curl_multi_setopt($mh, CURLMOPT_MAXCONNECTS, 2);

//面倒くさいのでこのリクエストハンドラを複製して並列リクエストします。
//URLは適当なので置き換えて試してください
$prototype = curl_init('http://static.local/sample.html');
curl_setopt($prototype, CURLOPT_RETURNTRANSFER, true);
curl_setopt($prototype, CURLOPT_VERBOSE, true);

for ($i=0; $i < LOOP; $i++) {
    $ch = curl_copy_handle($prototype);
    curl_multi_add_handle($mh, $ch);
}

curl_close($prototype);
$start = microtime(true);

do {
    $stat = curl_multi_exec($mh, $running); //multiリクエストスタート
} while ($stat === CURLM_CALL_MULTI_PERFORM);
if ( ! $running || $stat !== CURLM_OK) {
    throw new RuntimeException('リクエストが開始出来なかった');
}

do switch (curl_multi_select($mh, TIMEOUT)) { //イベントが発生するまでブロック
    // ->最悪TIMEOUT秒待ち続ける。タイムアウトは全体で統一しておくと無駄がない

    case -1: //selectに失敗。通常は起きないはず…
        throw new RuntimeException('failed.');

    case 0:  //タイムアウト -> 必要に応じてエラー処理に入るべき
        throw new RuntimeException('timeout.');

    default: //どれかが成功 or 失敗した
        do {
            $stat = curl_multi_exec($mh, $running); //ステータスを更新
        } while ($stat === CURLM_CALL_MULTI_PERFORM);

        do if ($raised = curl_multi_info_read($mh, $remains)) {
            //変化のあったcurlハンドラを取得する
            $info = curl_getinfo($raised['handle']);
            //echo "{$info['url']}: {$info['http_code']}\n";
            $response = curl_multi_getcontent($raised['handle']);

            if ($response === false) {
                //エラー。404などが返ってきている
                echo 'ERROR!!!', PHP_EOL;
            } else {
                //正常にレスポンス取得できた
                //echo $response, PHP_EOL;
            }
            curl_multi_remove_handle($mh, $raised['handle']);
            curl_close($raised['handle']);
        } while ($remains);
        //select前に全ての処理が終わっていたりすると
        //複数の結果が入っていることがあるのでループが必要

} while ($running);

curl_multi_close($mh);


echo microtime(true) - $start, PHP_EOL;

verboseオプションをonにしているので、行った通信などを標準エラー出力してくれるようになっています。

まずcurl_multi_setoptをせずに実行すると、こんな感じの結果が返ってきました。


* About to connect() to static.local port 80 (#0)
*   Trying 192.168.67.128...
* About to connect() to static.local port 80 (#1)
*   Trying 192.168.67.128...
* About to connect() to static.local port 80 (#2)
*   Trying 192.168.67.128...
* Connected to static.local (192.168.67.128) port 80 (#2)
* Connected to static.local (192.168.67.128) port 80 (#2)
> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

* About to connect() to static.local port 80 (#3)
*   Trying 192.168.67.128...
* connected
* Connected to static.local (192.168.67.128) port 80 (#3)
> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

* About to connect() to static.local port 80 (#4)
*   Trying 192.168.67.128...
* connected
* Connected to static.local (192.168.67.128) port 80 (#4)
> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

* Connected to static.local (192.168.67.128) port 80 (#0)
* Connected to static.local (192.168.67.128) port 80 (#0)
> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

* Connected to static.local (192.168.67.128) port 80 (#1)
* Connected to static.local (192.168.67.128) port 80 (#1)
> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:49:22 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
* Connection #0 to host static.local left intact
< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:49:22 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
* Connection #3 to host static.local left intact
< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:49:22 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
* Connection #4 to host static.local left intact
< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:49:22 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
* Connection #2 to host static.local left intact
< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:49:22 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
* Connection #1 to host static.local left intact
0.0034000873565674

* About to connect() to static.local port 80 (#4) みたいな行がたくさん出てますね。コネクションを律儀に5個張っていることがわかります。

次に、Pipeliningをonにした場合。


* About to connect() to static.local port 80 (#0)
*   Trying 192.168.67.128...
* Re-using existing connection! (#0) with host (nil)
* Re-using existing connection! (#0) with host (nil)
* Re-using existing connection! (#0) with host (nil)
* Re-using existing connection! (#0) with host (nil)
* Connected to (nil) (192.168.67.128) port 80 (#0)
* Connected to (nil) (192.168.67.128) port 80 (#0)
> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:53:47 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

> GET /sample.html HTTP/1.1
Host: static.local
Accept: */*

< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:53:47 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:53:47 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:53:47 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
< HTTP/1.1 200 OK
< Date: Sat, 22 Jun 2013 14:53:47 GMT
< Server: Apache
< Last-Modified: Fri, 21 Jun 2013 13:55:35 GMT
< ETag: "72-4dfaa6abc43c0"
< Accept-Ranges: bytes
< Content-Length: 114
< Content-Type: text/html
<
* Connection #0 to host (nil) left intact
0.0069169998168945

* Re-using existing connection! (#0) with host (nil)がたくさん出てますね。コネクションを流用していることがわかります。そして、リクエストだけ先に送ってレスポンスをまとめて受け取っている様子がわかります。ちゃんと動いているみたいですね。

Pipeliningの使いどころ

オプションひとつでパフォーマンスが向上するので便利な機能なのですが、実際に役に立つ場面は限られます。

まず、別のサーバーに対してリクエストするのであれば役に立ちません。Pipeliningは同一サーバーへのリクエストをまとめるものだからです。Twitter APIとFacebook APIを同時に叩くためにPipeliningは使えません。

もちろんサーバー側がPipeliningに対応している必要もあります。ApacheやNginxなどの著名なhttpdであればだいたい対応していますが、node.jsで自前で建てたサーバーの場合は対応していないことがあるでしょう。

また、同一サーバーであっても3回以上リクエストしないと意味がありません。まずサーバーがPipeliningに対応しているか確認しなければならないので、最初のリクエストはPipeline化されず通常通りリクエストされます。2回のリクエストをPipeliningでまとめようとしても、お伺いの1回目と、Pipeline化された2回目に分断されるので、まとめたことになりません。

そのほか、POSTやPUTといった副作用のあるメソッドに対しては使わない方がよいとされています。

そんなわけで、ブラウザのように大量の画像を同じサーバーからダウンロードしたりするのに使うのがもっぱらです。

HTTPクライアントが高性能であれば、REST Oriented、つまり全機能をひたすらWebAPIとして作って疎結合にするようなアーキテクチャも現実的になりますので、新しいcURLは世界を広げてくれるのではないでしょうか(てきとー)。

keyword: HTTP Curl PHP

PHPの最新記事