PSR-7とPSR-15によるWebアプリケーション実装パターン

公開日:

東京都練馬区立区民・産業プラザ Coconeriホール およびニコニコ生放送で開催された『PHPerKaigi 2022』でライトニングトーク(5分)として発表しました。

Download PDF

スライドテキスト

Page 1

PSR-7とPSR-15によるWebアプリケーション実装パターン

Web application implementation pattern based on PSR-7 and15

pixiv Inc.
USAMI Kenta

2022-04-11

PHPerKaigi Day 2

Page 2

お前誰よ

  • うさみけんた (@tadsan) / Zonu.EXE / にゃんだーすわん
  • ピクシブ株式会社 pixiv事業本部 エンジニア
    • 最近はピクシブ百科事典(dic.pixiv.net)を開発しています
  • Emacs Lisper, PHPer
    • Emacs PHP Modeを開発しています (2017年-)
  • PHPerKaigi コアスタッフ、PHPカンファレンス実行委員

Page 3

tadsanのあれこれが読める場所

  • https://tadsan.fanbox.cc/
  • https://scrapbox.io/php/
  • https://www.phper.ninja/
  • https://zenn.dev/tadsan
  • https://qiita.com/tadsan
  • https://github.com/bag2php

Page 4

前回のPHPerKaigi

Page 5

去年のPHPカンファレンス

Page 6

一昨年のPHPカンファレンス

Page 7

Software Design 11月号

Page 8

WEB+DB PRESS総集編

Page 9

WEB+DB PRESS Vol.96

(2016年)

Page 10

WEB+DB PRESS Vol.96

(2017年)

Page 11

仕事の話

Page 12

Powered by PSR-15

Page 13

PIXIV SPRING BOOT CAMP

Page 14

たぶん近日中に夏インターンも募集

Page 15

それそろフレームワークとしての

改善ポイントが

突っ込みどころ
枯渇しつつあるので
参加したい学生はお早めに

Page 16

今回のお題

Page 17

プロポーザル

Page 18

日本語のPSR-HTTP関連資料集

Page 19

Webフレームワークを 作りたい人や内部構造
を理解したい人向け

Page 20

前半はフレームワーク
一般 概念 話

の の もします

Page 21

後半もPSR-15入門 とテスト方法みたいな
話に終始します

Page 22

最後のスライド
(予告)

Page 23

俺たちのPSR-15道は
これからだ!
(ご静聴ありがとうございました。 tadsanの次回作にご期待ください)

Page 24

さて

Page 25

誰がPSR-15を使うのか

  • PSR標準に準拠したフレームワークを開発したい人
  • CakePHPのようなPSR-15準拠フレームワークでミドルウェアを書く必要
    に迫られた人
  • シンプルなライブラリを組み合わせだけで、
    リーンでクリーンなWebアプリを開発したい人

Page 26

PSRとは

Page 27

PHP-FIG

Page 28

PSRs

Page 29

PSR

(PHP Standard Recommendation)

  • PHP-FIG(PHP相互運用グループ)という団体が策定する標準勧告
  • コーディング規約やインターフェイス定義などが定められている
  • フレームワークやCMSの開発団体間で共通仕様を策定して
    コンポーネントを相互運用できるようにすることが目的
    • 本来はメンバー間での相互運用が目的なのだが、
      われわれ下々の一般PHPerもその成果物の恩恵に与っている恰好

Page 30

よく知られたPSR

  • PSR-4 オートローディング
    • 名前空間とディレクトリをマッピングするルール。Composerで利用可能
  • PSR-1 基本コーディング標準
    • ファイル名はUTF-8で、定義とスクリプトのファイル分離など基本ルール
  • PSR-12 拡張コーディングスタイルガイド
    • 改行やスペース位置などのスタイル策定のベースになるガイド

Page 31

PSR interfaces

  • PSR-3 ロガー
    • syslog風の汎用ロギングインターフェイス
  • PSR-11 コンテナ
    • DIコンテナと例外送出のインターフェイス
  • PSR-6 キャッシュ / PSR-16 単純なキャッシュ(SimpleCache)
    • TTL(寿命)付きで値を保持するクラスのインターフェイス

Page 32

インターフェイス 概要

(interface)

  • class定義に似ているが、クラスが備えるべきメソッドの型だけ宣言したもの
  • interface名は型宣言(パラメータや戻り値、プロパティなど)に利用できる
  • クラスは複数のインターフェイスを実装(implements)できる
    • PHPのクラスは一つだけ継承(extends)できるのと対照的
  • class定義と分けることで、具体的な実装と抽象を分離できる
    • 同じインターフェイスを実装したクラスに交換可能になる

Page 33

余談:PSRへの誤解

  • PSRは全PHP開発者が守らなければいけない標準という性質ではない
    • PHP-FIG参加メンバーも勧告に従うかどうかは任意
  • PSR-2/PSR-12(スタイルガイド)は規約ではなく、
    プロジェクトごとにスタイルを策定するためのガイドラインです
    • カスタマイズする場合でも、PHP-CS-FixerやPHP_CodeSnifferのよう
      なツールでPSR-12からの差分で定義できるというのがメリット

Page 34

PSR vs HTTP

  • PSRではHTTPに関連するオブジェクトのインターフェイスを定義している
  • PSR-7 HTTPメッセージ
    • リクエスト、レスポンス、URI、ストリームなどのインターフェイスを定義
    • PSR-17でこれらメッセージオブジェクトのファクトリが定義されている
  • PSR-15 HTTPハンドラ(ミドルウェア)
    • リクエストハンドラとミドルウェアのインターフェイスを定義

Page 35

PSRのコンセプトについて最重要資料

