hnwの日記

PHPでHTTPの並行ダウンロードを実現する(Guzzle編)

PHPで最近注目のHTTPクライアントライブラリにGuzzleがあります。日本での知名度はまだまだという印象ですが、かなり高機能かつ真面目にメンテナンスされている印象で、今後のデファクトスタンダードになりうるライブラリと言えるでしょう。


本稿ではこのGuzzleを使ってWebサーバから並行にダウンロードする方法を紹介します。Webブラウザのように同時に複数コネクションを管理しながらKeep-Aliveでコネクションを使い回しますので、下手なコードで実現するより接続先Webサーバにも優しいはずです。

Guzzleの特徴

まずは、Guzzleについて僕が特徴的だと思う点を紹介します。

  • パッと見でわかりやすいインターフェース
  • cURLは必須ではないがデフォルトでcURLを使う
    • cURLの無い環境がありうるので、cURL無しでも動くのは嬉しい
    • cURLのわかりにくいインターフェースを隠してくれるのも嬉しい
  • イベントフック(リクエストの前後などでフック可能)の導入
  • Composer対応
  • クッキー、OAuth、キャッシュ、ロギングなど多くのプラグインを提供


欠点は巨大すぎることと、マニュアルの日本語訳がまだ無いことくらいでしょう。

Guzzleのバージョン

Guzzleを使う上で、Guzzleのバージョンには要注意です。GuzzleにはGuzzle 3とGuzzle 4と2つのバージョンがあります。同じ機能でもGuzzle 3とGuzzle 4とでメソッド名が変わっていたりするので、検索で見つけた記事を読むような場合は注意してください。


Guzzle 3とGuzzle 4は異なるネームスペースを利用していますので、どちらを使っているかは一目瞭然です。Guzzle 3の名前空間は「\Guzzle\」から始まり、Guzzle 4は「\GuzzleHttp\」から始まります。


また、Packagistでのパッケージ名もGuzzle 3は「guzzle/guzzle」、Guzzle 4+は「guzzlehttp/guzzle」となっていますので、composer.jsonに書く際も注意が必要です。


Guzzle 4は今年の3月にリリースされたばかりですが、今から使うならこちらを使った方が良いでしょう。本稿でもGuzzle 4を利用しています。

並行ダウンロードするサンプルコード

Guzzleの一般的な使い方は他のページを見て頂くとして、早速並行ダウンロードのサンプルコードを紹介します。このコードはジェネレータを使いたかったのでPHP 5.5以降でしか動作しませんが、Guzzle 4はPHP 5.4以降で動くはずです。

<?php
use GuzzleHttp\Client;
use GuzzleHttp\Event\CompleteEvent;

$client = new Client();
$client->sendAll(requests_generator($client), [
        'parallel' => 4,
        'complete' => function (CompleteEvent $event) {
            echo 'Completed request to ' . $event->getRequest()->getUrl() . "\n";
            echo 'Response: ' . $event->getResponse()->getBody() . "\n\n";
        }
    ]);

function requests_generator($client)
{
    // IDの若いGitHubユーザー20人
    $loginIds = [
        'mojombo', 'defunkt', 'pjhyett', 'wycats', 'ezmobius',
        'ivey', 'evanphx', 'vanpelt', 'wayneeseguin', 'brynary',
        'kevinclark', 'technoweenie', 'macournoyer', 'takeo', 'Caged',
        'topfunky', 'anotherjesse', 'roland', 'lukas', 'fanvsfan',
    ];
    foreach ($loginIds as $loginId) {
        yield $client->createRequest('GET', 'https://github.com/'.$loginId.'.keys');
    }
}


これは4並列でgithub.comの20URLへHTTPSアクセスする例です。ここで利用しているsendAllメソッドが今回のキモで、並列にHTTPリクエストを投げるメソッドになります。第一引数でリクエストの配列またはイテレータを渡し、第二引数の連想配列で並列数とリクエスト終了毎に呼ばれるコールバック関数を指定するようなインターフェースになっています。


これを実際に動かすと、4コネクションを作った上で、作ったコネクションをKeep-Aliveで使い回しながら並列にリクエストを投げてくれます。HTTPSの場合はコネクションの確立に時間がかかるので、こうしたテクニックが特に有効です。


これを実行してみるとジェネレータの順番と異なる順序で結果が返ってくることがあります。4並列の非同期I/O処理になっており、早く返ってきたレスポンスから順に処理されるためです。サンプルコードもPHPというよりNode.jsの方が近いくらいの印象で、非同期I/Oっぽいコードと言えるかもしれません。


ちなみに、PHPは他の言語のように非同期I/Oをうまく扱うような機構はありませんが、cURLエクステンションを使えばHTTP・HTTPSについての非同期I/O処理はギリギリ実現できます。Guzzleでも内部的にはcurl_multi_init関数やcurl_multi_exec関数、curl_multi_select関数などを利用しています。また、cURL無しの環境では上のサンプルコードは直列に動作します。

ベンチマークテスト

Keep-Aliveとコネクションプーリングが有効であることを確認する意味で、簡易的なベンチマークテストを取ってみました。

実行時間(sec)
(1) 4並列 2.2
(2) 1並列 4.7
(3) 1並列、Keep-Alive無し 22.7


(1)が上記のサンプルコードで、(2)は下記のようにcurlコマンド一発で20URLを取得したものです。

$ curl https://github.com/{mojombo,defunkt,pjhyett,wycats,ezmobius,ivey,evanphx,vanpelt,wayneeseguin,brynary,kevinclark,technoweenie,macournoyer,takeo,Caged,topfunky,anotherjesse,roland,lukas,fanvsfan}.keys


(2)は1接続をKeep-Aliveで使い回していると考えられますが、Guzzleで4並列にした方が倍以上速いという結果になりました。


また、(3)は下記のようにfor文でcurlコマンドを20回呼び出したものです。

$ (for i in https://github.com/{mojombo,defunkt,pjhyett,wycats,ezmobius,ivey,evanphx,vanpelt,wayneeseguin,brynary,kevinclark,technoweenie,macournoyer,takeo,Caged,topfunky,anotherjesse,roland,lukas,fanvsfan}.keys ; do curl $i; done)


実装者の経験不足などで(3)のように20回接続しては接続断するコードを書いた場合Guzzleで書くより10倍遅くなる可能性さえありそうです。怖いですね。


ちなみに、Guzzleで更に並列数を上げると多少は速くなりますが、たまに極端に遅くなったりしていたので、最大でも6並列くらいにとどめておいた方が良さそうです。

参考記事