2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PHPAdvent Calendar 2024

Day 5

PHPのAttribute使ってますか?

Posted at

PHPのAttributeとは?

PHP8.0よりAttributeが導入された。これにより色々なコード上の「もの」に属性を与えられるようになった。
これによってdoctrine/annotationsという強大で様々なプロジェクトで使用されていたライブラリがその役目を終えようとしている。
ありがとうdoctrine/annotations…
そんな大きな影響を与えたAttributeの導入だが、実際にどのような場面で使えば良いのかよくわからない、という人も多いと思う。
せっかくの機能追加(随分前の話だけど)。深掘りしてみよう。

注: 参考コードはPHP8.4を想定して作っているため、8.0で動かないコードがあるかもしれないため、ご了承を。

Attributeの基本の「キ」

Attributeには基底クラスが用意されている。
これを基に自作のカスタムアトリビュートが作れる。
作り方は以下のコードの通り。

<?php

#[Attribute]
class CustomAttribute {}

基本的にはこのように自作したAttributeを対象のコード上の「もの」に属性を与えてReflectionを通じて機能を追加していくことになる。

属性を与える対象

Attributeによって属性を与えられる対象はAttribute上のクラス定数によって規定されている。
対象は以下の6つ。また、各「もの」には複数のAttributeを指定することができる。

  1. クラス(Attribute::TARGET_CLASS)
  2. ファンクション(Attribute::TARGET_FUNCTION)
  3. クラスメソッド(Attribute::TARGET_METHOD)
  4. クラスプロパティ(Attribute::TARGET_PROPERTY)
  5. クラス定数(Attribute::TARGET_CLASS_CONSTANT)
  6. 引数パラメータ(Attribute::TARGET_PARAMETER)

また、それとは別に以下のクラス定数も存在する。

  1. 対象の指定なし(Attribute::TARGET_ALL)
  2. 対象の再定義可能(Attribute::IS_REPEATABLE)

これらのクラス定数はAttributeのコンストラクタ引数として扱われ、複数指定する場合は、ORビット演算で渡すことになる。(各クラス定数はTARGET_ALL以外$ 2^n $の整数があてがわれている)

以下にクラスが対象のAttributeとクラスプロパティ、クラス定数が対象のAttributeの書き方を示す。

<?php

// クラスを対象としたAttribute
#[Attribute(Attribute::TARGET_CLASS)]
class TargetClass {}

// クラスプロパティおよびクラス定数を対象としたAttribute
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS_CONSTANT)]
class TargetPropertyAndClassConstant {}

実際にどうやって使うのか

以下からは実際のコードに近い形でどうやって使うのかを示す。

AOPライクな処理

例えば、操作に必ず証跡を残したい特殊なAPI群を作りたいことがあったとする。
この時、MVCフレームワーク上でControllerにAttributeを追加し、Controller処理を行う部分に追加の改修を行うことで、特定のAPIで自動的にログを追加できることがわかるだろうか?
以下に簡易的なコード例を示す。(特定FWを想定しているわけではないので、各々使う際は頭の中で読み替えていただければ幸いである。)

まず、対象APIに証跡が必要なことを示すTrailRequiredアトリビュートを作成する。

#[Attribute(Attribute::TARGET_CLASS)]
class TrailRequired {
}

次に上記で作ったAttributeをController上の証跡を残したいメソッドに紐づける。

class HogeController {
    #[TrailRequired]
    public function __invoke(Request $request)
    {
        // APIのメイン処理
    }
}

最後にControllerの処理部分でReflectionを使用し、このAttributeがついているメソッドが呼び出された際には、必ずログを残すように処理を変更する。
(簡易のため__invokeメソッドのみアクションとして使えるようにしているが、こういった制限を設けたくない場合は、getMethodsを使用して実行対象にTrailRequiredのAttributeがついているか?の判定も必要となる)

function executeController(Request $request, string $className) {
    $reflection = new ReflectionClass($className);
    $method = $reflection->getMethod('__invoke');
    // 証跡を残すための処理
    if (count($method->getAttributes(TrailRequired::class)) > 0) {
        logTrail($request);
    }
    // 元のControllerの処理
    $controller = new $className();
    $controller($request);
}

上記の例ではリクエストオブジェクトのみをログ用のメソッドに渡しているがControllerごとにOperationTypeをプロパティとして設けて、必ず渡すようにするようなことも考えられるだろう。
(Javaで扱われるようなフルスタックな(?)AOPを実現するには、Ray.AopのようなPECL拡張や旧Ray.Aopのようなプリコンパイル機構が必要となる。興味がある人は使い方だけでも参考にしてみると良いと思う。)