Page 36

PSR-7のおさらい

Page 37

PSR-7 HTTP Messages

  • 不変(イミュータブル)が基本 (Streamを除く)
    • setXXXのようなメソッドは存在しない
  • 継承関係がある
    • Psr Http MessageInterface (レスポンスとリクエストの共通集合)
      \ \
      ├─ RequestInterface (HTTPリクエスト) │  └─ ServerRequestInterface (↑+追加情報) └─ ResponseInterface (HTTPレスポンス)

Page 38

不変性

(immutability)

  • 一度作ったオブジェクト内部の状態が変わることはない
  • 状態を付加したいときは $res->setHeader()のような操作はできず、
    $res = $res->withHeader() のように追記する
  • この性質により、一度作ったインスタンスオブジェクトが別の場所で
    意図せず書き換えられる可能性を避けられるので非常に安全

Page 39

PSR-7 Packages

  • PSR-7を実装するパッケージは複数あり、基本的には交換可能

https://packagist.org/providers/psr/http-message-implementation

  • 基本的にはWebフレームワークとHTTPクライアントどちらでも使える
    • guzzlehttp/psr7, nyholm/psr7,
      laminas/laminas-diactoros, slim/psr7などがある
    • Guzzleのstreamは機能豊富、Nyholmは軽量などの特徴がある

Page 40

ServerRequestInterface

  • RequestInterfaceを継承したインターフェイス
  • $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES相当の
    データを扱うメソッドが追加されている
    • withCookieParams()などは$_COOKIE相当のデータを付加するが、 内部で保持するHTTPヘッダを書き換えてはならない(MUST NOT)と仕
      様で明言されている
    • withAttribute()/getAttribute()という、事実上なんでも入れていい
      収納スペースがある

Page 41

ServerRequest::withAttribute()

  • アトリビュート(属性)という名前が付いているが、PHP 8で追加された #[Attributes] とは何の関係もなく、単に付加的な情報を持たせる
  • 型的には文字列をキーにして、本当にあらゆるものが付与できてしまう
    • 原則何を入れてもいいのだが、ユーザーのリクエストと関連のあるものに
      限って含めた方がとり回しがよくなるはず
    • キーにも任意の値をセットできるが、クラス名文字列を活用すると明示的
      • $req->withAttribute(Session::class, new ConcreteSession())
      • 個人的には単なるスカラー値よりオブジェクトで統一するのが安全と思う

Page 42

ResponseInterface

  • RequestInterfaceとは異なり、Server…版は存在しない
  • レスポンスボディは文字列ではなく、StreamInterfaceを実装した
    オブジェクトで表現する
    • ボディをセットするには $response->withBody()を使う方法と
      $response->getBody()->write()する方法がある
      • 後者は追記になってしまうので、前者の方が確実

Page 43

PSR-17 HTTP Factories

  • PSR-7オブジェクトを生成する役割のインターフェイス
  • $res = new GuzzleHttp Psr7 Response()と書く代りに

\ \

$res = $response_factory->createResponse()でオブジェクトを
生成できる

  • これがなぜ重要なのかは田中ひさてるさんの発表を見てください
  • 個別の実装が具体的なライブラリに依存しなくなるようにできます

Page 44

不変性

(immutability)

  • 一度作ったオブジェクト内部の状態が変わることはない
  • 状態を付加したいときは $res->setHeader()のような操作はできず、
    $res = $res->withHeader() のように追記する
  • この性質により、一度作ったインスタンスオブジェクトが別の場所で
    意図せず書き換えられる可能性を避けられるので非常に安全

Page 45

Nyholm Psr17Factory

  • PSR-7オブジェクトを生成する役割のインターフェイス
  • PSR-17の全部のinterfaceをオールインワンにまとめている
  • なんちゃらインターフェイスを要求する引数にこのオブジェクト渡せばいい

Page 46

Nyholm/psr7のREADMEより

Page 47

PSR-7とフレームワークの関係

  • PHP-FIGメンバーだからと言って全て従うわけではない
  • SlimやLaminas mezzioはPSR-7を採用している
  • CakePHP 4.xのHTTPメッセージ(cakephp/http)は
    PSR-7とCakePHP 3互換のインターフェイス両方を持っている
  • symfony/http-foundationはPSR-7ではないが、symfony/psr-
    http-message-bridgeというアダプターを噛ますとLaravelでも使える

Page 48

PHPの実行環境

Page 49

従来型のPHP実行環境

  • HTTPリクエストを受け付けるたびに全てがリセットされる ⇒ 超短命
    • クラス定義・関数定義・変数・定数… あらゆるものがまっさらになる
  • Apache(mod_php), PHP-FPM, ビルトインサーバ(php -S), CGIなど、
    既存のPHPが動いてきた環境のほとんどがこの動作をする
  • どんな環境でもCGIと互換性のあるスクリプトが動いてきたのが
    PHPが長年しぶとく生きのびた秘訣。
    • Perl、RubyやPythonはCGIとの断絶を経験している

Page 50

フレームワークを使わないPHP

  • ユーザーからのHTTPリクエスト情報はスーパーグローバル変数に格納
    • 宣言なしでどこからでもアクセスできる特殊なグローバル変数
    • $_SERVER $_GET $_POST $_COOKIE $_FILES $_SESSION
    • リクエストボディは(cid:224)le_get_contents('php://input')で取得可能
  • header()関数などでセットしたものがHTTPレスポンスヘッダとして、
    echoしたものがHTTPレスポンスボディとして出力される

Page 51

フレームワークを使わないPHP

<?php
$name = $_GET['name'];
$title = "Hello, {$name}!";
header('Content-Type: application/html; charset=UTF-8');
?>
<title><?= htmlspecialchars($title) ?></title>
<h1><?= htmlspecialchars($title) ?></h1>

