Skip to content

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

技術など

  • 2012年以前はRubyを書いてました
  • 現職入社以後は社内APIおよびフレームワークの開

発・メンテナンスなどを担当

  • いままでのカンファレンスではComposerやPHP静的

解析・言語機能などを紹介してきました

USAMI Kenta

  • 趣味ではPHPやPHPやEmacs Lispなどを

@tadsan書いてます

Page 4

今回のお題

Page 5

Page 6

Page 7

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

読めば全部書いてある

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

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

Page 8

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

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

Page 9

  • 「抽象的な話はわかった(わからん)。で、

結局何すればいいの」という理解のギャップを埋めることを目標としています

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

Page 10

アジェンダ

Page 11

1. HTTPの概説とPHPの制約

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

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

Page 12

1. HTTPの概説とPHPの制約

Page 13

ネットのアドレスのやつ?

HTTPとは何か

Page 14

Page 15

https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol より引用最終更新 2020年11月3日 (火) 13:19

Page 16

ネットじゃん

  • Hypertextって…?
  • リンクとかクリックすると

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

  • 要はHTMLとかのこと

Page 17

https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol より引用最終更新 2020年11月3日 (火) 13:19

Page 18

テキストだけじゃな

いじゃん

https://ja.wikipedia.org/wiki/Hypertext_Transfer_Protocol より引用最終更新 2020年11月3日 (火) 13:19

Page 19

Enterを押すとHTTP通信が走る

Page 20

HTTP/3は違いますが…

  • HTTPはTCPベースの通信
  • TCPはバイトの並びを送れる
  • HTTP/1はTCPでテキストを

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

Page 21

  • 一般的なPHPでのHTTP通信
  • file_get_contents() 関数
  • cURL 関数
  • GuzzleやPSR-18で抽象化

Page 22

  • HTTPのやりとりの基本
  • 送信された要求(request)には

返答(response)を返す

  • HTML, CSS, JavaScript, JSON, そ

のほかなんでも

Page 23

HTTPリクエスト

Page 24

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

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

ソケットが使える

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

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

Page 27

  • httpbin.org
  • HTTPリクエストに対してさまざまな返

答してくれるWebサービス

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

Page 28

HTTPリクエスト (リクエスト組み立て)

リクエスト行

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

Page 29

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

ご安心ください

ところで、この方法で簡単に通信できるのはHTTP/1.1以下だけ。

HTTPSやHTTP/2では困難。

Page 31

HTTPSだろうとブラウザからは通信は簡単に見れる

Page 32

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

Page 33

さて

Page 34

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

Page 35

実際にはGoかもしれないしRubyかもしれない…

そうですPHPです

Page 36

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

Page 37

スーパーグローバル変数

$_SERVER

$_GET, $_POST

Page 38

$_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

https://www.php.net/manual/ja/reserved.variables.server.php より引用2020年12月12日 閲覧

Page 40

  • CGI (Common Gateway Interface)
  • かつては掲示板のような

Webアプリ = CGIだった

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

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

Page 41

  • CGIから$_SERVER
  • $_SERVER['REQUEST_URI']
  • ヘッダを全て大文字 - を_に変換
  • Content-Type ⇒

$_SERVER['HTTP_CONTENT_TYPE']

Page 42

  • PHPとSAPI (ServerAPI)
  • PHPはサーバーの実行環境の差を

ServerAPIという層で吸収している

  • cgi-fcgi, apache, fpm-fcgi, cli-sever...
  • 環境ごとにコードを書き換える必要性が限り

なく小さい

Page 43

HTTPレスポンス

Page 44

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

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

PHPではechoなどで出力したものはそのままレスポンスボディになる(レスポンスヘッダは、ボディをechoする前にheader()を呼んでおく)

Page 47

PHPでは基本的にこれだけ

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

echo json_encode($data);

Page 48

PHPでは基本的にこれだけ

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

Page 49

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

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

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

Page 51

  • スーパーグローバル変数
  • $_GET, $_POST, $_REQUEST

$_SERVER, $_COOKIE, $FILES

  • グローバル関数
  • header(), setcookie(), session

Page 52

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

Page 53

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

Page 54

