49
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PHPAdvent Calendar 2022

Day 1

Attributesで実現するPHP8時代のバリデータ

Last updated at Posted at 2022-11-30

メリークリスマス! みなさまに愛されたPHP7.x系は2022年11月28日をもってEOLを迎えました :tada:

さて、標記のAttribute(アトリビュート)とは、PHP 8.0で追加された機能です。

#[ほにゃらら(なんか: "書ける")]
class クラス{
    #[ほにゃらら(ここにも: "書ける")]
    public string $string;
}

PHP 7.4の命運が尽きたということは、全人類のPHPランタイム環境でもれなくアトリビュートが使えるようになったに違いありません[要出典]。PHP 8.0がリリースされてからも既に2年が経過していますが、Attributeに関してはみなさんあまり納得できてなさそうというか、どうやって実用すればいいのか持てあまされてるように見えるので、この記事では簡単な実装方法を紹介します。

注意
この記事は特定のライブラリの宣伝をするものではありません。
キミだけの最強のバリデータを実装してライバルに差をつけろ!

アトリビュートは大雑把にいうと、従来のPHPではPHPDocで書かれていたものを代替しようとするものです。

/**
 * @ほにゃらら(なんか: "書ける")
 */
class クラス{
    /** @ほにゃらら(ここにも: "書ける") */
    public string $string;
}

このようなシチュエーションで // コメント/* コメント */ ではなく /** ... */ のように必ず /** になるのは、きちんとした意味があります。それはReflectionClass::getDocComment()ReflectionProperty::getDocComment()などのメソッドで実行時に取得可能な形式であるということです。

アトリビュートは外側が #[ ... ] のような形式で、内側が ほにゃらら ほにゃらら("文字列") ほにゃらら(name: "arg") のような、ひとめ見ると関数呼び出しのような書式で記述されます。

アトリビュートはデコレータに相当すると説明されることもありますが、他人の空似であって、まったく別の機能です。

Attributesを設計してみる

こんな感じで使えるようにしてみましょう。

class Address
{
    public function __construct(
        #[Length(min: 1, max: 100)]
        public string $city,
        #[RegExp(pattern: '/\A\d{3}-\d{4}\z/')]
        public string $postal,
    ) {
        validate_properties($this);
    }
}

上記のコードはコンストラクタプロパティプロモーションというPHP 8.0の機能を使っています。

コンストラクタプロモーションを使わないで書く場合
class Address
{
    #[Length(min: 1, max: 100)]
    public string $city;

    #[RegExp(pattern: '/\A\d{3}-\d{4}\z/')]
    public string $postal;

    public function __construct(string $city, string $postal)
    {
        $this->city = $city;
        $this->postal = $postal;

        validate_properties($this);
    }
}

この記法に馴染みがない場合は上記の折り畳みを展開して見比べてみてください。

さて、Length RegExp が今回定義するアトリビュートです。プロパティの型はpublic string $prop;のように宣言しているので必ず string として初期化されることが保障されていますが、アトリビュートによってそれ以上に厳格な制約があることを表現しようというのです。

繰り返しますが、アトリビュートはデコレータではありません。アトリビュートだけで勝手に実行される処理をはさみこんだりする能力はないのです。なので、プロパティの値がバリデータアトリビュートの宣言通りの文脈に沿っているかは明示的に処理しなければなりません。

そのため、このコードでは validate_properties($this); として処理しています。この関数の実装は次の節で示します。

Validatorインターフェイスの宣言

今回は設計が雑なので ensure() メソッドだけを持つインターフェイスを考えてみましょう。

interface Validator
{
    public function ensure(string $name, mixed $input): void;
}

戻り値が : void であるということは、このメソッド自体は有効な値を返さず、問題なければ正常終了し、問題があれば例外を送出するメソッドであることを示唆しています。

では、さっそくアトリビュートをひとつ実装してみましょう。

アトリビュートの定義の仕方はPHPマニュアルに書いてあります。

プロパティの文字列が#[RegExp(pattern: '/\A\d{3}-\d{4}\z/')]のように記述された正規表現のパターンにマッチするかを保障するアトリビュートはこのように定義できます。

RegExp.php
#[Attribute(Attribute::TARGET_PROPERTY)]
class RegExp implements Validator
{
    public function __construct(public string $pattern) {}

    public function ensure(string $name, mixed $input): void
    {
        if (!is_string($input)) {
            throw new TypeError();
        }

        if (!preg_match($this->pattern, $input)) {
            throw new DomainException("{$name} がパターンにマッチしません");
        }
    }
}
コード解説 (折り畳み)
  1. クラスの上で#[Attribute(Attribute::TARGET_PROPERTY)]と記述することでプロパティ対象のアトリビュートであると明確にします
  2. function __construct(public string $pattern) {} コンストラクタプロモーションで、プロパティ宣言と代入を同時にします
  3. 「プロパティの値が文字列でなかったら」「正規表現にマッチしなかったら」 それぞれのパターンで例外を送出します

文字列が期待通りの長さかどうかをチェックするための Length バリデータも定義しましょう。