現在は

<p>

年 月 日 時 分 秒

<time><?= htmlspecialchars(date('Y m d H i s ')) ?></time>

です

</p>

Page 52

Good old PHP

/var/www/example.com/public/index.php

なんかいろんな関数を
呼んだりファイルをincludeしたりする

$_SERVER

Page 53

Good old PHP

/var/www/example.com/public/index.php

$_SERVER

Page 54

Good old PHP

/var/www/example.com/public/index.php

header()

$_SERVER

Page 55

Good old PHP

/var/www/example.com/public/index.php

header()

ob_start()

$_SERVER

Page 56

Good old PHP

/var/www/example.com/public/index.php

header()

ob_start()

echo

$_SERVER

Page 57

Good old PHP

/var/www/example.com/public/index.php

echo

header()

ob_start()

echo

$_SERVER

Page 58

Good old PHP

/var/www/example.com/public/index.php

echo

set_cookie()

header()

ob_start()

echo

$_SERVER

Page 59

Good old PHP

/var/www/example.com/public/index.php

echo

set_cookie()

ob_end_flush()

header()

ob_start()

echo

$_SERVER

Page 60

よくある従来のPHPスクリプト

(CGI型)

  • どこでもユーザー入力をグローバル変数から参照する
    ($_GET, $_SERVER, $_COOKIEなど)
  • どこでもheader()関数でHTTPヘッダを設定したり、
    どこでもechoしてHTMLを出力したりすることができる
    • 呼ばれた関数の処理の奥底で何気なくheader()や setcookie()関数を呼んでセットされてたりするやつ
    • 処理を全部追わないと最終的な出力 (レスポンスヘッダ・ボディ)がわからん

Page 61

こういう世界と
どうやって整合性を
合わせるのか

Page 62

PSR-7の出力

(emit)

  • ResponseInterfaceを出力する役割 (PSR-7の仕様外)
  • ApacheやPHP-FPMで動かしているなら自作することも 割と簡単にできるが、できればライブラリに任せた方がいい
  • 従来型環境での定番は laminas/laminas-httphandlerrunner
    • SapiEmitterとSapiStreamEmitterの2種類がある
    • 一回で出力するか、リソースから読み込めた都度出力するかの違い

Page 63

PSR-7の出力

(emit)

概念的なざっくり実装。ほんとはもうちょっと考慮事項がある

//

function emitResponse(ResponseInterface $response): void
{
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
foreach ((array)$values as $value) {
header("{$name}: {$value}");
}
}

echo $response->getBody();
}

Page 64

PSR-7の入力

(ServerRequestの錬成)

  • ServerRequestはどこから来るの?
  • 各PSR-7ライブラリとかがグローバル変数から情報をひっこぬいて
    ServerRequestを作ってくれる便利クラスを提供してる(fromGlobals)
    • Guzzleの場合はServerRequest::fromGlobals()と呼ぶだけ
    • Nyholmはnyholm/psr7-serverという別パッケージに分離されてる
  • 基本的に一箇所で一回だけ生成するようにした方がいい

Page 65

Good old PHP

/var/www/example.com/public/index.php

なんかいろんな関数を
呼んだりファイルをincludeしたりする

$_SERVER

Page 66

従来型環境でPSR-7を使う概念

ServerRequest (fromGlobals)

/var/www/example.com/public/index.php

$_SERVER

Page 67

従来型環境でPSR-7を使う概念

ServerRequest (fromGlobals)

/var/www/example.com/public/index.php

$_SERVER

なんかいろんな関数を
呼んだりしてResponse作る

Page 68

従来型環境でPSR-7を使う概念

ServerRequest

SapiEmitter

(fromGlobals)

/var/www/example.com/public/index.php

$_SERVER

なんかいろんな関数を
呼んだりしてResponse作る

Page 69

従来型環境でPSR-7を使う概念

ServerRequest

SapiEmitter

(fromGlobals)

/var/www/example.com/public/index.php

echo

$_SERVER

header

なんかいろんな関数を
呼んだりしてResponse作る

Page 70

フレームワークの光と影

  • そもそもPHPはフレームワークがなくてもWebアプリが書ける

Page 71

フレームワークの光と影

  • そもそもPHPはフレームワークがなくてもWebアプリが書ける
  • PHPにとってのWebフレームワークには二つの性質がある

Page 72

フレームワークの光と影

  • そもそもPHPはフレームワークがなくてもWebアプリが書ける
  • PHPにとってのWebフレームワークには二つの性質がある
    • PHP標準ではめんどくさい機能を実現する便利ライブラリの集合体

Page 73

フレームワークの光と影

  • そもそもPHPはフレームワークがなくてもWebアプリが書ける
  • PHPにとってのWebフレームワークには二つの性質がある
    • PHP標準ではめんどくさい機能を実現する便利ライブラリの集合体
    • フリーダムにどこでもなんでも書けてしまうPHPの機能に規則を
      与えることで秩序をもたらす拘束具

Page 74

フレームワークの光と影

  • そもそもPHPはフレームワークがなくてもWebアプリが書ける
  • PHPにとってのWebフレームワークには二つの性質がある
    • PHP標準ではめんどくさい機能を実現する便利ライブラリの集合体
    • フリーダムにどこでもなんでも書けてしまうPHPの機能に規則を
      与えることで秩序をもたらす拘束具
  • PSR-7/15は従来型のPHPにとって縛りプレイのための異物に過ぎない

Page 75

