PHPのcurlコネクションの再利用

# なぜ

以下の方法は全然最適解ではないのだけれども、
web apiをcurlでたたきまくる必要があって、
それが原因でローカルTCPポートが枯渇してしまい、
接続できなくなる、という問題に直面。

この現象については以下参照。

http://d.hatena.ne.jp/download_takeshi/20091013/1255443592

で、本来はweb apiやクライアントの使い方等含めて解決する問題ではあるが、
とりあえずTCPの接続を使い回せれば何とかなる問題だということで、
掲題の実装を行ってみたので、その際の注意すべきこととかもあったので共有。
最終的にもやもやな感じだけど。。

# リクエスト毎に接続を行う実装

上記の問題が発生した際のコードのだいたいイメージとしては以下のような感じ（簡略して書いてます）。

```php:curl.php
<?php

$cw = new CurlWrapper();

for ($i = 0; $i < $argv[1]; $i++) {
    try {
        $resp = $cw->get("http://localhost/");
        echo $resp->getResponseBody() . PHP_EOL;
    } catch (Exception $e) {
        echo $e->getMessage() . PHP_EOL;
    }
}

class HttpWrapper {

    public function get($url) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "GET";
        return $this->_exec($url, $curlOptions);
    }

    public function put($url, $contentBody) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "PUT";
        $curlOptions[CURLOPT_POSTFIELDS] = $contentBody;
        return $this->_exec($url, $curlOptions);
    }

    public function post($url, $contentBody) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "POST";
        $curlOptions[CURLOPT_POSTFIELDS] = $contentBody;
        return $this->_exec($url, $curlOptions);
    }

    public function delete($url) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "DELETE";
        return $this->_exec($url, $curlOptions);
    }

    private function _exec($url, $curlOptions = array()) {

        $ch = curl_init();

        $curlOptions[CURLOPT_URL] = $url;
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
        $curlOptions[CURLOPT_VERBOSE] = true; //curlのログを標準エラーに出力する

        curl_setopt_array($ch, $curlOptions);

        $responseBody = curl_exec($ch);
        $info = curl_getinfo($ch);
        $error = curl_error($ch);
        curl_close($ch);

        if ($error) {
            throw new Exception($error);
        }

        return new HttpResponse($info[CURLINFO_HTTP_CODE], $responseBody);
    }
}

class HttpResponse {

    private $_statusCode;
    private $_responseBody;

    public function __construct($statusCode, $responseBody) {
        $this->_statusCode = $statusCode;
        $this->_responseBody = $responseBody;
    }

    public function getStatusCode() {
        return $this->_statusCode;
    }

    public function getResponseBody() {
        return $this->_responseBody;
    }
}
```

で、このスクリプトを実行すると、以下のような出力になる。

```shell-session:
$ php curl.php 2
* About to connect() to localhost port 80 (#0)
*   Trying ::1...
* connected
* Connected to localhost (::1) port 80 (#0)
> GET / HTTP/1.1
Host: localhost
Accept: */*

< HTTP/1.1 200 OK
< Date: Wed, 19 Jun 2013 15:14:54 GMT
< Server: Apache/2.2.22 (Unix) DAV/2 mod_ssl/2.2.22 OpenSSL/0.9.8x
< Content-Location: index.html.en
< Vary: negotiate
< TCN: choice
< Last-Modified: Sat, 28 Jul 2012 04:57:43 GMT
< ETag: "2120c2c-2c-4c5dcab399fc0"
< Accept-Ranges: bytes
< Content-Length: 44
< Content-Type: text/html
< Content-Language: en
<
* Connection #0 to host localhost left intact
* Closing connection #0
<html><body><h1>It works!</h1></body></html>
* About to connect() to localhost port 80 (#0)
*   Trying ::1...
* connected
* Connected to localhost (::1) port 80 (#0)
> GET / HTTP/1.1
Host: localhost
Accept: */*

< HTTP/1.1 200 OK
< Date: Wed, 19 Jun 2013 15:14:54 GMT
< Server: Apache/2.2.22 (Unix) DAV/2 mod_ssl/2.2.22 OpenSSL/0.9.8x
< Content-Location: index.html.en
< Vary: negotiate
< TCN: choice
< Last-Modified: Sat, 28 Jul 2012 04:57:43 GMT
< ETag: "2120c2c-2c-4c5dcab399fc0"
< Accept-Ranges: bytes
< Content-Length: 44
< Content-Type: text/html
< Content-Language: en
<
* Connection #0 to host localhost left intact
* Closing connection #0
<html><body><h1>It works!</h1></body></html>
```

