PHP
Guzzle
PSR-7

PHPのプロキシサーバーを説明する

昨日書いたPHPでプロキシサーバーを作るについて、ちょっとだけ説明します。

PHPタグとnamespace

<?php

namespace zonuexe\ZoProxy;

みなさまご存じ、PHPファイルでは <?php … ?> タグの中にスクリプトを書くことができます。

名前空間のnamespaceキーワードはファイルの冒頭に書く必要がある(declareを除く)ので、ここまでは基本的にワンセットです。名前空間の名前の付けかたは… PSR-4を参考にしてください。

use(クラスのインポート/エイリアス)

use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Psr7;

useについてはPHP: 名前空間の使用法: エイリアス/インポート - Manualを読んでください。

この宣言により、以後このクラス内では\GuzzleHttp\ClientHttpClientとして、\GuzzleHttp\Psr7Psr7として使用することができます。このuseキーワードはエイリアスまたはインポートと呼ばれますが、その用語の印象に反してこの時点ではまだクラスはロードされません。飽くまで、名前をつけるだけです。

Guzzle (HTTPクライアント)

今回のコードではクライアントとしてGuzzleを利用しました。PHPのHTTPクライアント実装としてはGuzzle以外にもいろいろありますが、PSR-7: HTTP message interfaces準拠のものを選びました。あとは私がGuzzle6が割と好みだからですね。

PSR-7準拠のHTTPクラスとHTTPクライアントライブラリを利用すれば、今回のコードはほとんど変更なしで動く気がします。HTTPクラスとしてはguzzle/psr7: PSR-7 HTTP message libraryを利用します。インターフェイスとしてPSR-7ならば別のライブラリを使っても全然大丈夫ですが、Guzzleの依存関係として勝手にインストールされるので別のライブラリを入れる積極的理由もなさげ。

シェルで以下のようなコマンドを叩くとインストールできます。Composerを使ったことがないひとはComposerでPHPの依存関係を管理するとかを読んでください。

composer require guzzlehttp/guzzle:~6.3

Composerのクラスローダーをrequire

require __DIR__ . '/../vendor/autoload.php';

Composerの初期化ファイルを読み込みます。Composerでインストールしたディレクトリを指定してください。includeでもrequire_onceでもinclude_onceでも、この場合は好みでいいです。

Composerでインストールさたれファイルはこれだけでロードする準備が整った状態になりますので、個別のファイルを読み込む必要はありませんinclude_pathを明示的に通す必要もありません。むしろ、絶対読み込まないでください。

対応表を作る

// ここはいい感じにやってね
$host_table = [
    'hoge.example.com' => [
        'host' => 'localhost',
        'port' => 3939,
        'scheme' => 'http',
    ],
    'foo.example.com' => [
        'host' => 'foo.example.jp',
        'scheme' => 'https',
    ],
];

「この名前でリクエストされたら、この名前に変換するよ」の対応表です。前回を記事を書いたときに気付いたのですが、これではあるユースケースに対応できないので一考の余地がありますね。ただ、私の要件としてはこれで十分機能するので、このままでいきます。 (拡張性への課題については読者への宿題とします)

Requestオブジェクトを作る

$request = Psr7\ServerRequest::fromGlobals();

$_GET, $_POST, $_COOKIE, $_FILESなどのスーパーグローバル変数をもとにPSR-7のRequestオブジェクトを組み立てます。つまり、このサーバーへのリクエストからPSR-7に変換することができます。

\Guzzle\Psr7\ServerRequest::fromGlobals()はPSR-7の仕様ではなくguzzle/psr7の機能です。(ただし、ほかの多くのHTTPクラス実装も同様の機能を持ってます)

URIオブジェクトを作る

$new_uri = $request->getUri()->withPort(80);

URIオブジェクトはRequest::getUri()で取り出すことができます。URIではhttp://localhost:3939/のように、:3939の書式でポート番号を指定できます。一般にHTTPのデフォルトポートは80なので、guzzle/psr7はデフォルトポートをURLから取り除きます。

ここまでこの記事を書いていまさら気付いたのですが、これは良くない前提を置いてますね…? 気になるひとはpsr7/Uri.phpを読んでみてください。 (誰も気付いてくれないと悲しい)

対応表から取り出すキーを作る

$key = $new_uri->getHost();
$port = $new_uri->getPort();
if ($port !== null && !Psr7\Uri::isDefaultPort($port)) {
    $key .= ":{$port}";
}

さきほど作った対応表から取り出すためのキーです。つまり、"hoge.example.com"とか"localhost:3939"のような文字列に変換されるのです。

対応表に沿って、くいくいっとURIを改竄する

if (isset($host_table[$key])) {
    if (isset($host_table[$key]['host'])) {
        $new_uri = $new_uri->withHost($host_table[$key]['host']);
    }
    if (isset($host_table[$key]['port'])) {
        $new_uri = $new_uri->withPort($host_table[$key]['port']);
    }
    if (isset($host_table[$key]['scheme'])) {
        $new_uri = $new_uri->withScheme($host_table[$key]['scheme']);
    }
}

httphttpsに変換したり、:3939ってくっつけたり、foo.example.comfoo.example.jpに変換したりできます。

HTTPリクエストする

$client = new HttpClient;
$response = $client->send($request->withUri($new_uri), [
    'http_errors' => false,
]);

さて、Quickstart — Guzzle Documentationにある通りGuzzleのおてがるな利用方法としては$client->get('http://httpbin.org/get')のようなリクエストができるのですが、$client->send($request)のようにリクエストすることもできます。

自分がクライアントから受け取ったのもリクエストなら、こちらがほかのHTTPサーバーに送りつけるのもリクエストです。いいですね。これがPSR-7のようなHTTPオブジェクトを利用する醍醐味です。

さて、Guzzleはべんりなので、HTTPのステータスコード404500などの(20x30x以外の)ステータスコードが返ってきたときに例外を投げてくれます。しかし今回は完全に同じレスポンスをクライアントに渡したいので、PHPレベルでの例外にする必要はありませんね。なので'http_errors' => falseにします。そのほかのオプションはRequest Options — Guzzle Documentationを見てくださいね。

レスポンスを返す

foreach ($response->getHeaders() as $key => $values) {
    foreach ($values as $value) {
        header("{$key}:{$value}");
    }
}

echo $response->getBody();

CGIとかなら、普通はヘッダインジェクションを警戒したいところですが… header()はそのあたりのチェックはちゃんとしてくれるはずなので。たぶん。

まとめ

いかがでしたか? 今回はPHPでPSR-7を使ってproxy鯖を実装する方法を調べてみました。みなさまのお役に立てば幸いです。 PSR-7をほとんど定義通りそのまま使っただけなので、今回新たに調べたことは特にない。うさんくさいblogとかキュレーションメディアの常套句を一回使ってみるのが夢だった。