こんにちは、VTuberとPHP をこよなく愛しているふじしゃんです。
去年の7月からpixiv運営本部 Webエンジニアリングチームでアルバイトをしています。
今回は、pixivのParamHelper
にPSR-7とValueObjectの力を授けたRequestParamFilter
をピクシブ百科事典に実装した話を書いていきます。
ParamHelper について
これまでピクシブ百科事典には、リクエストパラメータやリクエストボディを厳密に検証する仕組みがありませんでした。
Webアプリケーションにとって入力値検証は非常に重要なことです。
pixivでは、受け取った値を安全に扱うためにParamHelper
という機能を実装し、必ず検証するようにしています。
以下のように書くことで値を検証しPHPStanで型付けを行うことができます。
<?php $page = ParamHelper::get('page') ->orDefault(1) ->asPositiveInt(); \PHPStan\dumpType($page) // false|int<1,max>
詳しくは以下の記事を参照してください。
この便利な ParamHelper
に PSR-7 との統合と ValueObject をマッピングする機能を追加したものをピクシブ百科事典に実装しました。
PSR-7ってなーに?
PSR-7はPHP-FIGというグループが定めたHTTPのリクエストとレスポンスに関する標準仕様のことです。
PHPのWebフレームワークである、LaravelやCakePHPでもPSR-7の仕様を用いてリクエストとレスポンスを記述できるようになっています。
仕様として定義されているのはインターフェイスのみで、それ実装した様々なパッケージが存在しています。特徴としては、基本的にどのメソッドも新しいオブジェクトを返すためイミュータブルになっていることです。
PSRといえば、最近巷で話題のPERs(PHP Evolving Recommendations)を新たに導入する提案が先日可決されました。
いままで、既存のPSRを改定するときには PSR-2 ⇒ PSR-12 のように、新しくPSRを策定して古い PSR を廃止するフローでしたが、PERの導入によって、ワーキンググループを通して継続的にメンテナンスしていけるようになりました。
2022年3月時点ではまだ具体的な動きはありませんが、提案によれば以下のような粒度でのPERが想定されています。
- PER-CodingStandards (uses PSR-12 as a basis, updating it for each new PHP release.)
- PER-Cache Utils (maintains the cache-util and simplecache-util libraries.)
- PER-HTTP (maintains the various PSR-7/15/17/18 util libraries.)
- PER-DocBlocks (would replace PSR-19, the tag library, but NOT PSR-5, the parsing rules, which still need to get finished.)
まさに、進化する勧告ですね。
PSR-7とParamHelper の統合
さて、本題です。
ピクシブ百科事典では ParamHelper
にまず PSR-7 の力を与えました。これを百科事典の実装では RequestParamFilter
と呼んでいます。
<?php class RequestParamFilter { /** @var ServerRequestInterface */ protected $request; public function __construct(ServerRequestInterface $request) { $this->request = $request; } // ... }
RequestParamFilter
のインスタンスを作る際には、ServerRequestInterface
に縛られたリクエストをコンストラクタで受け取るようにしています。
従来の ParamHelper
では、$_GET
や $_POST
などのスーパーグローバル変数を用いてリクエストから値を取り出していました。
ピクシブ百科事典では PSR-7 と PSR-15 に依存した設計になっているためこのような形で実装しています。
<?php $request = $this->getServerRequestFactory() ->createServerRequest('GET', '/dummy') ->withQueryParams(['return_to' => 'https://pixiv.net/']); $param = new RequestParamFilter($request); // return_to に値が渡されていなければ NotFoundException が投げられる $return_to = $param->get('return_to')->asNonEmptyString(); var_dump($return_to); // string(18) "https://pixiv.net/"
また、ピクシブ百科事典には /a/記事名
のような動的URLが存在するため、これについても取り扱えるようにする必要があります。
RequestParamFilter
では以下のように書くことで、ルーターで処理されたURLに含まれる動的なパラメータについても安全に値を検証し取得することができます。
<?php // https://dic.pixiv.net/a/pixiv : OK // https://dic.pixiv.net/a/ : NotFoundException $article_name = $param->urlparam('article_name')->asNonEmptyString(); var_dump($article_name); // string(5) "pixiv"
ValueObject へのマッピング
ParamHelper
が次に得た力は ValueObject へのマッピングです。
ValueObject は、DDD(ドメイン駆動設計)においてもドメインモデルの重要な分類として言及されている考え方です。
特定の概念を表したクラスのことでUserId
やArticleId
などが例に挙げられます。IDを表す単なるint型の値に代えてValueObjectを導入することで、特定の概念固有の条件を隠蔽することができ、可読性が向上したり値の取り違えを防げます。
ピクシブ百科事典では ValueObject が内包する値のバリデーションを含めて、以下のようなValueInterface
を定義しました。
<?php interface ValueInterface { /** * 与えられた値を検証する * * @param mixed $value */ public static function isValid($value): bool; }
ValueInterface
では静的メソッドとして値のバリデーションメソッドの実装を要件としています。
たとえば、ArticleId
が内包する数値は「0以上の正整数」である場合、以下のような実装になります。
<?php trait PositiveIntId { /** * 値を int にキャストして返す * * @phpstan-return positive-int */ public function toInt(): int { return (int)$this->value; } /** * 与えられた値を検証する * * @param mixed $value */ public static function isValid($value): bool { return Validate::isPositiveInt($value); } }
<?php class ArticleId implements ValueInterface { use PositiveIntId; /** @var positive-int */ private $value; public function __construct($value) { assert($this->isValid($value)); $this->value = $value; } }
このValueObjectをRequestParamFilter
を通してマッピングできるようにしていきます。RequestParamFilter::asValueObject()
の実装は以下の通りです。
<?php class RequestParamFilter { /** * @template T of ValueInterface * @param class-string<T> $class * @return T|ValueInterface * @phpstan-return T * @throws NotFoundException */ public function asValueObject(string $class): ValueInterface { assert(is_a($class, ValueInterface::class, true)); // RequestParamFilter に値が存在せず、デフォルト値が存在する場合 if ($this->_value === null && $this->_default_value !== false) { return new $class($this->_default_value); } $is_valid = $class::isValid($this->_value); if (!$is_valid) { throw new NotFoundException(); } return new $class($this->_value); } }
RequestParamFilter::asValueObject()
を使うと、以下のような形でValueObjectをマッピングできます。
<?php $request = $this->getServerRequestFactory() ->createServerRequest('POST', '/dummy') ->withParsedBody(['article_id' => 1]); $param = new RequestParamFilter($request); // ArticleId のバリデーションと ValueObject へのマッピングが行われる $article_id = $param->post('article_id')->asValueObject(ArticleId::class); \PHPStan\dumpType($article_id); // ArticleId var_dump($article_id->toInt()); // int(1)
受け取った値をRequestParamFilter
の中でValueObjectのインスタンスにマッピングすることで、このあと行う処理で値を取り違えることがなくなりますし、毎回複雑で長い条件を書く必要もなくなります。
また、RequestParamFilter::asValueObject()
は型パラメータ(ジェネリクス)により、ValueInterface
ではなくパラメータで渡したクラス名に応じた具象クラスに型付けされています。
さいごに
ParamHelper
にPSR-7とValueObjectという力を与えることで、とても便利で画期的な #RequestParamFilter
がピクシブ百科事典にやってきました。
ピクシブ百科事典は2009年にサービスを開始してから今年で13年目となります🎉
レガシーコードも多く、まだまだ対応が必要な箇所は山積みですが一つ一つ紐解きモダンなコードへ改善を進めていきます。
また、ピクシブは2022年4月9日から11日に開催されるPHPerKaigi 2022に協賛しています。
ピクシブ百科事典を開発しているtadsanも「PSR-7とPSR-15によるWebアプリケーション実装パターン」として、RequestParamFilterにも言及するようです。
また、tadsanがPHPerKaigiの頃までに汎用化したバージョンをOSSとして公開する予定があるようです。ご期待ください…?