バリデーション処理

次にバリデーション処理で使う例を考えてみよう。
MVCフレームワーク上で必ずリクエストオブジェクトをControllerごとにクラス名を指定して生成するとする。
その際に、パラメータにAttributeを追加するだけで、バリデーションを行えたら、とても便利だろう。
以下に簡易的なコード例を示す。

まず、下記のようにvalidateメソッドを持つinterfaceを定義する。

#[Attribute(Attribute::TARGET_PARAMETER)]
interface ValidateRequired {
    public function validate(mixed $value): bool;
}

バリデーションの内容毎に上記のinterfaceを実装したAttributeを作成する。

class RegExp implements ValidateRequired {
    public function __construct(public readonly string $regExp) {}

    public function validate($value): bool
    {
        return is_string($value) && preg_match($this->regExp, $value) === 1;
    }
}

class StrLength implements ValidateRequired {
    public function __construct(public readonly int $min = 0, public readonly int $max = PHP_INT_MAX) {}
    public function validate($value): bool
    {
        return is_string($value) && strlen($value) >= $this->min && strlen($value) <= $this->max;
    }
}

class MinMax implements ValidateRequired {
    public function __construct(public readonly int $min = PHP_INT_MIN, public readonly int $max = PHP_INT_MAX) {}
    public function validate($value): bool
    {
        return is_int($value) && $value >= $this->min && $value <= $this->max;
    }
}

リクエストオブジェクトのコンストラクタの引数パラメータに必要なAttributeを指定する。

class CreateUserRequest extends Request {
    public function __construct(
        #[RegExp('/^\s+ \s+$/')]
        #[StrLength(1, 50)]
        private readonly string $name,
        #[RegExp('/^[A-z0-9 ]+$/')]
        #[StrLength(1, 16)]
        private readonly string $nickname,
        #[MinMax(0, 200)]
        private readonly int $age
    )
    {
    }
}

リクエストオブジェクト生成処理において、パラメータ毎にAttributeがついているかどうかを判定し、バリデーションを行うように変更する。

// $requestClassNameはFW上で指定できるリクエスト用のクラス名、$dataはHTTPリクエストボディ内のJSONなどを連想配列化したもの。
function generateRequest(string $requestClassName, array $data): Request {
    // リクエストオブジェクト用のクラスからコンストラクタの引数パラメータのリフレクションを取得する
    $reflection = new ReflectionClass($requestClassName);
    $constructorReflection = $reflection->getConstructor();
    $parameters = $constructorReflection->getParameters();
    // バリデーション失敗時用のInvalidArgument"s"Exceptionをインスタンス化
    $invalidArgumentsException = new InvalidArgumentsException();
    // リクエストオブジェクトのコンストラクタに渡す引数用の配列
    $arguments = [];
    foreach ($parameters as $parameter) {
        // 引数パラメータ毎に処理
        $attributes = $parameter->getAttributes();
        foreach ($attributes as $attributeReflection) {
            // 指定されているAttributeを一つずつ検証
            $attribute = $attributeReflection->newInstance();
            // AttributeがValidateRequiredを継承していたら、バリデーションを行う。
            // 成功したなら、コンストラクタに渡す引数の配列に渡ってきたリクエスト配列の内容を
            // 失敗したなら、例外にパラメータ名を
            // 追加する。
            if (is_a($attribute, ValidateRequired::class)) {
                if ($attribute->validate($data[$parameter->getName()])) {
                    $arguments[] = $data[$parameter->getName()];
                } else {
                    $invalidArgumentsException->addInvalidArgument($parameter->getName());
                }
            }
        }
    }

    if (count($invalidArgumentsException->getInvalidArgumentNames()) > 0) {
        throw $invalidArgumentsException;
    }
    return $reflection->newInstanceArgs($arguments);
}

このようにすることで、簡単にバリデーションを追加することが可能になる。
また、リクエストオブジェクトをインスタンス化する前にバリデーションを全て行えるため、例外に全てのバリデーション結果を入れられるところも、良いところだと思う。
リクエストオブジェクトのプロパティとしてクラスを指定する際には、もう少し手をかければ下記のようなライブラリと組み合わせることも考えられるだろう。
https://github.com/old-home/array_capture
(上記は私が書いたものなのだが、README.mdを書いておらず申し訳ない。tests/unit内をみていただければ、使い方はなんとなくわかると思う。)

最後に

今回用例としては以上とするが、まだまだAttributeの使い途はたくさんあると思う。
あなたのコードの保守が楽になる一助になればと思う。

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?