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分)として発表しました。
PSRで学ぶHTTP Webアプリケーションの実践
2020-12-12 PHPカンファレンス2020 #phpcon
pixiv Inc.
USAMI Kenta
お前誰よ
うさみけんた
●
ピクシブ株式会社 事業本部
●
pixiv
現職には 年から所属
○
2012
は 年頃から
○
PHP 2013
現行メンテナー
●
Emacs PHP Mode
USAMI Kenta
対応 まだです
○
PHP8 Attribute
@tadsan
カンファレンス終わったら
○
PHP …
などのコミュニティ
●
Emacs JP, php-users-ja
2540
技術など
年以前は を書いてました
●
2012 Ruby
現職入社以後は社内 およびフレームワークの開
●
API
発・メンテナンスなどを担当
いままでのカンファレンスでは や 静的
●
Composer PHP
USAMI Kenta
解析・言語機能などを紹介してきました
趣味では や や などを
●
PHP PHP Emacs Lisp
@tadsan
書いてます
2540
今回のお題
2540
基本的には既存の素晴らしい資料を
●
読めば全部書いてある
しかしPHPとHTTPとオブジェクト指向
○
の理解がないと、何が嬉しいのか納
得しがたい
2540
特にPSR-15に関しては今年の頭に
●
PHPerKaigiのために手を動かしてみる まで、いまいちぴんときていなかった
2540
「抽象的な話はわかった 。で、
●
(わからん)
結局何すればいいの」という
理解のギャップを埋めることを目標とし
ています
発表後に資料の復習と実践を推奨しま
●
す
2540
アジェンダ
2540
1. HTTPの概説とPHPの制約
2. HTTPとフレームワークとPSR
3. PSR-7/15/17ベースの実装と勘所
2540
1. HTTPの概説と
PHPの制約
2540
ネットの
アドレスのやつ?
HTTPとは何か
2540
より引用
https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol
最終更新 年 月 日 火
2020 11 3 ( ) 13:19
2540
ネットじゃん
Hypertextって…?
●
リンクとかクリックすると
○
別のページに飛べたりする文書
要はHTMLとかのこと
○
2540
より引用
https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol
最終更新 年 月 日 火
2020 11 3 ( ) 13:19
2540
テキストだけじゃな
いじゃん
より引用
https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol
最終更新 年 月 日 火
2020 11 3 ( ) 13:19
2540
Enterを押すとHTTP
通信が走る
2540
HTTP/3は
HTTPはTCPベースの通信
違いますが…
●
TCPはバイトの並びを送れる
○
HTTP/1はTCPでテキストを
○
特定のルールで区切って送信する
2540
一般的なPHPでのHTTP通信
●
file_get_contents() 関数
○
cURL 関数
○
GuzzleやPSR-18で抽象化
■
2540
HTTPのやりとりの基本
●
送信された要求(request)には
○
返答(response)を返す
HTML, CSS, JavaScript, JSON, そ
■
のほかなんでも
2540
HTTPリクエスト
2540
リクエスト
HTTP
リクエスト行
POST /post HTTP/1.1
Host: httpbin.org
Accept: application/json
Content-Type: application/json
ヘッダフィールド
Connection: close
{"foo":"bar"}
リクエストボディ
リクエスト行・ヘッダ の区切りはCR+LF
(\r\n)
2540
http://httpbin.org/post
POST /post HTTP/1.1
Host: httpbin.org
Accept: application/json
Content-Type: application/json
Connection: close
{"foo":"bar"}
2540
ソケットが
使える
このルール通りにテキストを組み立てて
TCPでいい感じに送ればHTTP通信できる
2540
httpbin.org
●
HTTPリクエストに対してさまざまな返
○
答してくれるWebサービス
ブラウザ上からもテストできる
○
2540
リクエスト リクエスト組み立て
HTTP ( )
リクエスト行
<?php
$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']),
の区切りはCR+LF
]);
(\r\n)
2540
リクエスト ソケット読み書き
HTTP ( )
名前解決
$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 !== '');
2540
ご安心ください
ところで、この方法で簡単に
通信できるのはHTTP/1.1以下だけ。
HTTPSやHTTP/2では困難。
2540
HTTPSだろうと
ブラウザからは
通信は簡単に見れる
2540
Webブラウザの 開発者ツール
2540
さて
2540
リクエストを送る人が居れば リクエストを受け取る人も居る
2540
実際にはGoかも
しれないしRubyかも
しれない…
そうですPHPです
2540
ブラウザでPHPの実行結果 が見えるということはPHPが HTTPリクエストを受け取って
レスポンスを
返しているにほかならない
2540
スーパー
グローバル変数
$_SERVER
$_GET, $_POST
2540
リクエスト
$_GET
HTTP
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
2540
より引用
https://www.php.net/manual/ja/reserved.variables.server.php
年 月 日閲覧
2020 12 12
2540
CGI
●
(Common Gateway Interface)
かつては掲示板のような
○
Webアプリ = CGIだった
リクエストごとにプロセス起動し標準
○
入出力と環境変数で受け渡し
2540
CGIから$_SERVER
●
○
$_SERVER['REQUEST_URI']
ヘッダを全て大文字 - を_に変換
○ ○
Content-Type ⇒
$_SERVER['HTTP_CONTENT_TYPE']
2540
PHPとSAPI (ServerAPI)
●
○
PHPはサーバーの実行環境の差を
ServerAPIという層で吸収している
cgi-fcgi, apache, fpm-fcgi, cli-sever...
○
環境ごとにコードを書き換える必要性が限り
○
なく小さい
2540
HTTPレスポンス
2540
レスポンス
HTTP
バージョン/ステータス
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..."}
2540
レスポンス
HTTP
バージョン/ステータス
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>
2540
PHPではechoなどで出力したものは
そのままレスポンスボディになる
(レスポンスヘッダは、ボディを
echoする前にheader()を呼んでおく)
2540
では基本的にこれだけ
PHP
<?php
header('Content-Type: application/json'); header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');
echo json_encode($data);
2540
では基本的にこれだけ
PHP
<?php
header('Content-Type: text/html');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');
?>
<!DOCTYPE html>
<html>
<head>...
2540
では基本的にこれだけ
PHP
<?php
header('Content-Type: image/png');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');
画像ファイルをそのまま返すこともできる
//
readfile(__DIR__ . '/../storage/flower.png');
2540
PerlやRubyなどの言語のCGI機能はあ
●
くまでもライブラリだが、PHPはServer APIとしてHTTP処理が言語処理系と統
合されている
2540
スーパーグローバル変数
●
$_GET, $_POST, $_REQUEST
○
$_SERVER, $_COOKIE, $FILES
グローバル関数
●
header(), setcookie(), session
○
2540
ならPHPの基本機能をそのまま 使うのが一番自然でいいのでは
2540
そうともいかない事情がある
2540
グローバルは
テストとひたすら相
スーパーグローバル変数
●
性が悪い
$_GET, $_POST, $_REQUEST
○
$_SERVER, $_COOKIE, $FILES
グローバル関数
●
header(), setcookie(), session
○
2540
なぜテストと相性が悪いのか
●
一般的なユニットテストはテスト対象
○
に引数を与えて、その返り値をア
サーションする
実装が引数以外のものに依存すると
○
テストしにくい
2540
とはいえグローバル変数はどうにかな
●
る
PHPUnitならテストケース実行前の
○
setUp()で確実にリセットしておけばい
い
2540
header()/setcookieとCLIの相性
●
PHPUnitのテスト対象で呼ばれると
○
Warningで止まる
○ Warning: Cannot modify header
information
2540
header()/setcookieとCLIの相性
●
「特定の場合にヘッダ/Cookieが送
○
信されること」のようなアサーションは
書けない
ラッパー関数などの工夫で可能
■
弊社ではStaticMockで対応
■
2540
2. HTTPとフレームワー
クとPSR
2540
ここまでの問題点を
整理しよう
2540
PHPのHTTP機能はグローバルな
●
状態と密結合している
操作できないグローバルな状態が
●
存在すると引数と返り値による
アサーションに落とし込めない
2540
それならHTTPを
引数と返り値で扱える
場所に持ってくればいい
2540
HTTPの構造は決まってる
わけじゃないですか
2540
リクエスト
HTTP
リクエスト行
POST /post HTTP/1.1
Host: httpbin.org
Accept: application/json
Content-Type: application/json
ヘッダフィールド
Connection: close
{"foo":"bar"}
リクエストボディ
2540
レスポンス
HTTP
バージョン/ステータス
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..."}
2540
いかにもクラスに
できそう
2540
システムの奥深くでグローバル
変数を参照されたら困るなら
リクエストオブジェクトを渡して
そこから状態を取得させればいい
2540
header()関数をコールされたら困る
なら、送信したいヘッダを
レスポンスオブジェクトに記録して受
け渡していけばいい
2540
そりゃみんな
やりますよね…
より引用
Rob Allen HTTP, PSR-7 and Middleware
2540
いろいろあった
2540
2013年にはこういう
のもあった
より引用
https://stackphp.com/
2540
より引用
https://stackphp.com/
2540
より引用
https://stackphp.com/
2540
そこで出てくるのが
PHP-FIGとPSR
2540
PHP-FIG
●
PHP Framework Interop Group
○
(フレームワーク相互運用グループ)
PSR
●
PHP Standard Recommendation
○
(PHP標準勧告)
2540
経緯はsasezakiさんの
「憂鬱な希望としての PSR-7」を参照
2540
時は流れて2018年
PSR-15:
HTTP Server Request Handlers
PSR-17: HTTP Factories
が受理
2540
これで晴れて具象への依存を避けつつPSR ベースのWebアプリを書けるようになったとい
う話は、田中ひさてるさんの
PHP-FIGのHTTP処理標準の設計はなぜ
PSR-7/15/17になったのか
を参照ください
2540
3. PSR-7/15/17ベー
スの実装と勘所
2540
お待たせしました
ここからWebアプリを
作りはじめましょう
2540
実装を選ぶ
●
PSRは飽くまでインターフェイス
○
オブジェクトは別に必要
○
2540
PSR-7/PSR-17
●
を使う
○
Guzzleもいいがnyholm/psr7
●
SAPIとの入出力
○
nyholm/psr7-server
narrowspark/http-emitter
○
2540
ServerRequestInterface
●
Requestにスーパーグローバル
○
($_GETなど)相当+Attributeを
付与して引き回せる
2540
Emitter
●
Responseオブジェクトを出力
○
(echo)
HTTPヘッダに合わせてheader()関数
○
を呼ぶ
2540
プロジェクト初期化
https://github.com/zonuexe/phpcon-psr-app
% composer require nyholm/psr7 nyholm/psr7-server
% composer require narrowspark/http-emitter
サーバー起動
#
% php -S localhost:3939 -t public/ ./public/index.php
2540
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
);
2540
public/index.php
$serverRequest = $creator->fromGlobals();
;
$response = $psr17Factory->createResponse()
->withHeader('Content-Type', 'text/plain')
->withBody($psr17Factory->createStream('Hello!'));
$emitter = new SapiEmitter();
$emitter->emit($response);
2540
ControllerでResponseを返してみる
●
RequestHandlerで実装するとLaravel
○
の っぽくなる
Single Action Controller
__invoke()ではなくhandle()に
○
2540
注意: 今回はControllerを
●
RequestHandlerにしているが、
別の方法で安全に起動できる手段が
あれば他の方法でもよい
2540
src/Http/RequestHandler/Hello.php
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;
}
2540
src/Http/RequestHandler/Hello.php
public function handle(Request $request): Response
{
return $this->response_factory->createResponse()
->withHeader('Content-Type', 'text/plain')
->withBody(
$this->stream_factory->createStream('Hello!')
);
}
}
2540
public/index.php
$serverRequest = $creator->fromGlobals();
$hello = new Hello($psr17Factory, $psr17Factory);
$response = $hello->handle($serverRequest);
$emitter = new SapiEmitter();
$emitter->emit($response);
2540
せっかくなので、超簡単な
●
ルーターも作っていきましょう
2540
public/index.php
$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);
2540
src/Http/RequestHandler/StaticRouter.php
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;
}
2540
src/Http/RequestHandler/StaticRouter.php
public function handle(Request $request): Response
{
$path = $request->getUri()->getPath();
$method = $request->getMethod();
return (
$this->routes[$path][$method]
?? $this->error_page )()->handle($request);
}
}
2540
既存のミドルウェアを導入していく
●
○
https://github.com/middlewares/psr15-middle
のプロジェクトがさまざまなミド
wares
ルウェアを既に実装している
2540
オブジェクトの生成をどうするか
●
今回は数が少ないのですべて
○
では
○
https://github.com/phppg/phperkaigi-golf
PHP-DIにオブジェクトの生成とディス
パッチを任せている
2540
ServerRequestと型付けの問題
●
ServerRequest::getParsedBody()や
○
getQueryParams()から値を取り出す
のは$_GETと大差ない
getAttributeの値も現状
○
@varなどで明示的に型付けが必要