Length.php
#[Attribute(Attribute::TARGET_PROPERTY)]
class Length implements Validator
{
    /**
     * @param ?non-negative-int $min
     * @param ?non-negative-int $max
     */
    public function __construct(
        public ?int $min,
        public ?int $max,
    ) {
    }

    public function ensure(string $name, mixed $input): void
    {
        if (!is_string($input)) {
            throw new TypeError();
        }

        $length = mb_strlen($input, 'UTF-8');

        if (isset($this->min) && $this->min > $length) {
            throw new DomainException("{$name} (length={$length}) は {$this->min} 以上である必要があります");
        }

        if (isset($this->max) && $this->max < $length) {
            throw new DomainException("{$name} (length={$length}) は {$this->max} 以下である必要があります");
        }
    }
}

やってることはさきほどのコードを踏まえれば自明なので解説は省略します。

あらためて、このバリデータを使ってプロパティを検証する実装クラスはこのようなコードになります。

class Address
{
    public function __construct(
        #[Length(min: 1, max: 100)]
        public string $city,
        #[RegExp(pattern: '/\A\d{3}-\d{4}\z/')]
        public string $postal,
    ) {
        validate_properties($this);
    }
}

validate_properties()関数ではバリデータをインスタンス化してValidator::ensure()メソッドを呼び出せれば勝ちです。

そんなわけで実装してみましょう。

validate_properties
function validate_properties(object $subject): void
{
    $ref = new ReflectionClass($subject);

    foreach ($ref->getProperties() as $prop) {
        $name = $subject::class . '::$' . $prop->getName();
        $value = $prop->getValue($subject);
        foreach ($prop->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) as $attr) {
            $attr->newInstance()->ensure($name, $value);
        }
    }
}
  1. 検証したいオブジェクト$thisを受け取り、ReflectionClass を取得
  2. foreach ($ref->getProperties() as $prop) でクラスに属する全プロパティを列挙
  3. バリデーション違反だったときのメッセージに含めやすいよう、$nameを組み立て
  4. $prop->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) でバリデータアトリビュートを全列挙
    • if ($attr instanceof Validator) でマッチしたアトリビュートだけが返されると考えてください
  5. $attrReflectionAttributeクラスなので、ReflectionAttribute::newInstance()で実体化する
    • #[Length(min: 1, max: 100)]と書かれたコードが、ここで new Length(min: 1, max: 100)として処理されると考えてください
  6. newInstance() から、そのまま ->ensure($name, $value) でプロパティ値を検証
    • 問題があるときだけ例外が送出されるので、正常ケースはそのままスルーされる
  7. エラーなくすべてのアトリビュートが呼び出されたらプロパティがすべて正常だと担保される
  8. 完了!

テストしてみよう

ちゃんとしたライブラリとして後悔するならきちんとユニットテストするべきだが、僕はふまじめな人間なのでこのようなスクリプトを書きます。

function test(Closure ...$tests): void
{
    $result = [];
    foreach ($tests as $test) {
        try {
            $result[] = $test();
        } catch (\Throwable $e) {
            $result[] = $e->getMessage();
        }
    }

    var_dump($result);
}

test(
    fn() => new Address('', '151-0053'),
    fn() => new Address('渋谷区', ''),
    fn() => new Address('渋谷区', '151-0053'),
);

fn() => はもちろんアロー関数なのですが、このような使いかたをサンク(thunk)といいます。なんでこんな変な書きかた(遅延評価)をするのかというと、new Address('', '151-0053')はもちろんバリデータに弾かれて例外が送出される爆弾だからです。このように fn() => でくるんであげることで、 try-catchブロックまで安全に持ち運んでから爆発物処理ができるということです。

実行結果はこうなります

array(3) {
  [0]=>
  string(72) "App\Address::$city (length=0) は 1 以上である必要があります"
  [1]=>
  string(60) "App\Address::$postal がパターンにマッチしません"
  [2]=>
  object(App\Address)#11 (2) {
    ["city"]=>
    string(9) "渋谷区"
    ["postal"]=>
    string(8) "151-0053"
  }
}

未定義を無視してはならない

あなたが namespace 付きのファイルでコーディングしているならば、アトリビュートとして参照するクラスは確実に use しなければなりません。

スクリーンショット 2022-11-30 1.47.36.png

PHPのアトリビュートは存在しないクラス名で記述しようと、 newInstance() が呼ばれるその瞬間までは決して警告を発しない。今回の場合は $prop->getAttributes(Validator::class, ReflectionAttribute::IS_INSTANCEOF) で拾われることもないので、単にスルーされてしまうことでしょう。

適切にuseされていない状態で実行された場合にコードがどのような振る舞いをするかは読者への課題とします。

ヒント
重要なのはリアルタイムで未定義クラスを参照していることに気付けるような環境でコードを書くことです。PhpStormを使う、VSCodeでIntelephenseを有効化する、PHPStanやPsalmでチェックするなどです。

ソースコード

コードはここに置いておきました。

これはライブラリとして使うために用意したものではないので、使うなら自分で拡張してみてね!

謝辞

この記事は2022年11月29日に開催されたPHP TechCafeのwill1992114さんの発表後の会話にインスパイヤされたものです。

49
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?