グローバルはテストとひたすら相性が悪い

  • スーパーグローバル変数
  • $_GET, $_POST, $_REQUEST

$_SERVER, $_COOKIE, $FILES

  • グローバル関数
  • header(), setcookie(), session

Page 55

  • なぜテストと相性が悪いのか
  • 一般的なユニットテストはテスト対象

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

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

テストしにくい

Page 56

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

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

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

Page 57

  • header()/setcookieとCLIの相性
  • PHPUnitのテスト対象で呼ばれると

Warningで止まる

  • Warning: Cannot modify header

information

Page 58

  • header()/setcookieとCLIの相性
  • 「特定の場合にヘッダ/Cookieが送

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

  • ラッパー関数などの工夫で可能
  • 弊社ではStaticMockで対応

Page 59

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

Page 60

ここまでの問題点を整理しよう

Page 61

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

状態と密結合している

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

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

Page 62

それならHTTPを引数と返り値で扱える場所に持ってくればいい

Page 63

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

Page 64

HTTPリクエスト

リクエスト行

ヘッダフィールド

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

リクエストボディ

{"foo":"bar"}

Page 65

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

いかにもクラスにできそう

Page 67

システムの奥深くでグローバル変数を参照されたら困るならリクエストオブジェクトを渡してそこから状態を取得させればいい

Page 68

header()関数をコールされたら困るなら、送信したいヘッダをレスポンスオブジェクトに記録して受け渡していけばいい

Page 69

そりゃみんな

やりますよね…

Rob Allen HTTP, PSR-7 and Middlewareより引用

Page 70

いろいろあった

Page 71

2013年にはこういう

のもあった

https://stackphp.com/ より引用

Page 72

https://stackphp.com/ より引用

Page 73

https://stackphp.com/ より引用

Page 74

そこで出てくるのがPHP-FIGとPSR

Page 75

  • PHP-FIG
  • PHP Framework Interop Group

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

  • PSR
  • PHP Standard Recommendation

(PHP標準勧告)

Page 76

経緯はsasezakiさんの「憂鬱な希望としての PSR-7」を参照

Page 77

時は流れて2018年

PSR-15: HTTP Server Request Handlers

PSR-17: HTTP Factories

が受理

Page 78

これで晴れて具象への依存を避けつつPSRベースのWebアプリを書けるようになったという話は、田中ひさてるさんのPHP-FIGのHTTP処理標準の設計はなぜPSR-7/15/17になったのかを参照ください

Page 79

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

Page 80

お待たせしましたここからWebアプリを作りはじめましょう

Page 81

  • 実装を選ぶ
  • PSRは飽くまでインターフェイス
  • オブジェクトは別に必要

Page 82

  • PSR-7/PSR-17
  • Guzzleもいいがnyholm/psr7を使う
  • SAPIとの入出力
  • nyholm/psr7-server
  • narrowspark/http-emitter

Page 83

  • ServerRequestInterface
  • Requestにスーパーグローバル

($_GETなど)相当+Attributeを付与して引き回せる

Page 84

  • Emitter
  • Responseオブジェクトを出力

(echo)

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

を呼ぶ

Page 85

プロジェクト初期化

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

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

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

  • ControllerでResponseを返してみる
  • RequestHandlerで実装するとLaravel

のSingle Action Controllerっぽくなる

  • __invoke()ではなくhandle()に

Page 89

  • 注意: 今回はControllerを

RequestHandlerにしているが、別の方法で安全に起動できる手段があれば他の方法でもよい

Page 90

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

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

public/index.php

$serverRequest = $creator->fromGlobals();

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

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

Page 93

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

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

Page 94

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

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

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

  • 既存のミドルウェアを導入していく
  • https://github.com/middlewares/psr15-middle

wares のプロジェクトがさまざまなミドルウェアを既に実装している

Page 98

  • オブジェクトの生成をどうするか
  • 今回は数が少ないのですべて
  • https://github.com/phppg/phperkaigi-golf では

PHP-DIにオブジェクトの生成とディスパッチを任せている

Page 99

  • ServerRequestと型付けの問題
  • ServerRequest::getParsedBody()や

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

  • getAttributeの値も現状

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