縛ることによって得られるもの

  • $_GETのようなグローバル変数やheader()のような関数を排斥することで
    入出力が明確に分離されてコードの見通しがよくなり、テストしやすくなる
  • Long-livingなPHPとの親和性
    • 従来のPHPはリクエストごとに全ての状態がリセットされるのに対して、
      RoadRunnerやSwooleでHTTPサーバを立てると毎回リセットされない
    • そのような環境ではCLI SAPIで動作するので、$_GETや関数からリクエス
      ト情報が得られないし、echoしてもHTTPレスポンスにならない

Page 76

Long-living PHPとの互換性

  • 入口と出口、つまりServerRequestを作る部分とエミッタを差し替えれば
    ApacheやPHP-FPMなど従来型のPHP環境でも動かせるようになる
  • 最近はフレームワークが実行環境の差異を吸収してくれるようになった
    • Symfony Runtime + https://github.com/php-runtime
    • Laravel Octane

Page 77

CyberAgent白井さんの発表

Page 78

Long-living PHPの世界

  • 従来環境とはオブジェクトの初期化や生存期間の概念が変わるので注意
  • $_SERVER, $_GETのようなグローバル変数や
    header()、setcookie()のような関数が今まで通りの動きをしない
    • Laravelでもこれらの機能を使うことはできたが、動かなくなる
  • 定数、グローバル変数、静的プロパティなどもリセットされなくなる
  • グローバルな状態を持たず引数と戻り値に閉じていれば問題ない

Page 79

従来型環境でPSR-7を使う概念

ServerRequest (fromGlobals)

/var/www/example.com/public/index.php

$_SERVER

Page 80

従来型環境でPSR-7を使う概念

ServerRequest (fromGlobals)

/var/www/example.com/public/index.php

$_SERVER

なんかいろんな関数を
呼んだりしてResponse作る

Page 81

従来型環境でPSR-7を使う概念

ServerRequest

SapiEmitter

(fromGlobals)

/var/www/example.com/public/index.php

$_SERVER

なんかいろんな関数を
呼んだりしてResponse作る

Page 82

従来型環境でPSR-7を使う概念

ServerRequest

SapiEmitter

(fromGlobals)

/var/www/example.com/public/index.php

echo

$_SERVER

header

なんかいろんな関数を
呼んだりしてResponse作る

Page 83

RoadRunner

Page 84

RoadRunner

Page 85

RoadRunner

Page 86

RoadRunner

Page 87

RoadRunner

Page 88

RoadRunner

Page 89

RoadRunner

Page 90

RoadRunner

なんかいろんな関数を
呼んだりしてResponse作る

Page 91

RoadRunner

ServerRequest

なんかいろんな関数を
呼んだりしてResponse作る

Page 92

RoadRunner

Response

ServerRequest

なんかいろんな関数を
呼んだりしてResponse作る

Page 93

RoadRunner

Response

ServerRequest

なんかいろんな関数を
呼んだりしてResponse作る

Page 94

RoadRunner

Response

ServerRequest

なんかいろんな関数を
呼んだりしてResponse作る

Page 95

従来型環境

ServerRequest

SapiEmitter

(fromGlobals)

/var/www/example.com/public/index.php

echo

$_SERVER

header

なんかいろんな関数を
呼んだりしてResponse作る

Page 96

RoadRunner

Response

ServerRequest

なんかいろんな関数を
呼んだりしてResponse作る

Page 97

入出力を端に揃えると交換可能に

Page 98

入出力を端に揃えると交換可能に

両端の構造が合っていれば
従来型とも共通化しやすそう

Page 99

外の世界の事情を無視できる

Page 100

外の世界の事情を無視できる

どこで動いてようが あまり関係なくなる

Page 101

ユニットテストで実行もできる

Page 102

ユニットテストで実行もできる

値を渡して
戻り値を検査する

Page 103

もうちょっと
具体的に

Page 104

PSR-15

Page 105

PSR-15 HTTP Handlers

  • Psr Http Server 名前空間に2種類のインターフェイスが定義されている
    \ \ \
  • RequestHandlerInterface
    • レスポンスを生成する (例外送出するかもしれない)
  • MiddlewareInterface
    • リクエストハンドラを呼び出すか、呼び出さずにレスポンスを生成してもよい

Page 106

「ミドルウェア」という語について

  • コンピュータ用語の中でも意味が一貫していないことで有名
    • なんかの中間で何かを処理するという非常に漠然としたニュアンス
    • OSでもハードウェアでもない、ソフトウェアとやりとりする何者かが
      ミドルウェアと呼ばれることがある(Webサーバとかファイルシステムとか)
  • Webフレームワークでは最終的に呼び出されるやつ(RequestHandler)
    の間に入って何か処理するものをミドルウェアと呼ぶ

Page 107

ほかの世界のミドルウェア

  • 結構いろんな言語のHTTP仕様に入っている
    • Ruby(Rails含む)ではRack、PythonではWSGIなど
  • PHPでもStackPHPという仕様が提案されていたこともあった
  • CakePHP 3では独自インターフェイスのミドルウェアがあったが、
    CakePHP 4でPSR-15互換に移行された

Page 108

RequestHandlerInterface

<?php

namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface;
}

Page 109

MiddlewareInterface

<?php

namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface MiddlewareInterface
{
public function process(ServerRequestInterface $request,

RequestHandlerInterface $handler): ResponseInterface;
}

Page 110

PSR Middlewares

  • GitHubのmiddlewares Organizationに実装済みのミドルウェアがある
    • https://github.com/middlewares/psr15-middlewares
    • https://github.com/middlewares/awesome-psr15-middlewares
    • 私は音楽性の合わない部分があるので使ってませんが便利だと思います
      • PSR-17に対応してないからです

Page 111

