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
公開日:
by USAMI Kenta @tadsan
に東京都の練馬区立区民・産業プラザ Coconeriホール およびニコニコ生放送で開催された『PHPerKaigi 2022』でライトニングトーク(5分)として発表しました。
PSR-7とPSR-15によるWebアプリケーション実装パターン
Web application implementation pattern based on PSR-7 and15
pixiv Inc.
USAMI Kenta
2022-04-11
PHPerKaigi Day 2
お前誰よ
tadsanのあれこれが読める場所
前回のPHPerKaigi
去年のPHPカンファレンス
一昨年のPHPカンファレンス
Software Design 11月号
WEB+DB PRESS総集編
WEB+DB PRESS Vol.96
(2016年)
WEB+DB PRESS Vol.96
(2017年)
仕事の話
Powered by PSR-15
PIXIV SPRING BOOT CAMP
たぶん近日中に夏インターンも募集
それそろフレームワークとしての
改善ポイントが
突っ込みどころ
枯渇しつつあるので
参加したい学生はお早めに
今回のお題
プロポーザル
日本語のPSR-HTTP関連資料集
Webフレームワークを 作りたい人や内部構造
を理解したい人向け
前半はフレームワーク
一般 概念 話
の の もします
後半もPSR-15入門 とテスト方法みたいな
話に終始します
最後のスライド
(予告)
俺たちのPSR-15道は
これからだ!
(ご静聴ありがとうございました。 tadsanの次回作にご期待ください)
さて
誰がPSR-15を使うのか
PSRとは
PHP-FIG
PSRs
PSR
(PHP Standard Recommendation)
よく知られたPSR
PSR interfaces
インターフェイス 概要
(interface)
余談:PSRへの誤解
PSR vs HTTP
PSRのコンセプトについて最重要資料
PSR-7のおさらい
PSR-7 HTTP Messages
不変性
(immutability)
PSR-7 Packages
https://packagist.org/providers/psr/http-message-implementation
ServerRequestInterface
ServerRequest::withAttribute()
ResponseInterface
PSR-17 HTTP Factories
\ \
$res = $response_factory->createResponse()でオブジェクトを
生成できる
不変性
(immutability)
Nyholm Psr17Factory
Nyholm/psr7のREADMEより
PSR-7とフレームワークの関係
PHPの実行環境
従来型のPHP実行環境
フレームワークを使わないPHP
フレームワークを使わない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>
Good old PHP
/var/www/example.com/public/index.php
なんかいろんな関数を
呼んだりファイルをincludeしたりする
$_SERVER
Good old PHP
/var/www/example.com/public/index.php
$_SERVER
Good old PHP
/var/www/example.com/public/index.php
header()
$_SERVER
Good old PHP
/var/www/example.com/public/index.php
header()
ob_start()
$_SERVER
Good old PHP
/var/www/example.com/public/index.php
header()
ob_start()
echo
$_SERVER
Good old PHP
/var/www/example.com/public/index.php
echo
header()
ob_start()
echo
$_SERVER
Good old PHP
/var/www/example.com/public/index.php
echo
set_cookie()
header()
ob_start()
echo
$_SERVER
Good old PHP
/var/www/example.com/public/index.php
echo
set_cookie()
ob_end_flush()
header()
ob_start()
echo
$_SERVER
よくある従来のPHPスクリプト
(CGI型)
こういう世界と
どうやって整合性を
合わせるのか
PSR-7の出力
(emit)
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();
}
PSR-7の入力
(ServerRequestの錬成)
Good old PHP
/var/www/example.com/public/index.php
なんかいろんな関数を
呼んだりファイルをincludeしたりする
$_SERVER
従来型環境でPSR-7を使う概念
ServerRequest (fromGlobals)
/var/www/example.com/public/index.php
$_SERVER
従来型環境でPSR-7を使う概念
ServerRequest (fromGlobals)
/var/www/example.com/public/index.php
$_SERVER
なんかいろんな関数を
呼んだりしてResponse作る
従来型環境でPSR-7を使う概念
ServerRequest
SapiEmitter
(fromGlobals)
/var/www/example.com/public/index.php
$_SERVER
なんかいろんな関数を
呼んだりしてResponse作る
従来型環境でPSR-7を使う概念
ServerRequest
SapiEmitter
(fromGlobals)
/var/www/example.com/public/index.php
echo
$_SERVER
header
なんかいろんな関数を
呼んだりしてResponse作る
フレームワークの光と影
フレームワークの光と影
フレームワークの光と影
フレームワークの光と影
フレームワークの光と影
縛ることによって得られるもの
Long-living PHPとの互換性
CyberAgent白井さんの発表
Long-living PHPの世界
従来型環境でPSR-7を使う概念
ServerRequest (fromGlobals)
/var/www/example.com/public/index.php
$_SERVER
従来型環境でPSR-7を使う概念
ServerRequest (fromGlobals)
/var/www/example.com/public/index.php
$_SERVER
なんかいろんな関数を
呼んだりしてResponse作る
従来型環境でPSR-7を使う概念
ServerRequest
SapiEmitter
(fromGlobals)
/var/www/example.com/public/index.php
$_SERVER
なんかいろんな関数を
呼んだりしてResponse作る
従来型環境でPSR-7を使う概念
ServerRequest
SapiEmitter
(fromGlobals)
/var/www/example.com/public/index.php
echo
$_SERVER
header
なんかいろんな関数を
呼んだりしてResponse作る
RoadRunner
RoadRunner
RoadRunner
RoadRunner
RoadRunner
RoadRunner
RoadRunner
RoadRunner
なんかいろんな関数を
呼んだりしてResponse作る
RoadRunner
ServerRequest
なんかいろんな関数を
呼んだりしてResponse作る
RoadRunner
Response
ServerRequest
なんかいろんな関数を
呼んだりしてResponse作る
RoadRunner
Response
ServerRequest
なんかいろんな関数を
呼んだりしてResponse作る
RoadRunner
Response
ServerRequest
なんかいろんな関数を
呼んだりしてResponse作る
従来型環境
ServerRequest
SapiEmitter
(fromGlobals)
/var/www/example.com/public/index.php
echo
$_SERVER
header
なんかいろんな関数を
呼んだりしてResponse作る
RoadRunner
Response
ServerRequest
なんかいろんな関数を
呼んだりしてResponse作る
入出力を端に揃えると交換可能に
入出力を端に揃えると交換可能に
両端の構造が合っていれば
従来型とも共通化しやすそう
外の世界の事情を無視できる
外の世界の事情を無視できる
どこで動いてようが あまり関係なくなる
ユニットテストで実行もできる
ユニットテストで実行もできる
値を渡して
戻り値を検査する
もうちょっと
具体的に
PSR-15
PSR-15 HTTP Handlers
「ミドルウェア」という語について
ほかの世界のミドルウェア
RequestHandlerInterface
<?php
namespace Psr\Http\Server;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface;
}
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;
}
PSR Middlewares
簡単なRequestHandlerを作ってみる
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);
}
}
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);
}
RequestHandler呼び出しイメージ
RequestHandler
ServerRequest
RequestHandler呼び出しイメージ
RequestHandler
Response
ServerRequest
テストしてみよう
テストケース自身が
Factoryの機能を持っている
と書き心地が良くなる
RequestHandler呼び出しイメージ
RequestHandler
Response
ServerRequest
RequestHandler呼び出しイメージ
RequestHandler
RequestHandler呼び出しイメージ
create
ServerRequest
RequestHandler
RequestHandler呼び出しイメージ
create
ServerRequest
RequestHandler
ServerRequest
RequestHandler呼び出しイメージ
create
ServerRequest
RequestHandler
Response
ServerRequest
RequestHandler呼び出しイメージ
create
assertEquals()
ServerRequest
RequestHandler
Response
ServerRequest
テストの準備
(HttpFactoryTrait)
use Nyholm\Psr7\Factory\Psr17Factory;
trait HttpFactoryTrait
{
private function psr17factory(): Psr17Factory
{
return new Psr17Factory();
}
テストの準備
(HttpFactoryTrait)
public function getRequestFactory(): RequestFactoryInterface
{
return $this->psr17factory();
}
public function getResponseFactory(): ResponseFactoryInterface
{
return $this->psr17factory();
}
public function getServerRequestFactory(): ServerRequestFactoryInterface
{
テストの準備
(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);
}
HttpFactoryTraitの特徴
テストしてみよう
(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());
}
テスト
/**
* @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());
}
テスト
(dataProvider)
public function requestProvider(): iterable
{
yield 'GET' => [
$this->createServerRequest('GET', '/dummy'),
[
'status_code' => 200,
'headers' => [
'Content-Type' => ['application/json'],
],
'body' => '{"Hello":"World"}',
],
];
テスト
(再掲)
/**
* @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());
}
テスト
(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,
];
}
テスト
(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,
すべて結果は同じになる
];
}
PHPUnit実行結果
RequestHandlerテストの要点
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;
}
簡単なMiddlewareを作ってみる
Middleware呼び出しイメージ
Middleware
RequestHandler
ServerRequest
Response
Middleware呼び出しイメージ
Middleware
RequestHandler
ServerRequest
Response
何もしないミドルウェア(ストロー)
class StrawMiddleware implements MiddlewareInterface
{
public function process(
ServerRequest $request, RequestHandler $handler): Response
{
return $handler->handle($request);
}
}
全レスポンスに規定のヘッダを付与
class StrawMiddleware implements MiddlewareInterface
{
public function process(
ServerRequest $request, RequestHandler $handler): Response
{
return $handler->handle($request)
->withHeader('X-Content-Type-Options', ['nosniff']);
}
}
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);
}
}
例外に対して標準エラーページ
class ErrorPageMiddleware implements MiddlewareInterface
{
public function process(
ServerRequest $request, RequestHandler $handler): Response
{
try {
return $handler->handle;
} catch (HttpException $e) {
return $this->renderHtmlErrorPage($e);
}
}
}
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());
}
ResponseHandlerのテスト
(再掲)
Middlewareのテスト
Middleware呼び出しイメージ
Middleware
RequestHandler
ServerRequest
Response
Middleware呼び出しイメージ
Middleware
RequestHandler
ServerRequest
Response
Middleware呼び出しイメージ
create
Middleware
ServerRequest
RequestHandler
ServerRequest
Response
Middleware呼び出しイメージ
create
assertEquals()
Middleware
ServerRequest
RequestHandler
ServerRequest
Response
Middleware呼び出しイメージ
create
assertEquals()
Middleware
ServerRequest
RequestHandler
ServerRequest
Response
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());
}
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());
}
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;
} });
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;
} });
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行で実装できるが…
まあまあだるい
大雑把な処理の流れ
テストの準備
(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;
}
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;
} });
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());
}
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),
);
}
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),
);
}
Middlewareテストの要点
その他のハンドラ
(関数のラッパー)
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);
}
その他のハンドラ
(例外を投げる君)
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;
}
実アプリケーション
への適用を考える
大雑把な処理の流れ
nyholm/
大雑把な処理の流れ
psr7-server
nyholm/
大雑把な処理の流れ
psr7-server
ちくちく定義する
nyholm/
大雑把な処理の流れ
psr7-server
ちくちく定義する
Laminasのエミッタを使う
nyholm/
大雑把な処理の流れ
psr7-server
ちくちく定義する
Laminasのエミッタを使う
create
assertEquals()
Middleware
ServerRequest
RequestHandler
これまでは
Middleware1個
Handler1個
しか考えてない
いままでのミドルウェア実行イメージ
Middleware
RequestHandler
せっかくの既存
ミドルウェアたくさん
重ねたくないですか
いままでのミドルウェア実行イメージ
Middleware
RequestHandler
ミドルウェアを重ねたい
Middlewares
RequestHandler
複数のミドルウェアを扱う
セッションってどうするの?
セッションを参照する
前略
//
public function handle(ServerRequestInterface $request): ResponseInterface {
$session = $request->getAttribute(Session::class);
assert($session instanceof Session);
を使った処理を書く
// $session
if ($session instanceof LoggedIn) {
// ...
}
}
どうやってユーザーに
コードを書かせるか
「心に棚を作れ」
(by 炎の転校生)
ここまでできれば
外の枠組みは
結構できてきた
アプリケーションって
どこにあるの?
いわゆるコントローラやアクション
phperkaigi-golf
Index.php
のルーティング定義(抜萃)
Index.php
のルーティング定義(抜萃)
Index.php
のミドルウェア定義(1/2)
Index.php
のミドルウェア定義(2/2)
ピクシブ百科事典
Cookieを扱う難しさ
PSR-7がプリミティブすぎる件
ParamHelperにPSR-7とValueObject の力を授けた話
今回の発表の参照実装 とか便利なライブラリ とかそのうちいろいろ
出します!!!!
俺たちのPSR-15道は
これからだ!
(ご静聴ありがとうございました。 tadsanの次回作にご期待ください)