上記の出力を見るとわかるように、$cw->getを呼び出す度に以下のような接続とクローズを行っている。

シェルでの呼び出し時に引数に10000とかの数字を入れて実行して、その間にnetstatで接続状態を確認すると、localhostの80番ポートに接続しにいって、TIME_WAITになった接続が大量に表示される。

そこで、（そもそも、そんなに大量のhttpリクエストを送っているのか、という問題はさておき）、この問題を解決するために以下のような実装を行う。

# コネクションを再利用する実装

```php:curl_connection_reuse.php
<?php

$cw = new CurlWrapper();

for ($i = 0; $i < $argv[1]; $i++) {
    try {
        $resp = $cw->get("http://localhost/");
        echo $resp->getResponseBody() . PHP_EOL;
    } catch (Exception $e) {
        echo $e->getMessage() . PHP_EOL;
    }
}


class CurlWrapper {

    private $_ch;

    public function __destruct() {
        curl_close($this->_ch);
        unset($this->_ch);
    }

    public function get($url) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "GET";
        return $this->_exec($url, $curlOptions);
    }

    public function put($url, $contentBody) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "PUT";
        $curlOptions[CURLOPT_POSTFIELDS] = $contentBody;
        return $this->_exec($url, $curlOptions);
    }

    public function post($url, $contentBody) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "POST";
        $curlOptions[CURLOPT_POSTFIELDS] = $contentBody;
        return $this->_exec($url, $curlOptions);
    }

    public function delete($url) {
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "DELETE";
        return $this->_exec($url, $curlOptions);
    }

    private function _exec($url, $curlOptions = array()) {

        if (!isset($this->_ch)) {
            $this->_ch = curl_init();
        }

        $curlOptions[CURLOPT_URL] = $url;
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
        $curlOptions[CURLOPT_VERBOSE] = true; //curlのログを標準エラーに出力する

        curl_setopt_array($this->_ch, $curlOptions);

        $responseBody = curl_exec($this->_ch);
        $info = curl_getinfo($this->_ch);
        $error = curl_error($this->_ch);

        if ($error) {
            throw new Exception($error);
        }

        return new HttpResponse($info[CURLINFO_HTTP_CODE], $responseBody);
    }
}

class HttpResponse {

    private $_statusCode;
    private $_responseBody;

    public function __construct($statusCode, $responseBody) {
        $this->_statusCode = $statusCode;
        $this->_responseBody = $responseBody;
    }

    public function getStatusCode() {
        return $this->_statusCode;
    }

    public function getResponseBody() {
        return $this->_responseBody;
    }
}
```

この出力は以下の通り。

```shell-session:
$ php curl_connection_reuse.php 2
* About to connect() to localhost port 80 (#0)
*   Trying ::1...
* connected
* Connected to localhost (::1) port 80 (#0)
> GET / HTTP/1.1
Host: localhost
Accept: */*

< HTTP/1.1 200 OK
< Date: Wed, 19 Jun 2013 15:39:59 GMT
< Server: Apache/2.2.22 (Unix) DAV/2 mod_ssl/2.2.22 OpenSSL/0.9.8x
< Content-Location: index.html.en
< Vary: negotiate
< TCN: choice
< Last-Modified: Sat, 28 Jul 2012 04:57:43 GMT
< ETag: "2120c2c-2c-4c5dcab399fc0"
< Accept-Ranges: bytes
< Content-Length: 44
< Content-Type: text/html
< Content-Language: en
<
* Connection #0 to host localhost left intact
<html><body><h1>It works!</h1></body></html>
* Re-using existing connection! (#0) with host (nil)
* Connected to (nil) (::1) port 80 (#0)
> GET / HTTP/1.1
Host: localhost
Accept: */*

< HTTP/1.1 200 OK
< Date: Wed, 19 Jun 2013 15:39:59 GMT
< Server: Apache/2.2.22 (Unix) DAV/2 mod_ssl/2.2.22 OpenSSL/0.9.8x
< Content-Location: index.html.en
< Vary: negotiate
< TCN: choice
< Last-Modified: Sat, 28 Jul 2012 04:57:43 GMT
< ETag: "2120c2c-2c-4c5dcab399fc0"
< Accept-Ranges: bytes
< Content-Length: 44
< Content-Type: text/html
< Content-Language: en
<
* Connection #0 to host (nil) left intact
<html><body><h1>It works!</h1></body></html>
* Closing connection #0
```