簡単なRequestHandlerを作ってみる

  • 以後、スライド上に出すコードは簡潔にするために適宜省略しています
    • namespaceやuseは省略しています
    • RequestFactoryInterfaceなどの末尾もasで省略します
  • 完全なソースコードはGitHub上で確認してください
    • https://github.com/zonuexe/phperkaigi-psr15

Page 112

HelloWorldっぽいレスポンスを返す

class HelloJsonHandler implements RequestHandlerInterface {
public function __construct(
private StreamFactory $stream_factory,

private ResponseFactory $response_factory

){}

public function handle(ServerRequest $request): Response {
$body = $this->stream_factory->createStream(json_encode(['Hello' => 'World']);
return $this->response_factory->createResponse(200)
 ->withHeader('Content-Type', ['application/json'])
->withBody($body);
}
}

Page 113

GETのときにHelloWorldを返す

前略

//

public function handle(ServerRequestInterface $request): ResponseInterface {
if ($request->getMethod() !== 'GET') {
return $this->response_factory->createResponse(404);
}

後はさっきと同じ

//

$body = $this->stream_factory->createStream(json_encode(['Hello' => 'World']);
return $this->response_factory->createResponse(200)
 ->withHeader('Content-Type', ['application/json'])
->withBody($body);
}

Page 114

RequestHandler呼び出しイメージ

RequestHandler

ServerRequest

Page 115

RequestHandler呼び出しイメージ

RequestHandler

Response

ServerRequest

Page 116

テストしてみよう

テストケース自身が
Factoryの機能を持っている
と書き心地が良くなる

  • シンプルにPHPUnitを使える
  • ResponseHanlderのインスタンスを用意する
  • ServerRequestのインスタンスを用意する
  • $response = $handler->handler($request) のように呼び出す
  • $responseからステータスコード、HTTPヘッダ、ボディなどを取り出して
    assertEquals()などで比較してみる

Page 117

RequestHandler呼び出しイメージ

RequestHandler

Response

ServerRequest

Page 118

RequestHandler呼び出しイメージ

RequestHandler

Page 119

RequestHandler呼び出しイメージ

create
ServerRequest

RequestHandler

Page 120

RequestHandler呼び出しイメージ

create
ServerRequest

RequestHandler

ServerRequest

Page 121

RequestHandler呼び出しイメージ

create
ServerRequest

RequestHandler

Response

ServerRequest

Page 122

RequestHandler呼び出しイメージ

create

assertEquals()

ServerRequest

RequestHandler

Response

ServerRequest

Page 123

テストの準備

(HttpFactoryTrait)

use Nyholm\Psr7\Factory\Psr17Factory;

trait HttpFactoryTrait
{
private function psr17factory(): Psr17Factory
{
return new Psr17Factory();
}

Page 124

テストの準備

(HttpFactoryTrait)

public function getRequestFactory(): RequestFactoryInterface
{
return $this->psr17factory();
}

public function getResponseFactory(): ResponseFactoryInterface
{
return $this->psr17factory();
}

public function getServerRequestFactory(): ServerRequestFactoryInterface
{

Page 125

テストの準備

(HttpFactoryTrait)

/**
* Create a new stream from a string.
*
* The stream SHOULD be created with a temporary resource.
*
* @param string $content String content with which to populate the stream.
*/
public function createStream(string $contents): StreamInterface
{
return $this->getStreamFactory()->createStream($contents);
}

Page 126

HttpFactoryTraitの特徴

  • 特定のPSR-7ライブラリにあえて強く依存している
    • Nyholm Psr17Factoryは状態を持たないのでオブジェクトをキャッシュ してもいいが、そもそも状態をまったく持たない軽量なオブジェクトなので
      必要な都度新しいインスタンスを作ってもボトルネックにならない
    • 正攻法であればsetUpやsetUpBeforeClassで注入する形をとるが、
      dataProviderから依存できるようにあえて割り切っている

Page 127

テストしてみよう

(setup)

class HelloJsonHandlerTest extends \PHPUnit\Framework\TestCase
{
use Helper\HttpFactoryTrait;

private HelloJsonHandler $subject;

public function setUp(): void {
parent::setUp();

$this->subject = new HelloJsonHandler(
$this->getStreamFactory(), $this->getResponseFactory());
}

Page 128

テスト

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$actual = $this->subject->handle($request);

$this->assertSame($expected['status_code'], $actual->getStatusCode());
$this->assertEquals($expected['headers'], $actual->getHeaders()); $this->assertSame($expected['body'], (string)$actual->getBody());
}

Page 129

テスト

(dataProvider)

public function requestProvider(): iterable
{
yield 'GET' => [
$this->createServerRequest('GET', '/dummy'),
[
'status_code' => 200,
'headers' => [
'Content-Type' => ['application/json'],
],
'body' => '{"Hello":"World"}',
],
];

Page 130

テスト

(再掲)

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$actual = $this->subject->handle($request);

$this->assertSame($expected['status_code'], $actual->getStatusCode());
$this->assertEquals($expected['headers'], $actual->getHeaders()); $this->assertSame($expected['body'], (string)$actual->getBody());
}

Page 131

テスト

(dataProviderを追加)

$default_expected = ['status_code' => 404, 'headers' => [], 'body' => ''];

$http_methods = ['POST', 'PUT', 'DELETE'];

foreach ($http_methods as $method) {
yield $method => [
$this->createServerRequest($method, '/dummy'),
$default_expected,
];
}

Page 132

テスト

(dataProviderを追加)

$default_expected = ['status_code' => 404, 'headers' => [], 'body' => ''];

$http_methods = ['POST', 'PUT', 'DELETE'];

foreach ($http_methods as $method) {
yield $method => [
$this->createServerRequest($method, '/dummy'),

GET以外のメソッドは

$default_expected,

すべて結果は同じになる

];
}

Page 133

PHPUnit実行結果

Page 134

RequestHandlerテストの要点

  • テストケースの内容はごく薄くする
  • 「この値を渡したときに必ずこの値を返す」という構造に落とし込む
    • そうなっていないなら依存の分離がうまくいっていない
  • 戻り値となるResponseオブジェクトからは、特に
    getStatusCode(), getHeaders(), getBody()に注目する
    • bodyが巨大な場合は必ずしもassertSame()で比較する必要はない

Page 135

MiddlewareInterface

<?php

namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface MiddlewareInterface
{
public function process(ServerRequestInterface $request,

RequestHandlerInterface $handler): ResponseInterface;
}

Page 136

簡単なMiddlewareを作ってみる

  • Middlewareは結構なんでもできる
    • RequestHandlerに渡す前にServerRequestを加工する
    • RequestHandlerから戻ってきたResponseを加工する
    • RequestHandlerが発生したエラーをキャッチしてハンドリングする

Page 137

Middleware呼び出しイメージ

Middleware

RequestHandler

ServerRequest

Response

Page 138

Middleware呼び出しイメージ

Middleware

RequestHandler

ServerRequest

Response

Page 139

何もしないミドルウェア(ストロー)

class StrawMiddleware implements MiddlewareInterface
{
public function process(

ServerRequest $request, RequestHandler $handler): Response

{
return $handler->handle($request);

}
}

Page 140

全レスポンスに規定のヘッダを付与

class StrawMiddleware implements MiddlewareInterface
{
public function process(

ServerRequest $request, RequestHandler $handler): Response

{
return $handler->handle($request)
->withHeader('X-Content-Type-Options', ['nosniff']);
}
}

Page 141

HTTPSにリダイレクト

class HttpsRedirectMiddleware implements MiddlewareInterface
{
public function process(

ServerRequest $request, RequestHandler $handler): Response

{
$uri = $request->getUri();
if (strtolower($uri->getScheme()) !== 'https') {
return $this->response_factory->createResponse(302)
->withHeader('Location', [(string)$uri->withScheme('https')]);
}
return $handler->handle($request);
}
}

Page 142

例外に対して標準エラーページ

class ErrorPageMiddleware implements MiddlewareInterface
{
public function process(

ServerRequest $request, RequestHandler $handler): Response

{
try {
return $handler->handle;
} catch (HttpException $e) {
return $this->renderHtmlErrorPage($e);
}
}
}

Page 143

RequestHandlerのテスト

(再掲)

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$actual = $this->subject->handle($request);

$this->assertSame($expected['status_code'], $actual->getStatusCode());
$this->assertEquals($expected['headers'], $actual->getHeaders()); $this->assertSame($expected['body'], (string)$actual->getBody());
}

Page 144

ResponseHandlerのテスト

(再掲)

  • シンプルにPHPUnitを使える
  • ResponseHanlderのインスタンスを用意する
  • ServerRequestのインスタンスを用意する
  • $response = $handler->handler($request) のように呼び出す
  • $responseからステータスコード、HTTPヘッダ、ボディなどを取り出して
    assertEquals()などで比較してみる

Page 145

Middlewareのテスト

  • もちろんPHPUnitを使える
  • テスト対象のMiddlewareのインスタンスを用意する
  • ServerRequestとResponseHanlderのインスタンスを用意する
  • $response = $middleware->process($request, $handler)
    のように呼び出す
  • $responseからステータスコード、HTTPヘッダ、ボディなどを取り出して
    assertEquals()などで比較してみる

Page 146

Middleware呼び出しイメージ

Middleware

RequestHandler

ServerRequest

Response

Page 147

Middleware呼び出しイメージ

Middleware

RequestHandler

ServerRequest

Response

Page 148

Middleware呼び出しイメージ

create

Middleware

ServerRequest

RequestHandler

ServerRequest

Response

Page 149

Middleware呼び出しイメージ

create

assertEquals()

Middleware

ServerRequest

RequestHandler

ServerRequest

Response

Page 150

Middleware呼び出しイメージ

create

assertEquals()

Middleware

ServerRequest

RequestHandler

ServerRequest

Response

Page 151

Middlewareのテスト

(?)

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$actual = $this->subject->process($request, $handler);

$this->assertSame($expected['status_code'], $actual->getStatusCode());
$this->assertEquals($expected['headers'], $actual->getHeaders()); $this->assertSame($expected['body'], (string)$actual->getBody());
}

Page 152

Middlewareのテスト

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$actual = $this->subject->process($request, $handler);

$this->assertSame($expected['status_code'], $actual->getStatusCode());
$this->assertEquals($expected['headers'], $actual->getHeaders()); $this->assertSame($expected['body'], (string)$actual->getBody());
}

Page 153

Middlewareのテスト

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$ok = $this->createResponse(200);
$actual =

$this->subject->process($request, new class($ok) implements RequestHandler {

public function __construct(private Response $response) {} public function handle(ServerRequest $request): Response {
return $this->response;
} });

Page 154

Middlewareのテスト

固定のレスポンスを返すだけ

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$ok = $this->createResponse(200);
$actual =

$this->subject->process($request, new class($ok) implements RequestHandler {

public function __construct(private Response $response) {} public function handle(ServerRequest $request): Response {
return $this->response;
} });

Page 155

Middlewareのテスト

固定のレスポンスを返すだけ

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$ok = $this->createResponse(200);
$actual =

$this->subject->process($request, new class($ok) implements RequestHandler {

public function __construct(private Response $response) {} public function handle(ServerRequest $request): Response {
return $this->response;
} });

3行で実装できるが…
まあまあだるい

Page 156

大雑把な処理の流れ

