Page 1
PSRで学ぶHTTP
Webアプリケーションの実践
2020-12-12 PHPカンファレンス2020 #phpcon
pixiv Inc.USAMI Kenta
公開日:
by USAMI Kenta@tadsan
にオンラインのYouTube Liveで開催された『PHPカンファレンス2020』でLong session(60分)として発表しました。
pixiv Inc.USAMI Kenta
@tadsan
発・メンテナンスなどを担当
解析・言語機能などを紹介してきました
@tadsan書いてます
読めば全部書いてある
の理解がないと、何が嬉しいのか納得しがたい
PHPerKaigiのために手を動かしてみるまで、いまいちぴんときていなかった
結局何すればいいの」という理解のギャップを埋めることを目標としています
す
1. HTTPの概説とPHPの制約
2. HTTPとフレームワークとPSR
3. PSR-7/15/17ベースの実装と勘所
1. HTTPの概説とPHPの制約
ネットのアドレスのやつ?
https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol より引用最終更新 2020年11月3日 (火) 13:19
ネットじゃん
別のページに飛べたりする文書
https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol より引用最終更新 2020年11月3日 (火) 13:19
https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol より引用最終更新 2020年11月3日 (火) 13:19
Enterを押すとHTTP通信が走る
HTTP/3は違いますが…
特定のルールで区切って送信する
返答(response)を返す
のほかなんでも
リクエスト行
ヘッダフィールド
POST /post HTTP/1.1 Host: httpbin.org Accept: application/json Content-Type: application/json Connection: close
リクエストボディ
{"foo":"bar"}
リクエスト行・ヘッダの区切りはCR+LF(\r\n)
POST /post HTTP/1.1 Host: httpbin.org Accept: application/json Content-Type: application/json Connection: close
{"foo":"bar"}
ソケットが使える
答してくれるWebサービス
リクエスト行
<?php
ヘッダフィールド
リクエストボディ
リクエスト行・ヘッダの区切りはCR+LF
$request = implode("\r\n", ['POST /post HTTP/1.1','Host: httpbin.org','Accept: application/json','Content-Type: application/json','Connection: close','',json_encode(['foo' => 'bar']),]);
(\r\n)
名前解決
ソケット接続
$ip = dns_get_record('httpbin.org', DNS_A)[0]['ip'];$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);socket_connect($sock, $ip, 80) or die(socket_strerror(socket_last_error()));
ソケット書込(リクエスト)
socket_write($sock, $request);
ソケット読込(レスポンス)
$response = '';do {$responses .= ($buf = socket_read($sock, 2048));} while ($buf !== '');
ご安心ください
ところで、この方法で簡単に通信できるのはHTTP/1.1以下だけ。
HTTPSやHTTP/2では困難。
HTTPSだろうとブラウザからは通信は簡単に見れる
Webブラウザの開発者ツール
リクエストを送る人が居ればリクエストを受け取る人も居る
実際にはGoかもしれないしRubyかもしれない…
ブラウザでPHPの実行結果が見えるということはPHPがHTTPリクエストを受け取ってレスポンスを返しているにほかならない
スーパーグローバル変数
$_GET
POST /post?hoge=1&fuga=2 HTTP/1.1 Host: httpbin.org Accept: application/json Content-Type: application/x-www-form-urlencoded Connection: close
foo=3&bar=4
$_SERVER
$_POST
https://www.php.net/manual/ja/reserved.variables.server.php より引用2020年12月12日 閲覧
Webアプリ = CGIだった
入出力と環境変数で受け渡し
$_SERVER['HTTP_CONTENT_TYPE']
ServerAPIという層で吸収している
なく小さい
バージョン/ステータス
ヘッダフィールド
HTTP/1.1 200 OK Date: Fri, 11 Dec 2020 16:20:18 GMT Content-Type: application/json Content-Length: 339 Connection: close Server: gunicorn/19.9.0 Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true
レスポンスボディ
{"json":"response..."}
バージョン/ステータス
ヘッダフィールド
HTTP/1.1 200 OK Date: Fri, 11 Dec 2020 16:20:18 GMT Content-Type: text/html Content-Length: 339 Connection: close Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true
レスポンスボディ
<!DOCTYPE html><html><head>
PHPではechoなどで出力したものはそのままレスポンスボディになる(レスポンスヘッダは、ボディをechoする前にheader()を呼んでおく)
<?php header('Content-Type: application/json');header('Access-Control-Allow-Origin: *');header('Access-Control-Allow-Credentials: true');
echo json_encode($data);
<?php header('Content-Type: text/html');header('Access-Control-Allow-Origin: *');header('Access-Control-Allow-Credentials: true');?><!DOCTYPE html><html><head>...
<?php header('Content-Type: image/png');header('Access-Control-Allow-Origin: *');header('Access-Control-Allow-Credentials: true');
// 画像ファイルをそのまま返すこともできるreadfile(__DIR__ . '/../storage/flower.png');
くまでもライブラリだが、PHPはServer APIとしてHTTP処理が言語処理系と統合されている
$_SERVER, $_COOKIE, $FILES
ならPHPの基本機能をそのまま使うのが一番自然でいいのでは
グローバルはテストとひたすら相性が悪い
$_SERVER, $_COOKIE, $FILES
に引数を与えて、その返り値をアサーションする
テストしにくい
る
setUp()で確実にリセットしておけばいい
Warningで止まる
information
信されること」のようなアサーションは書けない
2. HTTPとフレームワークとPSR
ここまでの問題点を整理しよう
状態と密結合している
存在すると引数と返り値によるアサーションに落とし込めない
それならHTTPを引数と返り値で扱える場所に持ってくればいい
HTTPの構造は決まってるわけじゃないですか
リクエスト行
ヘッダフィールド
POST /post HTTP/1.1 Host: httpbin.org Accept: application/json Content-Type: application/json Connection: close
リクエストボディ
{"foo":"bar"}
バージョン/ステータス
ヘッダフィールド
HTTP/1.1 200 OK Date: Fri, 11 Dec 2020 16:20:18 GMT Content-Type: application/json Content-Length: 339 Connection: close Server: gunicorn/19.9.0 Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true
レスポンスボディ
{"json":"response..."}
いかにもクラスにできそう
システムの奥深くでグローバル変数を参照されたら困るならリクエストオブジェクトを渡してそこから状態を取得させればいい
header()関数をコールされたら困るなら、送信したいヘッダをレスポンスオブジェクトに記録して受け渡していけばいい
Rob Allen HTTP, PSR-7 and Middlewareより引用
https://stackphp.com/ より引用
そこで出てくるのがPHP-FIGとPSR
(フレームワーク相互運用グループ)
(PHP標準勧告)
経緯はsasezakiさんの「憂鬱な希望としての PSR-7」を参照
PSR-15: HTTP Server Request Handlers
PSR-17: HTTP Factories
これで晴れて具象への依存を避けつつPSRベースのWebアプリを書けるようになったという話は、田中ひさてるさんのPHP-FIGのHTTP処理標準の設計はなぜPSR-7/15/17になったのかを参照ください
3. PSR-7/15/17ベースの実装と勘所
お待たせしましたここからWebアプリを作りはじめましょう
($_GETなど)相当+Attributeを付与して引き回せる
(echo)
を呼ぶ
% composer require nyholm/psr7 nyholm/psr7-server% composer require narrowspark/http-emitter
# サーバー起動% php -S localhost:3939 -t public/ ./public/index.php
<?php declare(strict_types=1);use Narrowspark\HttpEmitter\SapiEmitter;use Nyholm\Psr7\Factory\Psr17Factory;use Nyholm\Psr7Server\ServerRequestCreator;require __DIR__ . '/../vendor/autoload.php';
$psr17Factory = new Psr17Factory();$creator = new ServerRequestCreator($psr17Factory, // ServerRequestFactory$psr17Factory, // UriFactory$psr17Factory, // UploadedFileFactory$psr17Factory // StreamFactory);
$serverRequest = $creator->fromGlobals();
$response = $psr17Factory->createResponse();->withHeader('Content-Type', 'text/plain')->withBody($psr17Factory->createStream('Hello!'));
$emitter = new SapiEmitter();$emitter->emit($response);
のSingle Action Controllerっぽくなる
RequestHandlerにしているが、別の方法で安全に起動できる手段があれば他の方法でもよい
final class Hello implements RequestHandlerInterface{private StreamFactory $stream_factory;private ResponseFactory $response_factory;
public function __construct(ResponseFactory $response_factory,StreamFactory $stream_factory) {$this->response_factory = $response_factory;$this->stream_factory = $stream_factory;}
public function handle(Request $request): Response{return $this->response_factory->createResponse()->withHeader('Content-Type', 'text/plain')->withBody($this->stream_factory->createStream('Hello!'));}}
$serverRequest = $creator->fromGlobals();
$hello = new Hello($psr17Factory, $psr17Factory);$response = $hello->handle($serverRequest);
$emitter = new SapiEmitter();$emitter->emit($response);
ルーターも作っていきましょう
$router = new StaticRouter(['/' => ['GET'=>fn() => new Index($psr17, $psr17)],'/hello' => ['GET'=>fn()=> new Hello($psr17, $psr17)],], fn() => new ErrorPage($psr17Factory, $psr17Factory));
$response = $router->handle($serverRequest);
$emitter = new SapiEmitter();$emitter->emit($response);
final class StaticRouter implements RequestHandlerInterface{private array $routes;private Closure $error_page;
public function __construct(array $routes, Closure$error_page){$this->routes = $routes;$this->error_page = $error_page;}
public function handle(Request $request): Response{$path = $request->getUri()->getPath();$method = $request->getMethod();
return ($this->routes[$path][$method]?? $this->error_page)()->handle($request);}}
wares のプロジェクトがさまざまなミドルウェアを既に実装している
PHP-DIにオブジェクトの生成とディスパッチを任せている
getQueryParams()から値を取り出すのは$_GETと大差ない
@varなどで明示的に型付けが必要
