26
9

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.

LITALICOAdvent Calendar 2022

Day 12

PHP8 Attributesの活用例

Last updated at Posted at 2022-12-11

近寄りがたいAttribute

PHP8からAttribute(アトリビュート)が導入されました。

アトリビュートはライブラリやフレームワークの開発者向けの活用事例が多く、通常の開発案件においてはあまり日の目を見ない機能です。

大抵の場合は他の手法で代替がききますので、この機能ならではの利便性を見出せないと候補に入らないというのもありそうです。

個人的な印象なのですがアトリビュートがこれでもかと記載されたファイルを見ると、これまでのPHPの記法からかけ離れた感じがして尻込みしてしまったのですが同意してくれる人はいるでしょうか。

いやになっている人

そんなとっつきの悪いアトリビュートですが、所属チームでは可能性を見出しており大活躍中です。弊社の事例をいくつか紹介させて頂き、アトリビュートの魅力を知って頂けたら嬉しく思います。

Attributeのおさらい

そもそもアトリビュートとは何か、実際のサンプルコード見てその動きを確認してみましょう。

Attributeを定義

use Attribute;

#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
class MyAttribute
{
    /**
     * @param string $context 場面ごとの使い分け
     */
    public function __construct(
        private readonly string $context,
    )
    {
    }

    /**
     * @return string
     */
    public function getContext(): string
    {
        return $this->context;
    }
}

独自のアトリビュートを定義するにはそのクラス自体にアトリビュートを設定します。アトリビュートにはいくつかのフラグ付けることが出来、上記は以下の設定を行っています。

IS_REPEATABLE ... 対象に複数のアトリビュートを設定できる
TARGET_METHOD ... メソッドに対してアトリビュートを設定できる

クラスアトリビュートが付く以外通常のクラスを作成するのとほとんど一緒です。

Attributeの適用

class Family
{
    /**
     * @param string $parentUserName 親の名前
     * @param string $userName 自分の名前
     */
    public function __construct(
        private readonly string $parentUserName,
        private readonly string $userName,
    )
    {
    }

    /**
     * @return string
     */
    #[MyAttribute(context: '家族紹介')]
    public function getParentUserName(): string
    {
        return $this->parentUserName;
    }

    /**
     * @return string
     */
    #[MyAttribute(context: '自己紹介')]
    #[MyAttribute(context: '家族紹介')]
    public function getUserName(): string
    {
        return $this->userName;
    }
}

アトリビュートを付与することが出来ました。アトリビュートは対象のプロパティやメソッドにメタ情報を付与出来るものです。これらを定義してもメソッドの振る舞いに影響を与えるものではありません。メタ情報をどのように活用するかは、このクラスやメソッドを利用する側に委ねられます。

それではアトリビュートを利用する側のクラスを見ていきましょう。

Attributeを利用するクラスを作成

上記で定義したクラスを利用するIntroduceクラスを定義します。

class Introduce
{
    /**
     * @param Family $family
     */
    public function __construct(
        private readonly Family $family,
    )
    {
    }

    /**
     * @param string $context
     * @return void
     * @throws \ReflectionException
     */
    public function talk(string $context): void
    {
        $ref = new ReflectionClass($this->family);
        foreach ($ref->getMethods() as $method) {
            // 自作したAttributeに絞って取得
            $attributes = $method->getAttributes(MyAttribute::class);
            if (!$attributes) {
                continue;
            }

            foreach ($attributes as $attribute) {
                /** @var MyAttribute $myAttribute */
                $myAttribute = $attribute->newInstance();
                // コンテキストにマッチした値のみ出力対象とする
                if ($myAttribute->getContext() == $context) {
                    // メソッド呼び出し
                    $value = $method->invokeArgs($this->family, []);
                    // 取得した値で挨拶文を出力           
                    print("こんにちは。{$value}です\n");
                }
            }
        }
    }
}

実行

これまで作成したクラスを利用して実行してみましょう。

$family = new Family('山田父', '山田息子');
$introduce = new Introduce($family);

$introduce->talk('自己紹介');
// こんにちは。山田息子です

$introduce->talk('家族紹介');
// こんにちは。山田父です
// こんにちは。山田息子です

このようにアトリビュートは簡単に定義することが出来そして利用することが出来ます。今回はコンテキストというアトリビュートを作成し、その中で該当するメソッドのみコールするという動作を実現してみました。

