【PHP】HTTPのGET/POSTメソッドの事が少しだけ分かった気になれる（かもしれない）コード

# コード実行結果のサンプル

<details><summary>HTTP リクエスト ⇒ レスポンスの様子がこんな風に分かります。</summary><div>

```
[0] => Array
(
    [url] => http://qiita.com/fallout/

    [request] => GET /fallout/ HTTP/1.1
Host: qiita.com
Connection: close
Accept-Encoding: gzip
User-Agent: Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35

    [response] => Array
        (
            [0] => HTTP/1.1 301 Moved Permanently
            [date] => Fri, 21 Jun 2019 06:06:53 GMT
            [content-type] => text/html
            [content-length] => 178
            [connection] => close
            [server] => nginx
            [location] => http://qiita.com/fallout
        )

    [stream] => Array
        (
            [timed_out] => 
            [blocked] => 1
            [eof] => 
            [stream_type] => tcp_socket/ssl
            [mode] => r+
            [unread_bytes] => 178
            [seekable] => 
        )

    [cookie] => Array
        (
        )
)


[1] => Array
(
    [url] => http://qiita.com/fallout

    [request] => GET /fallout HTTP/1.1
Host: qiita.com
Connection: close
Accept-Encoding: gzip
User-Agent: Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35

    [response] => Array
        (
            [0] => HTTP/1.1 301 Moved Permanently
            [date] => Fri, 21 Jun 2019 06:06:53 GMT
            [content-type] => text/html
            [transfer-encoding] => chunked
            [connection] => close
            [server] => nginx
            [location] => https://qiita.com/fallout
            [x-request-id] => *****
        )

    [stream] => Array
        (
            [timed_out] => 
            [blocked] => 1
            [eof] => 
            [stream_type] => tcp_socket/ssl
            [mode] => r+
            [unread_bytes] => 5
            [seekable] => 
        )

    [cookie] => Array
        (
        )
)


[2] => Array
(
    [url] => https://qiita.com/fallout

    [request] => GET /fallout HTTP/1.1
Host: qiita.com
Connection: close
Accept-Encoding: gzip
User-Agent: Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35

    [response] => Array
        (
            [0] => HTTP/1.1 200 OK
            [date] => Fri, 21 Jun 2019 06:06:54 GMT
            [content-type] => text/html; charset=utf-8
            [transfer-encoding] => chunked
            [connection] => close
            [server] => nginx
            [x-frame-options] => SAMEORIGIN
            [x-xss-protection] => 1; mode=block
            [x-content-type-options] => nosniff
            [x-download-options] => noopen
            [x-permitted-cross-domain-policies] => none
            [referrer-policy] => strict-origin-when-cross-origin
            [etag] => *****
            [cache-control] => max-age=0, private, must-revalidate
            [set-cookie] => _qiita_login_session=*****; domain=.qiita.com; path=/; expires=Sun, 21 Jun 2020 06:06:54 -0000; HttpOnly
            [x-runtime] => 0.172956
            [strict-transport-security] => max-age=2592000
            [x-request-id] => *****
            [content-encoding] => gzip
        )

    [stream] => Array
        (
            [crypto] => Array
                (
                    [protocol] => TLSv1.2
                    [cipher_name] => ECDHE-RSA-AES128-GCM-SHA256
                    [cipher_bits] => 128
                    [cipher_version] => TLSv1.2
                )

            [timed_out] => 
            [blocked] => 1
            [eof] => 
            [stream_type] => tcp_socket/ssl
            [mode] => r+
            [unread_bytes] => 7134
            [seekable] => 
        )

    [cookie] => Array
        (
            [_qiita_login_session] => *****
        )
)
```

</div></details>

--------------------------------------------------------------------------------

## コード

コメントを読みながら、HTTP のリクエスト ⇒ レスポンスという流れを、何となく感じ取ってもらえたらと思います。

- 学習用のコードです。コピペで使う事は意図していません。
- PHP5.2.X あたりのかなり古い環境でも動くように書いたつもりですが、実際の動作は PHP7.3.6 で簡単に確認しただけです。
- 引数のチェックやエラー処理は省略してます。
- GET と POST メソッドに対応しています。

