PSRで学ぶHTTP Webアプリケーションの実践

公開日:

オンラインYouTube Liveで開催された『PHPカンファレンス2020』でLong session(60分)として発表しました。

Download PDF

スライドテキスト

Page 1

PSRで学ぶHTTP Webアプリケーションの実践

2020-12-12 PHPカンファレンス2020 #phpcon


pixiv Inc.
USAMI Kenta

Page 2

お前誰よ

うさみけんた

ピクシブ株式会社 事業本部

pixiv
現職には 年から所属

2012
は 年頃から

PHP 2013

現行メンテナー

Emacs PHP Mode

USAMI Kenta

対応 まだです

PHP8 Attribute

@tadsan

カンファレンス終わったら

PHP …

などのコミュニティ

Emacs JP, php-users-ja

Page 3

2540

技術など

年以前は を書いてました

2012 Ruby
現職入社以後は社内 およびフレームワークの開

API

発・メンテナンスなどを担当
いままでのカンファレンスでは や 静的

Composer PHP

USAMI Kenta

解析・言語機能などを紹介してきました
趣味では や や などを

PHP PHP Emacs Lisp

@tadsan

書いてます

Page 4

2540

今回のお題


Page 5

Page 6

Page 7

2540

基本的には既存の素晴らしい資料を

読めば全部書いてある

しかしPHPとHTTPとオブジェクト指向

の理解がないと、何が嬉しいのか納
得しがたい


Page 8

2540

特にPSR-15に関しては今年の頭に

PHPerKaigiのために手を動かしてみる まで、いまいちぴんときていなかった


Page 9

2540

「抽象的な話はわかった 。で、

(わからん)

結局何すればいいの」という

理解のギャップを埋めることを目標とし
ています

発表後に資料の復習と実践を推奨しま

す


Page 10

2540

アジェンダ


Page 11

2540

1. HTTPの概説とPHPの制約


2. HTTPとフレームワークとPSR


3. PSR-7/15/17ベースの実装と勘所


Page 12

2540

1. HTTPの概説と

PHPの制約


Page 13

2540

ネットの

アドレスのやつ?


HTTPとは何か


Page 14

Page 15

2540

より引用

https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol
最終更新 年 月 日 火

2020 11 3 ( ) 13:19

Page 16

2540

ネットじゃん


Hypertextって…?


リンクとかクリックすると


別のページに飛べたりする文書


要はHTMLとかのこと


Page 17

2540

より引用

https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol
最終更新 年 月 日 火

2020 11 3 ( ) 13:19

Page 18

2540

テキストだけじゃな
いじゃん


より引用

https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol
最終更新 年 月 日 火

2020 11 3 ( ) 13:19

Page 19

2540

Enterを押すとHTTP
通信が走る


Page 20

2540

HTTP/3は


HTTPはTCPベースの通信


違いますが…


TCPはバイトの並びを送れる


HTTP/1はTCPでテキストを


特定のルールで区切って送信する


Page 21

2540

一般的なPHPでのHTTP通信


file_get_contents() 関数


cURL 関数


GuzzleやPSR-18で抽象化


Page 22

2540

HTTPのやりとりの基本


送信された要求(request)には


返答(response)を返す

HTML, CSS, JavaScript, JSON, そ

のほかなんでも


Page 23

2540

HTTPリクエスト


Page 24

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)


Page 25

2540

http://httpbin.org/post

POST /post HTTP/1.1
Host: httpbin.org
Accept: application/json
Content-Type: application/json
Connection: close

{"foo":"bar"}

Page 26

2540

ソケットが

使える


このルール通りにテキストを組み立てて

TCPでいい感じに送ればHTTP通信できる


Page 27

2540

httpbin.org


HTTPリクエストに対してさまざまな返

答してくれるWebサービス

ブラウザ上からもテストできる


Page 28

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)


Page 29

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 !== '');

Page 30

2540

ご安心ください


ところで、この方法で簡単に

通信できるのはHTTP/1.1以下だけ。

HTTPSやHTTP/2では困難。


Page 31

2540

HTTPSだろうと
 ブラウザからは