  • どうにかしてServerRequestオブジェクトを用意する
  • 最終的に処理するRequestHandlerオブジェクトを用意する
  • 間に処理するMiddlewareのオブジェクトを用意する
  • Middleware::process($request, $handler)として呼び出すと
    最終的なResponseが帰ってくる
  • エミッタがResponseをどうにか出力する

Page 157

テストの準備

(RawHandler)

class RawHandler implements RequestHandlerInterface {
/**
* @psalm-var list<ServerRequest>
* @psalm-readonly-allow-private-mutation
*/
public $received_server_requests = [];
public function __construct(private Response $response) {}

public function handle(ServerRequest $request): Response {
$this->received_server_requests[] = $request;
return $this->response;
}

Page 158

Middlewareのテスト

(無名クラス)

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$ok = $this->createResponse(200);
$actual =

$this->subject->process($request, new class($ok) implements RequestHandler {

public function __construct(private Response $response) {} public function handle(ServerRequest $request): Response {
return $this->response;
} });

Page 159

Middlewareのテスト

(RawHandler)

/**
* @dataProvider requestProvider
*/
public function test(ServerRequest $request, array $expected): void
{
$ok_handler = new RawHandler($this->createResponse(200));
$actual = );

$this->subject->process($ok_handler

$this->assertSame($expected['status_code'], $actual->getStatusCode());
$this->assertEquals($expected['headers'], $actual->getHeaders()); $this->assertSame($expected['body'], (string)$actual->getBody());
}

Page 160

Middlewareのテスト

(RawHandler)

public function test(ServerRequest $request, array $expected): void
{
$ok_handler = new RawHandler($this->createResponse(200));
$actual = );

$this->subject->process($ok_handler

$this->assertEquals($expected, [
'status_code' => $actual->getStatusCode());
'headers'=> $actual->getHeaders(),
'body' => (string)$actual->getBody(),
'not_redirected_count' => count($ok_handler->received_server_requests),
);
}

Page 161

Middlewareのテスト

(RawHandler)

public function test(ServerRequest $request, array $expected): void
{
$ok_handler = new RawHandler($this->createResponse(200));
$actual = );

$this->subject->process($ok_handler

$ok_handlerが コールされた回数

$this->assertEquals($expected, [
'status_code' => $actual->getStatusCode());
'headers'=> $actual->getHeaders(),
'body' => (string)$actual->getBody(),
'not_redirected_count' => count($ok_handler->received_server_requests),
);
}

Page 162

Middlewareテストの要点