```php
function fetchUrl($url, $postAry = array(), $cookieAry = array())
{
    //-- タイムアウト(秒) ※【補足1】
    $timeout = 10;

    //-- リダイレクト回数制限(回)
    $maxRedirects = 5;


    //-- リダイレクト回数
    static $cntRedirect = 0;

    //-- レスポンスヘッダなどの履歴
    static $historyAry = array();


    //-- URLの構成要素を配列にする
    $urlAry = parse_url($url);

    if (!$urlAry || !isset($urlAry['scheme'])) {
        return false;
    }

    //-- URLの履歴
    $historyAry[$cntRedirect]['url'] = $url;


    //-- 接続するソケットのアドレス
    $remote = '';

    //-- https ⇒ tls接続(デフォルトのportは443番)
    //-- http  ⇒ tcp接続(デフォルトのportは80番)
    if ($urlAry['scheme'] === 'https') {
        $remote = "tls://{$urlAry['host']}:";
        $remote .= (isset($urlAry['port'])) ? $urlAry['port'] : '443';
    } elseif ($urlAry['scheme'] === 'http') {
        $remote = "tcp://{$urlAry['host']}:";
        $remote .= (isset($urlAry['port'])) ? $urlAry['port'] : '80';
    } else {
        return false;
    }


    //-- リクエストヘッダ
    $reqAry = array();

    //-- Hostを指定する
    $reqAry['Host'] = $urlAry['host'];

    //-- keep-aliveは未対応
    $reqAry['Connection'] = 'close';

    //-- gzip圧縮転送へ対応
    if (function_exists('gzinflate')) {
        $reqAry['Accept-Encoding'] = 'gzip';
    }

    //-- UserAgentを設定する
    $reqAry['User-Agent'] = 'Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35';

    //-- リファラなども同様に処理できます
    // $reqAry['Referer'] = 'http://...';

    //-- Basic認証 ※【補足2】
    $authUser = (isset($urlAry['user'])) ? $urlAry['user'] : '';
    $authPass = (isset($urlAry['pass'])) ? $urlAry['pass'] : '';

    if ($authUser !== '' || $authPass !== '') {
        $reqAry['Authorization'] = 'Basic ' . base64_encode("{$authUser}:{$authPass}");
    }

    //-- クッキー
    if ($cookieAry) {
        $reqAry['Cookie'] = http_build_query($cookieAry, '', '; ');
    }


    //-- リクエストメソッド
    $method = 'GET';
    $content = null;

    //-- POSTメソッド
    if ($postAry) {
        $method = 'POST';
        $content = http_build_query($postAry, '', '&');
        $reqAry['Content-Type'] = 'application/x-www-form-urlencoded';
        $reqAry['Content-Length'] = strlen($content);
    }


    //-- リクエスト対象パス
    $path = (isset($urlAry['path'])) ? $urlAry['path'] : '/';

    //-- クエリストリングを追加
    if (isset($urlAry['query'])) {
        $path .= "?{$urlAry['query']}";
    }


    //-- HTTP/1.1でリクエスト
    $request = "{$method} {$path} HTTP/1.1\r\n";

    //-- リクエストラインの生成
    foreach ($reqAry as $name => $value) {
        $request .= "{$name}: {$value}\r\n";
    }

    //-- リクエストラインの最後は改行のみ
    $request .= "\r\n";

    //-- POSTメソッド用リクエストボディを追加
    if ($method === 'POST') { $request .= $content; }

    //-- リクエストの履歴
    $historyAry[$cntRedirect]['request'] = $request;


    //-- 接続開始
    if (!$sock = stream_socket_client($remote, $errNo, $errStr, $timeout)) {
        return false;
    }

    //-- 簡易的なタイムアウト処理
    if (!stream_set_timeout($sock, $timeout)) {
        return false;
    }

    //-- リクエストを送信
    if (fwrite($sock, $request) === false) {
        return false;
    }


    //-- レスポンスヘッダを取得する
    $resAry = array();

    //-- 改行のみで終わる行までがレスポンスヘッダ
    while ($line = trim(fgets($sock))) {
        //-- ヘッダフィールドを配列にする
        if (strpos($line, ':') !== false) {
            list($name, $value) = explode(':', $line, 2);

            //-- 小文字で統一
            $name = strtolower(rtrim($name));
            $value = ltrim($value);

            if (isset($resAry[$name])) {
                //-- cookieなど複数回登場するフィールドの処理
                if (!is_array($resAry[$name])) {
                    $resAry[$name] = (array) $resAry[$name];
                }

                $resAry[$name][] = $value;
            } else {
                $resAry[$name] = $value;
            }
        } else {
            $resAry[] = $line;
        }
    }

    //-- レスポンスヘッダの履歴
    $historyAry[$cntRedirect]['response'] = $resAry;


    //-- タイムアウト
    $resMetaAry = stream_get_meta_data($sock);

    if (!empty($resMetaAry['timed_out'])) {
        return false;
    }

    //-- ストリーム情報の履歴
    $historyAry[$cntRedirect]['stream'] = $resMetaAry;


    //-- クッキーの処理 ※【補足3】
    $resCookieAry = array();

    if (!empty($resAry['set-cookie'])) {
        foreach ((array) $resAry['set-cookie'] as $value) {
            if (preg_match('/\A([^\=]+)\=([^;]+)/', $value, $matchAry)) {
                $resCookieAry[urldecode($matchAry[1])] = urldecode($matchAry[2]);
            }
        }

        $cookieAry = array_merge($cookieAry, $resCookieAry);
    }

    //-- クッキーの履歴
    $historyAry[$cntRedirect]['cookie'] = $cookieAry;


    //-- リダイレクト ※【補足4】
    if (!empty($resAry['location']) && ++$cntRedirect <= $maxRedirects) {
        //-- 接続を閉じる
        fclose($sock);

        //-- リダイレクト先
        $location = (is_array($resAry['location'])) ? end($resAry['location'])
                                                    : $resAry['location'];

        //-- 再帰処理(リダイレクト先はGETメソッド)
        return fetchUrl($location, array(), $cookieAry);
    }


    //-- チャンク形式のデコード【補足5】
    $isChunked = isset($resAry['transfer-encoding']) && $resAry['transfer-encoding'] === 'chunked';
    $isDechunk = false;

    if ($isChunked) {
        //-- dechunkフィルタの使用
        if (in_array('dechunk', stream_get_filters())) {
            stream_filter_append($sock, 'dechunk', STREAM_FILTER_READ);
            $isDechunk = true;
        }
    }

    //-- レスポンスボディ
    $resBody = stream_get_contents($sock);

    //-- 接続を閉じる
    fclose($sock);


    //-- dechunkフィルタが使えない環境では自力でデコード
    if ($isChunked && !$isDechunk) {
        $decoded = '';
        $tmp = $resBody;

        //-- 「2」は"\r\n"の長さの事です
        do {
            $tmp = ltrim($tmp);
            $pos = strpos($tmp, "\r\n");
            $len = hexdec(substr($tmp, 0, $pos));
            $decoded .= substr($tmp, $pos + 2, $len);
            $tmp = substr($tmp, $pos + 2 + $len);
            $check = trim($tmp);
        } while (!empty($check));

        $resBody = $decoded;
    }


    //-- gzip圧縮データのデコード
    if (isset($resAry['content-encoding']) && $resAry['content-encoding'] === 'gzip') {
        $resBody = (function_exists('gzdecode')) ? gzdecode($resBody)
                                                 : gzinflate(substr($resBody, 10, -8));
    }


    //-- 取得結果を返す
    return array(
        $historyAry,    // レスポンスヘッダなどの履歴
        $resBody,       // 最終レスポンスボディ
    );
}
```