こちらのサンプルコードは下記に置いてありますので手元で実行してみてください。

サンプルとして実装しましたが、コンテキストが違うものを一つのモデルにまとめてアトリビュートで制御するとなると実際はなかなかカオスなことになりそうです。

活用例

次は弊社の実例を交えてより実用性のあるものを紹介します。

例1: JSON変換の調整

phpの標準関数であるjson_encodeはオブジェクトに対し使用すると、publicなプロパティが抽出されシリアライズされます。

例えば以下のようなモデルを定義します。

class FamilyModel
{
    public function __construct(
        public readonly ?string $parentUserName = null,
        public readonly ?string $userName = null,
    )
    {
    }
}

インスタンスを作成しjsonシリアライズしたものを出力します。

$family = new FamilyModel('山田父');
$json = json_encode($family, JSON_UNESCAPED_UNICODE);
print_r($json);
// {"parentUserName":"山田父","userName":null}

値が設定されていないuserNameのプロパティも出力されています。もし値が設定されていないプロパティはキー自体を出力したくない場合はどのようにすればよいでしょう。以下の方法が用意されています。

class FamilyModel implements \JsonSerializable
{
    public function __construct(
        public readonly ?string $parentUserName = null,
        public readonly ?string $userName = null,
    )
    {
    }

    public function jsonSerialize(): mixed
    {
        return array_filter([
            'userName' => $this->userName,
            'parentUserName' => $this->parentUserName
        ]);
    }
}

jsonSerializeをimplementsしてjsonSerialize()を定義します。値が設定されていないプロパティは省かれ先程の例ですと以下のような出力になります。

{"parentUserName":"山田父"}

上記でも十分対応出来るのですがここでアトリビュートを活用してみましょう。

Attributeクラス

#[\Attribute]
class JsonEncode
{
    /**
     * @param bool $ignoreIfNull 値がnullの場合は無視する
     */
    public function __construct(
        private readonly bool $ignoreIfNull,
    )
    {
    }

    /**
     * @return bool
     */
    public function ignoreIfNull(): bool
    {
        return $this->ignoreIfNull;
    }
}

Attributeを読み込むTrait

trait JsonEncodeTrait
{
    public function jsonSerialize(): mixed
    {
        $results = [];
        $ref = new ReflectionClass($this);
        foreach ($ref->getProperties() as $property) {
            $attributes = $property->getAttributes(JsonEncode::class);
            if (!$attributes) {
                continue;
            }
            foreach ($attributes as $attribute) {
                /** @var JsonEncode $jsonEncode */
                $jsonEncode = $attribute->newInstance();
                if ($jsonEncode->ignoreIfNull()) {
                    if ($value = $property->getValue($this)) {
                        $results[$property->getName()] = $value;
                    }
                } else {
                    $results[$property->getName()] = $property->getValue($this);
                }
            }
        }

        return $results;
    }
}

Attributeの適用

class FamilyModel implements \JsonSerializable
{
    use JsonEncodeTrait;

    public function __construct(
        #[JsonEncode(ignoreIfNull: true)] 
        public readonly ?string $parentUserName = null,
        #[JsonEncode(ignoreIfNull: true)] 
        public readonly ?string $userName = null,
    )
    {
    }
}

同じ様に出力をコントロールすることが出来ました。他のクラスでも同様にuse JsonEncodeTrait(とImplements)を行えば、後はアトリビュートを設定するだけで済むようになります。見た目も簡潔になり対象のプロパティの近くに設定が置かれているので可読性が上がりました。

例2: バリデーション & データ変換

アトリビュートの真骨頂はバリデーションにおいて最も発揮されるのではと感じています。弊社では障害者福祉に関連するシステムを開発しております。厚生労働省から提供されるインターフェース仕様書というものに目を通してコードに落とし込む作業を行っております。

インタフェース仕様書というのは例えば以下のようなものです。
インタフェース仕様書(事業所編)
インタフェース仕様書サンプル

請求に関するデータをCSVファイルに変換するための仕様が記載されています。弊社ではこれらの情報をモデル定義し、モデルからCSVを生成する機能を開発しています。この際インタフェース仕様に沿ったアトリビュートを設定しています。