  • 基本はRequestHandlerと同様にシンプルかつ明瞭にする
  • どのようなハンドラを渡せばMiddlewareが正常に動作しているか
    判断できるかを見極める
    • テスト用ハンドラはこだわらなくてもパターン化しやすい
    • リクエストに影響を及ぼすハンドラならリクエストの内容もチェックする
  • 実アプリケーションで呼ばれる他のハンドラに依存すると
    何をテストしたいのかぶれるので基本的には避ける

Page 163

その他のハンドラ

(関数のラッパー)

class CallbackHandler implements RequestHandlerInterface {
/**
* @psalm-var list<ServerRequest>
* @psalm-readonly-allow-private-mutation
*/
public $received_server_requests = [];
public function __construct(private Closure $callback) {}

public function handle(ServerRequest $request): Response {
$this->received_server_requests[] = $request;
return ($this->callback)($request);
}

Page 164

その他のハンドラ

(例外を投げる君)

class ThrowingHandler implements RequestHandlerInterface {
/**
* @psalm-var list<ServerRequest>
* @psalm-readonly-allow-private-mutation
*/
Public $received_server_requests = [];
public function __construct(private Throwable $e) {}

public function handle(ServerRequest $request): Response {
$this->received_server_requests[] = $request;
throw $this->e;
}

Page 165

実アプリケーション
への適用を考える

Page 166

大雑把な処理の流れ

  • どうにかしてServerRequestオブジェクトを用意する
  • 最終的に処理するRequestHandlerオブジェクトを用意する
  • 間に処理するMiddlewareのオブジェクトを用意する
  • $middleware->process($request, $handler)として呼び出すと
    最終的なResponseが帰ってくる
  • エミッタがResponseをどうにか出力する

Page 167

nyholm/

大雑把な処理の流れ

psr7-server

  • どうにかしてServerRequestオブジェクトを用意する
  • 最終的に処理するRequestHandlerオブジェクトを用意する
  • 間に処理するMiddlewareのオブジェクトを用意する
  • $middleware->process($request, $handler)として呼び出すと
    最終的なResponseが帰ってくる
  • エミッタがResponseをどうにか出力する

Page 168

nyholm/

大雑把な処理の流れ

psr7-server

ちくちく定義する

  • どうにかしてServerRequestオブジェクトを用意する
  • 最終的に処理するRequestHandlerオブジェクトを用意する
  • 間に処理するMiddlewareのオブジェクトを用意する
  • $middleware->process($request, $handler)として呼び出すと
    最終的なResponseが帰ってくる
  • エミッタがResponseをどうにか出力する

Page 169

nyholm/

大雑把な処理の流れ

psr7-server

ちくちく定義する

  • どうにかしてServerRequestオブジェクトを用意する
  • 最終的に処理するRequestHandlerオブジェクトを用意する
  • 間に処理するMiddlewareのオブジェクトを用意する
  • $middleware->process($request, $handler)として呼び出すと
    最終的なResponseが帰ってくる
  • エミッタがResponseをどうにか出力する

Laminasのエミッタを使う

Page 170

nyholm/

大雑把な処理の流れ

psr7-server

ちくちく定義する

