entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

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

こんにちは、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>

詳しくは以下の記事を参照してください。

inside.pixiv.blog

この便利な ParamHelper に PSR-7 との統合と ValueObject をマッピングする機能を追加したものをピクシブ百科事典に実装しました。

PSR-7ってなーに?

PSR-7はPHP-FIGというグループが定めたHTTPのリクエストとレスポンスに関する標準仕様のことです。

www.php-fig.org

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

groups.google.com

まさに、進化する勧告ですね。

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(ドメイン駆動設計)においてもドメインモデルの重要な分類として言及されている考え方です。

特定の概念を表したクラスのことでUserIdArticleIdなどが例に挙げられます。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に協賛しています。

phperkaigi.jp

ピクシブ百科事典を開発しているtadsanも「PSR-7とPSR-15によるWebアプリケーション実装パターン」として、RequestParamFilterにも言及するようです。

fortee.jp

また、tadsanがPHPerKaigiの頃までに汎用化したバージョンをOSSとして公開する予定があるようです。ご期待ください…?

icon
ふじしゃん
Webエンジニアリングチームでアルバイトとして働いています。古のコードをいい感じにリファクタリングすることとブロックチェーンが好きです。