PHPで複数の API を呼び出そう!

今日では、Google, Yahoo, Amazon などいろいろなところで情報をAPI(Application Program Interface)というかたちで提供しています。そこで、それらの情報を PHPによって収集/利用するのに cURLというプロジェクトを利用してみます。

* cURLとは

cURLとは、いろいろな通信プロトコルを用いてデータを転送するライブラリやコマンドを提供するプロジェクトで、Client for URLsからきています。

この cURLを利用し、いろいろなサービスが提供している情報を収集することができます。

cURL

* cURLを使ってみる

実際に Yahoo JAPANが提供している RSSを取得してみよう。(エラー処理は省いています)

<?php
$sUrl = "http://headlines.yahoo.co.jp/rss/eiga_c_ent.xml";
 
$conn = curl_init();
curl_setopt($conn, CURLOPT_URL, $sUrl);
curl_setopt($conn, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($conn, CURLOPT_TIMEOUT, 5);
curl_setopt($conn, CURLOPT_HEADER, FALSE);
$ret = curl_exec($conn);
curl_close($conn);
 
$xml = simplexml_load_string($ret);
foreach($xml->channel->item as $item) {
    echo (string)$item->title."\n";
}
?>

このように簡単に取得する事が出来ます。しかし、API を複数呼んで取得した情報をまとめて利用する場合、API ひとつひとつを順番に呼んで処理していては最後の情報を取得するまでに無駄な時間がかかってしまいます。そこで、cURL multi という技術を利用すると APIを同時に複数呼ぶことが出来ます。実際の通信時間は一番遅い APIとなります。

cURL multi

※ 同時に APIを呼ぶか、順番に API を呼ぶかは、情報の利用方法によって異なります。

* cURL multi を利用するなら自分なりのクラスを作ってみる

クラスを作る上で以下の点に注意してみます。

キャッシュの仕組みはいろいろあるが、最も簡単な APC(Alternative PHP Cache)を利用してみる。この部分は、memcachedなど別のキャッシュシステムに変更出来るよう考慮しておく。

以下が作ってみたクラス:

<?php
/**
 * QueryRequestクラス
 *
 * @package Gadgety
 * @create  2007.12.05  Shinta
 *
 * @version $Id$
 */

/**
 * QueryRequestクラス
 *
 * <pre>
 * 必要な extension: curl, md5, apc
 * APCの設定例:
 *  extension = "apc.so"
 *  apc.enabled=1           # APCの有効(1)/無効(0)
 *  apc.shm_segments=1      # セグメント数
 *  apc.shm_size=64         # 共有メモリ・セグメントサイズ(MB)
 *  apc.num_files_hit=1024
 *  apc.gc_ttl=3600         # キャッシュエントリーが GC listに残るかもしれない時間
 *  apc.ttl=0               # 他のエントリーに割り当てられるまでキャッシュに残る時間。0は解放しない
 *  apc.mmap_file_mask=/tmp/apc.XXXXXX
 *  apc.filters=            # キャッシュしないファイルリスト(カンマ区切り、正規表現可)
 *
 * ・cUrl multiによって複数の APIを並列にコールする
 * ・各APIが返す結果(XML, JSON, etc)を格納
 * ・現時点では HTTP/HTTPS のみ対応
 * ・APIを何度の呼ぶのは通信コストが高いのでキャッシュの機能を組み込む
 * ・キャッシュデータを小さくする為に圧縮可能にする
 * ・POST呼び出しによる APIの結果はキャッシュしない
 *
 * << 利用方法 >>
 * $objReq = new QueryRequest();
 *
 * // キャッシュの設定
 * $objReq->enableCache();
 * $objReq->enableCompressCache();
 *
 * // URLによる APIサーバ指定
 * $sUrl = "http://api1.example.com/rss.php?key=main&p=%B2%F7%C5%AC";
 * $objReq->addRequest("MainApi", $sUrl);
 * 
 * // HASHによる APIサーバ指定
 * $aryInfo = array(
 *         "HOST"     => "api2.example.com"     // 必須
 *        ,"URI"      => "prog.cgi"             // デフォルト "/"
 *        ,"TIMEOUT"  => 8                      // デフォルト 5秒
 *        ,"PORT"     => 80                     // デフォルト 80
 *        ,"PROTOCOL" => "http"                 // デフォルト "http"
 *        ,"QUERY"    => array("key"=>"sub", "p"=>"快適")  // デフォルト array()
 *     );
 * $objReq->addHashRequest("SubApi", $aryInfo, 5);
 *
 * // リクエスト発行
 * $objReq->execute();
 *
 * // 取得データ
 * $sMain = $objReq->getData("MainApi");
 * if ( $sMain ) {
 *     // 取得データ(XMLなど)を解析利用
 *     if ( preg_match("/<\?xml.*\?>/", $sMain) ) {
 *         $rss = simplexml_load_string($sMain);
 *         foreach($xml->channel->item as $item) {
 *             echo (string)$item->title."\n";
 *         }
 *     }
 * } else {
 *     // メイン情報が取れないから リロードページ?404?
 *     include("reload.inc");
 *     exit(0);
 * }
 *
 * $sSub = $objReq->getData("SubApi");
 * if ( $sSub ) {
 *     // 処理
 *     ...
 * } else {
 *     // 補足情報が取れないぐらいなら無視するなど・・・
 * }
 * </pre>
 *
 * @subpackage QueryRequest
 * @access  public
 * @auther  Shinta
 */
class QueryRequest {
    /**@+
     * 定数
     */
    const DEF_PROTOCOL = "http";        // プロトコル
    const DEF_PORT     = "80";          // ポート
    const DEF_TIMEOUT  = 5;             // タイムアウト(秒)
    /**@-*/

    /**
     * リクエスト値
     *
     * @access  private
     * @var     array
     */
    private $aryReqList = array();

    /**
     * レスポンス値
     *
     * @access  private
     * @array
     */
    private $aryResList = array();

    /**
     * リクエストデータ
     *
     * @access  private
     * @var     array
     */
    private $aryReqData = array();

    /**
     * キャッシュデータ
     *
     * @access  private
     * @var     string
     */
    private $sCache = null;

    /**
     * キャッシュ利用有無
     *
     * @access  private
     * @var     boolean
     */
    private $bCache = false;

    /**
     * キャッシュ圧縮モード
     *
     * @access  private
     * @var     boolean
     */
    private $bGzCache = false;

    /**
     * インフォメーションログの表示有無
     *
     * @access  private
     * @var     boolean
     */
    private $bShowInfo = false;

    /**
     * コンストラクタ
     *
     * @access  public
     * @retuen  void
     */
    function __construct() {
        $this->init();
    }

    /**
     * デストラクタ
     *
     * @access  public
     * @retuen  void
     */
    function __destruct() {
        $this->sCache = null;
    }

    /**
     * リクエスト/受信データの初期化
     *
     * @access  public
     * @return  void
     */
    function init() {
        $this->aryReqList = array();
        $this->aryResList = array();
        $this->aryReqData = array();
    }

    /**
     * APIコール設定メソッド(HASH版)
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @param   array   $aryInfo        API情報
     * @param   int     $iCache         キャッシュの有効時間
     * @return  boolean
     */
    function addRequestHash($sLabel, $aryInfo, $iCache=0) {
        if ( empty($sLabel) || !is_array($aryInfo) || count($aryInfo) == 0 ) {
            $this->error("request entry error: label or url is empty");
            return false;
        }

        // URL生成
        if ( isset($aryInfo['HOST']) && $aryInfo['HOST'] != '' ) {
            $sPro = self::DEF_PROTOCOL;
            if ( isset($aryInfo['PROTOCOL']) ) {
                $sPro = $aryInfo['PROTOCOL'];
            }
            $sPro .= "://";

            $sUrl = "/";
            if ( isset($aryInfo['URI']) && $aryInfo['URI'] != '' ) {
                if ( substr($aryInfo['URI'], 0, 1) == "/" ) {
                    $sUrl  = $aryInfo['URI'];       // 初期化
                } else {
                    $sUrl .= $aryInfo['URI'];       // 追加
                }
            }

            $sQuery = "";
            if ( isset($aryInfo['QUERY']) && is_array($aryInfo['QUERY']) ) {
                foreach( $aryInfo['QUERY'] as $sKey => $sVal ) {
                    if ( $sQuery == "" ) {
                        $sQuery .= '?';
                    } else {
                        $sQuery .= '&';
                    }
                    $sQuery .= $sKey."=".rawurlencode($sVal);
                }
            }

            $this->aryReqList[$sLabel]['URL']
                = $sPro.$aryInfo['HOST'].$sUrl.$sQuery;
            $this->aryReqList[$sLabel]['URL_MD5']
                = $this->makeCacheKey($this->aryReqList[$sLabel]['URL']);
        } else {
            $this->error("request entry error: hostname is empty");
            return false;
        }

        // POST向け処理
        if ( isset($aryInfo['POST']) && is_array($aryInfo['POST']) ) {
            foreach( $aryInfo['POST'] as $sKey => $sData ) {
                $this->aryReqList[$sLabel]['POST'][$sKey] = $sData;
            }
        }

        // キャッシュ
        $this->aryReqList[$sLabel]['CACHE'] = $iCache;

        // オプション
        if ( isset($aryInfo['TIMEOUT']) && $aryInfo['TIMEOUT'] > 0 ) {
            $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_CONNECTTIMEOUT] = $aryInfo['TIMEOUT'];
            $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_TIMEOUT]        = $aryInfo['TIMEOUT'];
        } else {
            $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_CONNECTTIMEOUT] = self::DEF_TIMEOUT;
            $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_TIMEOUT]        = self::DEF_TIMEOUT;
        }
        if ( isset($aryInfo['PORT']) ) {
            $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_PORT] = $aryInfo['PORT'];
        }

        return true;
    }

    /**
     * APIコール設定メソッド(QueryString)
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @param   string  $sUrl           URL
     * @param   int     $iCache         キャッシュの有効時間
     * @return  boolean
     */
    function addRequest($sLabel, $sUrl, $iCache=0) {
        if ( empty($sLabel) || empty($sUrl) ) {
            $this->error("request entry error: label or url is empty");
            return false;
        }

        $aryUrl = split("\?", $sUrl, 2);
        if ( isset($aryUrl[1]) ) {
            $aryParam = array();
            $aryQuery = array();
            parse_str($aryUrl[1], $aryParam);
            foreach( $aryParam as $sKey => $sVal ) {
                $aryQuery[] = $sKey."=".rawurlencode($sVal);
            }
            $sUrl = $aryUrl[0]."?".implode("&", $aryQuery);
        }
        
        $this->aryReqList[$sLabel] = array(
                'URL'     => $sUrl,
                'URL_MD5' => $this->makeCacheKey($sUrl),
                'CACHE'   => $iCache,
                'OPTIONS' => array(
                        CURLOPT_CONNECTTIMEOUT => self::DEF_TIMEOUT,
                        CURLOPT_TIMEOUT        => self::DEF_TIMEOUT
                    )
            );

        return true;
    }

    /**
     * オプション追加
     *
     * <pre>
     * $aryArh = apache_request_headers();
     * $objReq = new QueryRequest();
     * $objReq->addRequest("MainApi", ...);
     * $objReq->addOptions("MainApi", array(CURLOPT_COOKIE => $aryArh["Cookie"]));
     * </pre>
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @param   array   $aryOpt         オプション
     * @return  boolean
     */
    function addOptions($sLabel, $aryOpt) {
        if ( empty($sLabel) || !is_array($aryInfo) ) {
            $this->error("request entry error: label or option is empty");
            return false;
        }

        foreach( $aryOpt as $sKey => $sVal) {
            $this->aryReqList[$sLabel]['OPTIONS'][$sKey] = $sVal;
        }

        return true;
    }

    /**
     * タイプアウト設定
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @param   integer $iTime          タイムアウト値
     * @return  boolean
     */
    function setTimeout($sLabel, $iTime) {
        if ($iTime > 0 ) {
            $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_CONNECTTIMEOUT] = $iTime;
            $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_TIMEOUT]        = $iTime;
        }
    }

    /**
     * POSTデータセット
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @param   string  $sKey           POST送信したいデータ名
     * @param   string  $sData          POST送信したいデータ
     * @return  boolean
     */
    function addPostData($sLabel, $sKey, $sData) {
        // POST向け処理
        if ( isset($this->aryReqList[$sLabel]) ) {
            $this->aryReqList[$sLabel]['POST'][$sKey] = $sData;
        }
    }

    /**
     * APIコール実行
     *
     * @access  public
     * @return  array
     */
    function execute() {
        $iApiCache = 0;
        $iApiCall  = 0;
        $iApiTotal = count($this->aryReqList);
        if ( $iApiTotal == 0 ) {
            $this->error("execute error: no api call");
            return null;
        }

        $objMulti   = curl_multi_init();
        $aryChannel = array();
        foreach( $this->aryReqList as $sLabel => $aryParam ) {
            // cacheが有効かつ cacheが存在する
            if ( $aryParam["CACHE"] > 0 && $this->hasCache($aryParam["URL_MD5"]) ) {
                // キャッシュデータの取得
                $sBody = $this->getCache($aryParam["URL_MD5"]);
                if ($sBody == "") {
                    $this->aryReqData[$sLabel] = null;
                    $this->aryReqList[$sLabel]["http_code"] = "404";
                    $this->removeCache($aryParam["URL_MD5"]);
                } else {
                    $this->aryReqData[$sLabel] = $sBody;
                    $this->aryReqList[$sLabel]["http_code"] = "200";
                    $iApiCache++;
                }
                $this->info("DATA from cache - ".$aryParam["URL"]);
            }

            // cacheが無効または cacheが存在しない
            else {
                // 個々のリクエストの cURLチャンネルの登録
                $objCurl = curl_init();
                curl_setopt($objCurl, CURLOPT_URL, $aryParam['URL']);
                curl_setopt($objCurl, CURLOPT_RETURNTRANSFER, 1);
                if ( isset($aryParam["POST"]) ) {
                    curl_setopt($objCurl, CURLOPT_POST,       TRUE);
                    curl_setopt($objCurl, CURLOPT_POSTFIELDS, $aryParm["POST"]);
                    $aryChannel[$sLabel]["CACHE"] = 0;
                } else {
                    $aryChannel[$sLabel]["CACHE"] = $aryParam["CACHE"];
                }
                $aryChannel[$sLabel]["OBJECT"]  = $objCurl;
                $aryChannel[$sLabel]["URL"]     = $aryParam["URL"];
                $aryChannel[$sLabel]["URL_MD5"] = $aryParam["URL_MD5"];

                if ( count($aryParam['OPTIONS']) != 0 ) {
                    curl_setopt_array($objCurl, $aryParam['OPTIONS']);
                }
                curl_multi_add_handle($objMulti, $objCurl);

                $this->info("DATA from api  - ".$aryParam["URL"]);
                $iApiCall++;
            }
        }

        $this->info("API Info  - Total:".$iApiTotal
                    ."  Cache:".$iApiCache
                    ."  Call:".$iApiCall);

        $sCacheStatus = "";
        if ($this->bCache) {
            $sCacheStatus .= "active";
        } else {
            $sCacheStatus .= "inactive";
        }
        if ($this->bGzCache) {
            $sCacheStatus .= " compress";
        }
        $this->info("API cahce mode: ".$sCacheStatus);

        if ($iApiCall == 0) {
            return $this->aryResList;
        }

        // 並列にリクエストを処理(すべてのAPIの結果が返ってくるまで待ち、そしてパージ)
        $active = null;
        do {
            $iRet = curl_multi_exec($objMulti, $active);
        } while ($iRet == CURLM_CALL_MULTI_PERFORM);
        while ($active && $iRet == CURLM_OK) {
            if (curl_multi_select($objMulti) != -1) {
                do {
                    $iRet = curl_multi_exec($objMulti, $active);
                } while ($iRet == CURLM_CALL_MULTI_PERFORM);
            }
        }

        // 受信結果を保存
        foreach( $aryChannel as $sLabel => $aryCurl ) {
            $sBody = curl_multi_getcontent($aryCurl["OBJECT"]);
            $this->aryResList[$sLabel] = curl_getinfo($aryCurl["OBJECT"]);
            $iHttpCode = $this->aryResList[$sLabel]['http_code'];
            if ($iHttpCode == 200 && $sBody != "") {
                $this->aryReqData[$sLabel] = $sBody;
                // キャッシュへセット
                if ( $this->bCache && $aryCurl["CACHE"] > 0 ) {
                    $this->setCache($aryCurl["URL_MD5"], $sBody, $aryCurl["CACHE"]);
                }
            } else {
                $this->aryReqData[$sLabel] = null;
            }
            curl_multi_remove_handle($objMulti, $aryCurl["OBJECT"]);
            curl_close($aryCurl["OBJECT"]);
        }
        curl_multi_close($objMulti);

        reset($this->aryReqList);
        return($this->aryResList);
    }

    /**
     * データの取得
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @return  mixed
     */
    function getData($sLabel='') {
        if ( $sLabel == '' ) {
            return $this->aryReqData;
        } else if ( isset($this->aryReqData[$sLabel]) ) {
            return $this->aryReqData[$sLabel];
        } else {
            return null;
        }
    }

    /**
     * レスポンス情報の取得
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @return  array
     */
    function getResponseInfo($sLabel='') {
        if ( $sLabel != '' && isset($this->aryResList[$sLabel]) ) {
            return $this->aryResList[$sLabel];
        } else {
            return $this->aryResList;
        }
    }

    /**
     * HTTPステータスの取得
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @return  int
     */
    function getHttpStatus($sLabel) {
        if ( $sLabel != '' && isset($this->aryResList[$sLabel]) ) {
            return $this->aryResList[$sLabel]['http_code'];
        } else {
            return -1;
        }
    }

    /**
     * リクエスト情報の取得
     *
     * @access  public
     * @param   string  $sLabel         API識別ラベル
     * @return  array
     */
    function getRequestInfo($sLabel='') {
        if ( $sLabel != '' && isset($this->aryReqList[$sLabel]) ) {
            return $this->aryReqList[$sLabel];
        } else {
            return $this->aryReqList;
        }
    }

    /**
     * キャッシュ格納のキーを作成
     *
     * @access  public
     * @param   string  $sUrl
     * @return  string
     */
    function makeCacheKey($sUrl) {
        $seed = get_class($this)."::";
        return md5($seed.$sUrl);
    }

    /**
     * キャッシュが存在するかチェック
     *
     * @access  public
     * @param   string  $key
     * @return  boolean
     */
    function hasCache($key) {
        $data = false;
        if ($this->bCache) {
            $data = apc_fetch($key);
        }
        if ($data !== false) {
            $this->sCache = $data;
            $this->info("has cache ".$key);
            return true;
        } else {
            $this->sCache = null;
            $this->info("no cache  ".$key);
            return false;
        }
    }

    /**
     * キャッシュの取得
     *
     * @access  public
     * @param   string  $key
     * @return  data
     */
    function getCache($key) {
        if ($this->bGzCache) {
            return gzuncompress($this->sCache);
        } else {
            return $this->sCache;
        }
    }

    /**
     * キャッシュへ保存
     *
     * @access  public
     * @param   string  $key
     * @param   string  $content
     * @param   integer $ttl
     * @return  bool
     */
    function setCache($key, $content, $ttl=0) {
        // キャッシュの圧縮
        if ($this->bGzCache) {
            $content = gzcompress($content);
        }

        $bRet = false;
        if ($this->bCache) {
            $bRet = apc_store($key, $content, $ttl);
            if ($bRet) {
                $this->info("set cache as ".$key);
            } else {
                $this->error("set cache failed");
            }
        }
        return $bRet;
    }

    /**
     * キャッシュを削除
     *
     * @access  public
     * @param   string  $key
     * @return  bool
     */
    function removeCache($key) {
        $bRet = false;
        if ($this->bCache) {
            $bRet = apc_delete($key);
            if ($bRet) {
                $this->info("remove cache of ".$key);
            } else {
                $this->error("remove cache failed");
            }
        }
        return $bRet;
    }

    /**
     * キャッシュモード
     *
     * @access  public
     * @param   string  $key
     * @return  bool
     */
    function enableCache() {
        if ( function_exists("apc_store") ) {
            $this->bCache = true;
        }
    }

    /**
     * キャッシュ圧縮モード
     *
     * @access  public
     * @param   string  $key
     * @return  bool
     */
    function enableCompressCache() {
        if ( function_exists("gzcompress") ) {
            $this->bGzCache = true;
        }
    }

    /**
     * インフォメーションログ出力を有効にする
     */
    public function showInfo() {
        $this->bShowInfo = true;
    }

    /**
     * エラーログ出力
     *
     * @access  private
     * @param   string  $sMsg
     * @param   string  $sLine
     * @return  void
     */
    private function error($sMsg) {
        error_log($sMsg, 0);
    }

    /**
     * インフォメーションログ出力
     *
     * @access  private
     * @param   string  $sMsg
     * @return  void
     */
    private function info($sMsg) {
        if ($this->bShowInfo) {
            error_log($sMsg, 0);
        }
    }
}