通信は簡単に見れる


Page 32

2540

Webブラウザの
 開発者ツール


Page 33

2540

さて


Page 34

2540

リクエストを送る人が居れば
 リクエストを受け取る人も居る


Page 35

2540

実際にはGoかも

しれないしRubyかも
しれない…


そうですPHPです


Page 36

2540

ブラウザでPHPの実行結果 が見えるということはPHPが HTTPリクエストを受け取って
レスポンスを

返しているにほかならない


Page 37

2540

スーパー

グローバル変数


$_SERVER

$_GET, $_POST


Page 38

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


Page 39

2540

より引用

https://www.php.net/manual/ja/reserved.variables.server.php

年 月 日閲覧

2020 12 12

Page 40

2540

CGI

(Common Gateway Interface)


かつては掲示板のような


Webアプリ = CGIだった

リクエストごとにプロセス起動し標準

入出力と環境変数で受け渡し


Page 41

2540

CGIから$_SERVER


$_SERVER['REQUEST_URI']
ヘッダを全て大文字 - を_に変換

○ ○
Content-Type ⇒
$_SERVER['HTTP_CONTENT_TYPE']


Page 42

2540

PHPとSAPI (ServerAPI)


PHPはサーバーの実行環境の差を
 ServerAPIという層で吸収している


cgi-fcgi, apache, fpm-fcgi, cli-sever...


環境ごとにコードを書き換える必要性が限り

なく小さい


Page 43

2540

HTTPレスポンス


Page 44

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..."}

Page 45

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>

Page 46

2540

PHPではechoなどで出力したものは

そのままレスポンスボディになる

(レスポンスヘッダは、ボディを

echoする前にheader()を呼んでおく)


Page 47

2540

では基本的にこれだけ

PHP

<?php
header('Content-Type: application/json'); header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');

echo json_encode($data);

Page 48

2540

では基本的にこれだけ

PHP

<?php
header('Content-Type: text/html');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');
?>
<!DOCTYPE html>
<html>
<head>...

Page 49

2540

では基本的にこれだけ

PHP

<?php
header('Content-Type: image/png');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');

画像ファイルをそのまま返すこともできる

//
readfile(__DIR__ . '/../storage/flower.png');

Page 50

2540

PerlやRubyなどの言語のCGI機能はあ

くまでもライブラリだが、PHPはServer APIとしてHTTP処理が言語処理系と統
合されている


Page 51

2540

スーパーグローバル変数


$_GET, $_POST, $_REQUEST


$_SERVER, $_COOKIE, $FILES

グローバル関数


header(), setcookie(), session


Page 52

2540

ならPHPの基本機能をそのまま
 使うのが一番自然でいいのでは


Page 53

2540

そうともいかない事情がある


Page 54

2540

グローバルは

テストとひたすら相

スーパーグローバル変数


性が悪い


$_GET, $_POST, $_REQUEST


$_SERVER, $_COOKIE, $FILES

グローバル関数


header(), setcookie(), session


Page 55

2540

なぜテストと相性が悪いのか


一般的なユニットテストはテスト対象

に引数を与えて、その返り値をア
サーションする

実装が引数以外のものに依存すると

テストしにくい


Page 56

2540

とはいえグローバル変数はどうにかな

る

PHPUnitならテストケース実行前の

setUp()で確実にリセットしておけばい
い


Page 57

2540

header()/setcookieとCLIの相性


PHPUnitのテスト対象で呼ばれると

Warningで止まる

○ Warning: Cannot modify header

information

Page 58

2540

header()/setcookieとCLIの相性


「特定の場合にヘッダ/Cookieが送

信されること」のようなアサーションは
書けない

ラッパー関数などの工夫で可能


弊社ではStaticMockで対応


Page 59

2540

2. HTTPとフレームワー
クとPSR


Page 60

2540

ここまでの問題点を

整理しよう


Page 61

2540

PHPのHTTP機能はグローバルな


状態と密結合している

操作できないグローバルな状態が


存在すると引数と返り値による
 アサーションに落とし込めない


Page 62

2540

それならHTTPを

引数と返り値で扱える

場所に持ってくればいい