--------------------------------------------------------------------------------

### 使い方

```php
//-- ユーザー名の後に「/」を入れると2回リダイレクトする様子が観察できます
list($historyAry, $body) = fetchUrl('http://qiita.com/fallout/');
print_r($historyAry);


//-- POSTメソッドにも対応しています
$postAry = [
    'user' => '名前',
    'pass' => 'ぱすわぁど',
];

fetchUrl('http://example.com/', $postAry);


//-- 一応クッキーは処理しますが致命的な問題があります ※【補足3】
$cookieAry = [
    'aaa' => 'あああ',
    'bbb' => 'いいい',
];

fetchUrl('http://qiita.com/fallout/', array(), $cookieAry);


//-- PHP5.4以降であればこういう使い方もできます
$body = fetchUrl('http://qiita.com/fallout/')[1];


//-- Basic認証やport番号指定にも一応対応しています
fetchUrl('http://user:pass@example.com:80/');
```

--------------------------------------------------------------------------------

### 【補足1】タイムアウト

10 秒に制限していますが、本来こういうハードコーディングは避けるべきです。

また、簡易的な処理しかしていません。

それでも `file_get_contents()` よりはマシだと思いますが、きちんと処理するのであれば、`microtime()` などを使ってチェックするようにします。

```php:例
$startTime = microtime(true);

while (fgets($sock) !== false) {
    if (microtime(true) - $startTime >= $timeout) {
        // タイムアウト処理
    }
```