  • どうにかしてServerRequestオブジェクトを用意する
  • 最終的に処理するRequestHandlerオブジェクトを用意する
  • 間に処理するMiddlewareのオブジェクトを用意する
  • $middleware->process($request, $handler)として呼び出すと
    最終的なResponseが帰ってくる
  • この$middleware何者
    エミッタがResponseをどうにか出力する

Laminasのエミッタを使う

Page 171

create

assertEquals()

Middleware

ServerRequest

RequestHandler

Page 172

これまでは
Middleware1個
Handler1個
しか考えてない

Page 173

いままでのミドルウェア実行イメージ

Middleware

RequestHandler

Page 174

せっかくの既存
ミドルウェアたくさん
重ねたくないですか

Page 175

いままでのミドルウェア実行イメージ

Middleware

RequestHandler

Page 176

ミドルウェアを重ねたい

Middlewares

RequestHandler

Page 177

複数のミドルウェアを扱う

  • MiddlewareはServerRequestとRequestHandlerを受け取り、
    $request_handler->handle($request)のように呼び出す
    • このままではミドルウェア1個ハンドラ1個しか受け付けない…?
  • 「次のMiddleware」をラップしたRequestHandlerを用意する
    • PSR-15のメタドキュメントでは「キュー」と「デコレータ」の2種類が紹介
  • 特に理由がなければRelayというやつを選ぶといい感じ
    • 自作しても全然よい

Page 178

セッションってどうするの?

  • SessionMiddleware的なものを作る
    • RequestのCookieなどのセッションIDからDBなどを引く
    • session_start()や$_SESSIONのようなグローバルを参照する部分は
      さらに別のクラスに分けるとテストしやすくできる
  • $request->withAttribute()で付加することで後続のMiddlewareや
    RequestHandlerで $request->getAttribute()で取り出せる

Page 179

セッションを参照する

前略

//

public function handle(ServerRequestInterface $request): ResponseInterface {
$session = $request->getAttribute(Session::class);
assert($session instanceof Session);

を使った処理を書く

// $session

if ($session instanceof LoggedIn) {
// ...
}
}

Page 180

どうやってユーザーに
コードを書かせるか

Page 181

「心に棚を作れ」

(by 炎の転校生)

  • 独自FWを作るとき、全体的な枠組みを作るのもアプリ実装するのも自分
    • 「この変な設計したやつは誰だ」「俺だ」
    • 「規約を守れず腑抜けたアプリケーションを書くのは誰だ」「俺だ」
  • 「それはそれ、これはこれ」 by 逆境ナイン
    • 微妙におかしな設計をする自分と設計意図通りにアプリケーションを
      実装できない自分がいる
    • 妥協せずにバトルさせたあとに使いやすいフレームワークができる(気がする)

Page 182

ここまでできれば
外の枠組みは
結構できてきた

Page 183

アプリケーションって
どこにあるの?

Page 184

いわゆるコントローラやアクション

  • URLごとに対応して起動される関数やメソッドをどうする?
  • 基本はRequestHandlerから、どうにか関数決めて起動してやればいい
  • RequestHandlerはLaravelのSingleActionControllerと
    概念的に近いものだと考えることもできる
    • ただし引数や戻り値のインターフェイスが厳密
  • PHP-DIのようなライブラリを組み合せることでインスタンス生成や
    関数呼び出しを簡略化することもできる

Page 185

phperkaigi-golf

  • https://github.com/phppg/phperkaigi-golf
    • PHPerKaigi 2020のときに実装したWebアプリケーション
    • PSR-7/PSR-15ベース / Relayを使用
    • PHP-DIを使用
      • ルーティングに無名関数(クロージャ)を登録して実行する
        マイクロフレームワーク風の実装

Page 186

Index.php

のルーティング定義(抜萃)

Page 187

Index.php

のルーティング定義(抜萃)

Page 188

Index.php

のミドルウェア定義(1/2)

Page 189

Index.php

のミドルウェア定義(2/2)

Page 190

ピクシブ百科事典

  • http://dic.pixiv.net/
    • 2009年から運営されているWebサービス
    • PSR-7/PSR-15ベース / Relay / PHP-DIを使用
      • もともとCakePHP風のController/Modelの独自フレームワーク
      • 現在はPSR-15でコントローラを書けるようにしている
  • 全体的な雰囲気は「百科事典 インターン PSR-7」とかで
    Google検索してもらうと概観がわかってもらえると思います

Page 191

Cookieを扱う難しさ

  • withCookieParams()などは$_COOKIE相当のデータを付加するが、 内部で保持するHTTPヘッダを書き換えてはならない(MUST NOT)と
    仕様で明言されている
  • プレーンなPHPではsetcookie()すればよかったが、
    PSR-7ベースではプリミティブすぎて、 setHeaderで直接書かないといけない

Page 192

PSR-7がプリミティブすぎる件

  • 外部からの入力を型安全に受け取るには工夫が必要
    • (cid:224)lter_var()とか使うのが一番安全
    • 最近ではPSLというライブラリもいい感じ
    • ParamHelperにPSR-7とValueObject の力を授けた話
  • マルチパートってどうやって扱えばいいの…?
    • いいライブラリあれば誰か教えてください

Page 193

ParamHelperにPSR-7とValueObject の力を授けた話

Page 194

今回の発表の参照実装 とか便利なライブラリ とかそのうちいろいろ
出します!!!!

Page 195

俺たちのPSR-15道は
これからだ!
(ご静聴ありがとうございました。 tadsanの次回作にご期待ください)