Page 63

2540

HTTPの構造は決まってる
わけじゃないですか


Page 64

2540

リクエスト

HTTP

リクエスト行


POST /post HTTP/1.1
Host: httpbin.org
Accept: application/json
Content-Type: application/json

ヘッダフィールド


Connection: close

{"foo":"bar"}

リクエストボディ


Page 65

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..."}

Page 66

2540

いかにもクラスに

できそう


Page 67

2540

システムの奥深くでグローバル

変数を参照されたら困るなら
 リクエストオブジェクトを渡して

そこから状態を取得させればいい


Page 68

2540

header()関数をコールされたら困る
なら、送信したいヘッダを

レスポンスオブジェクトに記録して受
け渡していけばいい


Page 69

2540

そりゃみんな

やりますよね…


より引用

Rob Allen HTTP, PSR-7 and Middleware

Page 70

2540

いろいろあった


Page 71

2540

2013年にはこういう
のもあった


より引用

https://stackphp.com/

Page 72

2540

より引用

https://stackphp.com/

Page 73

2540

より引用

https://stackphp.com/

Page 74

2540

そこで出てくるのが

PHP-FIGとPSR


Page 75

2540

PHP-FIG


PHP Framework Interop Group


(フレームワーク相互運用グループ)


PSR


PHP Standard Recommendation

(PHP標準勧告)


Page 76

2540

経緯はsasezakiさんの

「憂鬱な希望としての PSR-7」を参照


Page 77

2540

時は流れて2018年

PSR-15: 


HTTP Server Request Handlers

PSR-17: HTTP Factories
が受理


Page 78

2540

これで晴れて具象への依存を避けつつPSR ベースのWebアプリを書けるようになったとい
う話は、田中ひさてるさんの

PHP-FIGのHTTP処理標準の設計はなぜ
PSR-7/15/17になったのか

を参照ください


Page 79

2540

3. PSR-7/15/17ベー
スの実装と勘所


Page 80

2540

お待たせしました

ここからWebアプリを

作りはじめましょう


Page 81

2540

実装を選ぶ


PSRは飽くまでインターフェイス


オブジェクトは別に必要


Page 82

2540

PSR-7/PSR-17


を使う



Guzzleもいいがnyholm/psr7


SAPIとの入出力


nyholm/psr7-server

narrowspark/http-emitter


Page 83

2540

ServerRequestInterface


Requestにスーパーグローバル

($_GETなど)相当+Attributeを

付与して引き回せる


Page 84

2540

Emitter


Responseオブジェクトを出力


(echo)

HTTPヘッダに合わせてheader()関数

を呼ぶ


Page 85

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

Page 86

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
);

Page 87

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);

Page 88

2540

ControllerでResponseを返してみる


RequestHandlerで実装するとLaravel

の っぽくなる


Single Action Controller
__invoke()ではなくhandle()に


Page 89

2540

注意: 今回はControllerを

RequestHandlerにしているが、

別の方法で安全に起動できる手段が

あれば他の方法でもよい


Page 90

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;
}

Page 91

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!')
);
}
}

Page 92

2540

public/index.php

$serverRequest = $creator->fromGlobals();

$hello = new Hello($psr17Factory, $psr17Factory);
$response = $hello->handle($serverRequest);

$emitter = new SapiEmitter();
$emitter->emit($response);

Page 93

2540

せっかくなので、超簡単な


ルーターも作っていきましょう


Page 94

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);

Page 95

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;
}

Page 96

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);
}
}

Page 97

2540

既存のミドルウェアを導入していく



https://github.com/middlewares/psr15-middle
のプロジェクトがさまざまなミド

wares
ルウェアを既に実装している


Page 98

2540

オブジェクトの生成をどうするか


今回は数が少ないのですべて


では


https://github.com/phppg/phperkaigi-golf
PHP-DIにオブジェクトの生成とディス
パッチを任せている


Page 99

2540

ServerRequestと型付けの問題


ServerRequest::getParsedBody()や

getQueryParams()から値を取り出す
のは$_GETと大差ない
 getAttributeの値も現状


@varなどで明示的に型付けが必要