?>

このクラスを利用して、API を呼んでみます。以下のプログラムは、二種類の APIを呼んでいます。一つ目の APIの結果は5秒キャッシュされ、二つ目の API の結果は存在しないのでキャシュされることはありません。

<?php
include("QueryRequest.php");
 
$objReq = new QueryRequest();
 
// キャッシュの設定
$objReq->enableCache();
$objReq->enableCompressCache();   // APIからのデータを圧縮してキャッシュ
 
// URLによる APIサーバ指定
$sUrl   = "http://headlines.yahoo.co.jp/rss/eiga_c_ent.xml";
$objReq->addRequest("MainApi", $sUrl, 5);
 
// HASHによる APIサーバ指定
$aryInfo = array(
        "HOST"     => "http://www.example.com/", // 必須
        "URI"      => "prog.cgi",                // デフォルト "/"
        "TIMEOUT"  => 8,                         // デフォルト 5秒
        "PORT"     => 80,                        // デフォルト 80
        "PROTOCOL" => "http",                    // デフォルト "http"
        "QUERY"    => array("key"=>"sub", "p"=>"快適")  // デフォルト array()
    );
$objReq->addRequestHash("SubApi", $aryInfo, 5);
 
// リクエスト発行
$objReq->execute();
 
// 取得データ
$sMain = $objReq->getData("MainApi");
if ( $sMain ) {
    // 取得データ(XMLなど)を解析利用
    if ( preg_match("/<\?xml.*\?>/", $sMain, $aryMatch) ) {
        $xml = simplexml_load_string($sMain);
        foreach($xml->channel->item as $item) {
            echo (string)$item->title."\n";
        }
    }
} else {
    // メイン情報が取れないから リロードページ?404?
    error_log("404 ------",0);
    exit(0);
}
$sSub = $objReq->getData("SubApi");
if ( $sSub ) {
    // 処理
} else {
    // 補足情報が取れないぐらいなら無視するなど・・・
}
?>

QueryRequestクラスで利用している parse_str() の挙動は、値を urldecode, htmlspecialchar同等の処理を通すのでいまいち使いにくいので似たような関数を実装した方が良いかもしれない。また、ログイン認証が必要な APIを呼び出す為に以下のようなメソッドを用意する必要があるかもしれない。。。POST時の扱いももう少し検討が必要かなあ〜

function setUser($sLabel, $sUser, $sPasswd) {
    if ( empty($sLabel) || empty($sUser) || empty($sPasswd) ) {
        $this->error("request entry error: label or user or password is empty");
        return false;
    }
 
    $this->aryReqList[$sLabel]['OPTIONS'][CURLOPT_USERPWD] = $sUser.":".$sPasswd;
 
    return true;
}