先ほどと異なるのが、1回目の接続が終わって、2回目の接続が始まるタイミングで" Re-using existing connection! (#0) with host (nil)"という表示になっている。また、"Closing connection #0"が1回だけしか表示されない。

上記のループを何回繰り返している間にnetstatで接続状況を確認してみると、元々の実装と異なり、localhostの80番ポートとのTCPセッションが数個どまりになるはず（手元のcentosで確認したところ、1000回に1回は新たにコネクションを作り直していた）。また、これによりTCPの接続コストも押さえられるので、パフォーマンスの向上にもつながった。

# CURLOPTのリフレッシュを意識した実装

ただし、上記の実装には問題点がある。curl_setopt_arrayでセッションハンドラに設定したオプション情報が接続毎に残ってしまう。

例えば、PUTやPOSTを行う場合、

$curlOptions[CURLOPT_POSTFIELDS] = $contentBody;

という形で、CURLOPT_POSTFIELDSに送信するリクエストボディをセットしている。

で、この後に、上記のコードでGETを行うと、PUT(POST)時にセットしたリクエストボディの情報がセッションハンドルに残ってしまう。PHP5.5からはセッションハンドルをリセットするcurl_resetという関数が導入されるっぽいのですが（http://www.php.net/manual/ja/function.curl-reset.php）、現状のcurlだとこれをリセットするすべが無い。ということで、以下のような実装に変更する必要がある。

```php:curl_connection_reuse_2.php
<?php

$cw = new CurlWrapper();

for ($i = 0; $i < $argv[1]; $i++) {
    try {
        $resp = $cw->get("http://localhost/");
        echo $resp->getResponseBody() . PHP_EOL;
    } catch (Exception $e) {
        echo $e->getMessage() . PHP_EOL;
    }
}


class CurlWrapper {

    private $_ch;

    private $_curlOptionTemplate = array(
        CURLOPT_URL => "",
        CURLOPT_CUSTOMREQUEST => "GET",
        CURLOPT_POSTFIELDS => "",
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_VERBOSE => true    //curlのログを標準エラーに出力する
    );

    public function get($url) {
        $curlOptions = $this->_curlOptionTemplate;
        $curlOptions[CURLOPT_URL] = $url;
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "GET";
        return $this->_exec($curlOptions);
    }

    public function put($url, $contentBody) {
        $curlOptions = $this->_curlOptionTemplate;
        $curlOptions[CURLOPT_URL] = $url;
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "PUT";
        $curlOptions[CURLOPT_POSTFIELDS] = $contentBody;
        return $this->_exec($curlOptions);
    }

    public function post($url, $contentBody) {
        $curlOptions = $this->_curlOptionTemplate;
        $curlOptions[CURLOPT_URL] = $url;
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "POST";
        $curlOptions[CURLOPT_POSTFIELDS] = $contentBody;
        return $this->_exec($curlOptions);
    }

    public function delete($url) {
        $curlOptions = $this->_curlOptionTemplate;
        $curlOptions[CURLOPT_URL] = $url;
        $curlOptions[CURLOPT_CUSTOMREQUEST] = "DELETE";
        return $this->_exec($curlOptions);
    }

    private function _exec($curlOptions) {

        if (!isset($this->_ch)) {
            $this->_ch = curl_init();
        }

        curl_setopt_array($this->_ch, $curlOptions);

        $responseBody = curl_exec($this->_ch);
        $info = curl_getinfo($this->_ch);
        $error = curl_error($this->_ch);

        if ($error) {
            throw new Exception($error);
        }

        return new HttpResponse($info[CURLINFO_HTTP_CODE], $responseBody);
    }
}

class HttpResponse {

    private $_statusCode;
    private $_responseBody;

    public function __construct($statusCode, $responseBody) {
        $this->_statusCode = $statusCode;
        $this->_responseBody = $responseBody;
    }

    public function getStatusCode() {
        return $this->_statusCode;
    }

    public function getResponseBody() {
        return $this->_responseBody;
    }
}
```

php5.5からは[curl_reset](http://jp1.php.net/manual/ja/function.curl-reset.php)というcurlのセッションハンドルにセットしたオプションをリセットしてくれる機能が入ってくるようなのだけど、とりあえず5.3の環境では上記のような無粋な方法になってしまった。。。