/**
 * 都道府県番号
 * @return string
 */
#[Csv(index:4, isRequired:true, maxBytes:6, type:Type::CODE)]
public function getPrefectureCode() :string
{
}

/**
 * 事業所番号
 * @return string
 */
#[Csv(index:5, isRequired:true, maxBytes:10, type:Type::ALPHA_NUMERIC)]
public function getFacilityNumber() :string
{
}

/**
 * 請求金額
 * @return string
 */
#[Csv(index:6, isRequired:true, maxBytes:10, type:Type::NUMERIC)]
public function getBilledAmount () :string
{
}

CSV変換時にアトリビュートの設定値を読み取り、仕様に違反していないかを担保することが出来るようになりました。

(個人的に仕様書をそのままコードに落とし込めている感覚が気持ちよかったりします)

例3: OpenApiの定義からLaravelValidationRuleの自動生成

弊社の一部PJではスキーマ駆動開発を採用しています。スキーマ駆動開発って何?という方はこちらの記事をお読みください。
実装と乖離させないスキーマ駆動開発フロー / OpenAPI Laravel編
(弊社エンジニアのkatzumiさんの記事です)

OpenAPISpecificationはAPIの仕様を書く為の仕様ですがSwagger-PHPを導入するとアノテーションやアトリビュートを用いてOpenAPIを記述出来ます。

リクエストパラメーターについては以下のように定義出来ます。

#[
    Schema(
        schema: 'CustomRequest',
        title: 'タイトル',
        description: '説明',
        required: ['userName'],
    )
]
class CustomRequest extends FormRequest
    #[OA\Property(
      'userName',
      description: 'ユーザー名',
      type: 'string',
      maxLength: 100,
      example: '山田太郎',
      nullable: false)
    ]
    public string $userName;
}

アトリビュートの情報に基づいてLaravelのValidationRuleを生成します。Laravelでは最終的にFormRequestを継承したクラス内で、rulesメソッドが各パラメーターのバリデーションルールを返却するようにします。アトリビュートのパースの仕組みはこれまで紹介した方法と基本的に同様です。

trait LaravelValidationRuleGenerator {
    public function rules(): array
    {
        $ref = new ReflectionClass($this);
        // Attributeのパースと、Ruleの変換処理

        // 最終的に以下のような配列を返す。
        return [
             "userNamet": [
                "string",
                "max:100",
             ],
        ];
    }
}

作成したtraitをリクエストクラスでuseすればそれだけでLaravelのバリデーションルールが生成され適用されます。

#[
    Schema(
        schema: 'CustomRequest',
        title: 'タイトル',
        description: '説明',
        required: ['userName'],
    )
]
class CustomRequest extends FormRequest
    use LaravelValidationRuleGenerator;  // useするだけ

    #[OA\Property(
      'userName',
      description: 'ユーザー名',
      type: 'string',
      maxLength: 100,
      example: '山田太郎',
      nullable: false)
    ]
    public string $userName;
}

rulesを自動生成することにより、仕様とコードの不一致が起こらないようになりました。OpenAPI Specificationは柔軟性のある記述が可能ですしLaravelのバリデーションルールの種類も豊富です。全てカバーするとなると少し大変ですので、弊社で開発したライブラリをいつか公開出来ればと思っています。

Attributeの使い所

冒頭にも触れましたがアトリビュートは無理に使わなくともアトリビュート登場以前の実現方法で解決出来ることがほとんどです。アトリビュートを活用することにより、より可読性やメンテナンス性が向上する場面を探っていけたらと思います。

アトリビュートの強みはメタ情報をプロパティやメソッドのすぐ近くに置くことにより、統一的な記法でその性質を表せることにあります。アノテーションでも同様のことは出来ましたが、アトリビュートの場合コードとし表現出来るのでより厳格であり情報をネイティブに扱えるところが大きく違います。

一方でアトリビュートに頼りすぎると別の問題が生じることがあります。アトリビュートはあくまで付加的な情報に過ぎません。アトリビュート自体は元のクラスやメソッドの振る舞いに影響を与えるわけではありません。アトリビュートを付与する素材が不味ければどうしようもないのです。まずはアトリビュートを抜きにしたあるべきアーキテクチャとモデル設計を行い、その上でアトリビュートの有効活用を検討していきましょう。

26
9
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
26
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?