--------------------------------------------------------------------------------

### 【補足2】Basic認証

ユーザー名とパスワードとを「:」で繋げて、Base64 形式でエンコードしただけだという事が分かると思います。

`.htaccess` で簡単に設定できるため、未だに利用者が多いですが、ユーザー名とパスワードは、暗号化もハッシュ化もされていないという事になります。

--------------------------------------------------------------------------------

### 【補足3】クッキーの処理

このコードには、致命的な問題があります。

例えば `1.qiita.com` ⇒ `2.example.com` にリダイレクトした際に、1 で発行されたクッキーが 2 に漏れてしまいますし、[クッキーに対して付与される様々な属性](https://www.google.co.jp/search?q=HTTP+cookie+%E4%BB%95%E6%A7%98+RFC&ie=utf-8&newwindow=1)も無視しています。

あくまでも流れや雰囲気をつかむためのコードですので、決して本番環境などではマネしないでください。

--------------------------------------------------------------------------------

### 【補足4】リダイレクト

5 回に制限していますが、本来こういうハードコーディングは避けるべきです。

また、`location` が「相対パス」で返ってきた際の処理は、省略していますので、下記を参考にしてみてください。

- [【PHP】相対パスを絶対パス(URL)に変換する](https://qiita.com/fallout/items/347c4b0c377e025198e6)

--------------------------------------------------------------------------------

### 【補足5】チャンク形式のデコード

チャンク形式の仕様は [RFC 7230](https://tools.ietf.org/html/rfc7230#section-4.1) にありますが、要するに…

```:改行コードは\r\n
[Aのサイズ(16進数)]
[データA]
[Bのサイズ(16進数)]
[データB]
0
```

のような形式で、データが返ってくるという事です。

PHP には、これを[ストリーム](https://www.php.net/manual/ja/intro.stream.php)処理できる `dechunk` というフィルタがありますが、古い環境では使えなかった記憶があるので、念のため自力でデコードする処理も書いています。

--------------------------------------------------------------------------------

## 処理速度

処理速度は、file_get_contents() ＜ 私のコード ＜ [cURL](https://www.php.net/manual/ja/ref.curl.php) になると思います。

※あくまでも理論上の話で、検証はしていません。

実用レベルでは、並列処理なども可能な cURL が断然オススメです。

※Guzzle などは、内部的には cURL を利用しています。

--------------------------------------------------------------------------------

## 【オマケ】キャッシュを作る例

一度取得した結果をキャッシュする際は、[`serialize()`](https://www.php.net/manual/ja/function.serialize.php) / [`unserialize()`](https://www.php.net/manual/ja/function.unserialize.php) を使うと簡単に処理が書けます。

スクレイピングでは、一度取得した結果をキャッシュして、データ取得先に負荷をかけない工夫が必要ですので、参考にしてみてください。

```php
//-- 関数に渡すパラメータ
$paramAry = [
    'url' => 'http://example.com/',
    'post' => ['aaa' => 'あああ'],
    'cookie' => ['test' => 'テスト'],
];

//-- パラメータをシリアライズした結果のハッシュ値をファイル名にする
$cacheFilename = md5(serialize($paramAry)) . '.cache';

//-- データを取得
$dataAry = fetchUrl($paramAry['url'], $paramAry['post'], $paramAry['cookie']);

//-- シリアライズしてキャッシュとして保存
file_put_contents("./{$cacheFilename}", serialize($dataAry), LOCK_EX);

//-- キャッシュを利用する際はアンシリアライズする
$dataAry = unserialize(file_get_contents("./{$cacheFilename}")));
```

--------------------------------------------------------------------------------

## 迷惑をかけている PHPer

[配慮の足りないスクレイピングプログラム作者へ物申す](https://qiita.com/fallout/items/d213158ef42de751dbd6)では明言を避けましたが、残念なスクレイピングで迷惑をかけている人の多くが、PHP を使っています。

ログに `GET ***** HTTP/1.0` と、HTTP/1.0 のリクエストがやたら元気よく踊っていれば[^1]、かなりの確率で残念 PHPer の仕業です。

加えて、IP アドレスがレンタルサーバーのものなら、確実にそうだと言い切って良いです。

[^1]: 令和の時代なら、HTTP/1.0 を拒否すれば済む話かもしれませんが、HTTP/1.0 のリクエストを眺めるのは、それはそれで楽しかったりします。

--------------------------------------------------------------------------------

## file_get_contents()

その理由はおそらく、[`file_get_contents()`](https://www.php.net/manual/ja/function.file-get-contents.php) のような、とても手軽に使える標準関数があるからだと思います。

例えば、`http://qiita.com/fallout` の内容を取得したいと思えば…

```php
$html = file_get_contents('http://qiita.com/fallout');
```

このたった 1 行で、`http://` ⇒ `https://` と 301 リダイレクトした結果の中身を、取得できてしまいます。お手軽ですね。

--------------------------------------------------------------------------------

### レスポンスヘッダも見てみる

[レスポンスヘッダ](https://www.google.co.jp/search?q=HTTP+%E3%83%AC%E3%82%B9%E3%83%9D%E3%83%B3%E3%82%B9%E3%83%98%E3%83%83%E3%83%80+Chrome+%E3%83%87%E3%83%99%E3%83%AD%E3%83%83%E3%83%91%E3%83%BC%E3%83%84%E3%83%BC%E3%83%AB&ie=utf-8&newwindow=1&tbs=qdr%3Ay1)も見たいと思えば…

```php
$html = file_get_contents('http://qiita.com/fallout');
print_r($http_response_header);

// Array
// (
//     [0] => HTTP/1.1 301 Moved Permanently
//     [5] => Location: https://qiita.com/fallout
// )
```

リダイレクト前の結果のみですが、この 2 行で見れてしまいます。[^2]

[^2]: [`$http_response_header` という変数](https://www.php.net/manual/ja/reserved.variables.php)が、何の前触れもなく現れるのはイケてませんが、PHP 4 時代の名残だとでも思えば…。

もし、ヘッダを取得するだけであれば、[`get_headers()`](https://php.net/manual/ja/function.get-headers.php) なら、同じ結果を 1 行で取得できます。お手軽ですね。

--------------------------------------------------------------------------------

### より複雑なことも

第 3 引数へ、[ストリームコンテキスト](https://www.php.net/manual/ja/function.stream-context-create.php)を渡せば、もっと複雑な事…

```php
$result = file_get_contents(
    'http://example.com/',
    false,
    stream_context_create(['～ 略 ～']),
    0, 30
);
```

…例えば、`example.com` へ、○○のデータを HTTP/1.1 で POST し、その結果の先頭から 30byte を取得する…といった事までできます。[^3]

[^3]: ちょっとやりすぎ感はあります。関数やメソッドを自作する際は、役割をできるだけ「シンプル」にするよう意識すると、見通しも使い勝手も良い「美しい」コードになると思います。

--------------------------------------------------------------------------------

## あえて車輪の再発明

そんな便利な標準関数や Guzzle などを、あえて使わないコードを見てもらえば、HTTP の事がほんの少し分かって、スクレイピングに対する理解も深まるかも？と思ったのが、この記事を書いた理由です。

色んな事を思い出しながら書いたのと、ロクに動作検証をしていないので、おかしなところがありましたらご指摘ください。

※本筋からそれる処理は省略してます。

車輪の再発明にあえてチャレンジすることも、それはそれで勉強になると思います。

- ファイルをアップロードする時はどう書くのか？
- 関数ではなくクラス化してみよう

など、色々とチャレンジしてみれば、より理解が深まるのではないでしょうか。

うわぁ…面倒くさい…「やっぱり file_get_contents() でいいや！」というオチは、ご勘弁を(笑) 他人に迷惑をかける PHPer が 1 人でも減ってくれることを、心から願っております。