-ao- ramune blog

©2024 unio / GO2直営からふるラムネ
2023年01月03日

Symfony5で自作ParamConverter

AttributeでオリジナルのParamConverterを作ろう

ParamConverter 便利ですよね。(Symfony6から コア機能として提供される予定です)
独自にカスタムしたいケースでは ParamConverterInterface を実装したコンバーターを作成することになるのですが、 今回は勉強がてらPHP8のAttributeを使ってオリジナルのParamConverterを作成してみました。

  • SymfonyのAutowiring設定が有効になっている前提です
  • AttributeはReflectionで取ります
  • kernel.requestなどの カーネルイベント で処理します

イメージとしては、下記のようなコントローラで hoge ID に対応するMyObjオブジェクトを取得できるようにします。

MyController.php
                
                    class MyController
                    {
                        // 引数は必ずnullable(?MyObj)で設定してください

                        #[MyConverter(class: MyObj::class, routeKey: 'hoge')]
                        #[Route('/{hoge}', name: 'my_controller')]
                        public function index(?MyObj $object): Response
                        {
                            ...
                        }
                    }
                
            

Attributeの作成

MyConverter.php
                
                    <?php
                    namespace App\Annotation;
                    use Attribute;

                    // Attribute::TARGET_METHOD でメソッドのみ利用できるAttributeになります
                    // Attribute::IS_REPEATABLE を設定すると、重複してAttributeを設置することができます

                    #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
                    class MyConverter
                    {
                        public string $class;
                        public string $routeKey;

                        // コンバート対象のクラスと、Routeのキーを設定します
                        public function __construct(string $class, string $routeKey)
                        {
                            $this->class = $class;
                            $this->routeKey = $routeKey;
                        }
                    }
                
            

EventSubscriberの作成

MyConvertSubscriber.php
                
                    <?php
                    namespace App\EventSubscriber;
                    use App\Annotation\MyConverter;
                    use App\Entity\MyObj;
                    use App\Repository\MyRepository;
                    use Symfony\Component\EventDispatcher\EventSubscriberInterface;
                    use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
                    use Symfony\Component\HttpKernel\KernelEvents;

                    class MyConvertSubscriber implements EventSubscriberInterface
                    {
                        private MyRepository $repository;

                        public function __construct(MyRepository $repository)
                        {
                            $this->repository = $repository;
                        }

                        public static function getSubscribedEvents(): array
                        {
                            return [
                                // コントローラの呼び出し直前に発火するイベントです
                                // https://symfony.com/doc/current/reference/events.html
                                KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments',
                            ];
                        }

                        public function onKernelControllerArguments(ControllerArgumentsEvent $event)
                        {
                            if (!$event->isMainRequest()) {
                                return;
                            }
                            $request = $event->getRequest();
                            $controller = $request->attributes->get('_controller');
                            try {
                                // リクエストを受け取ったコントローラのメソッドリクレクションを取得します
                                $method = new \ReflectionMethod($controller);
                            } catch (\Exception $e) {
                                return;
                            }

                            // メソッドに設定されたMyConverter Attributeを取得します
                            $refs = $method->getAttributes(MyConverter::class);
                            $attributes = array_map(fn(\ReflectionAttribute $ref) => $ref->newInstance(), $refs);
                            if ($attributes === []) {
                                return;
                            }

                            // メソッド引数の何番目が対象のオブジェクトかを把握するためのマップを作成します
                            $paramIndexes = [];
                            foreach ($method->getParameters() as $i => $param) {
                                // メソッド引数のクラス名がキーになります
                                $paramIndexes[$param->getType()->getName()] = $i;
                            }

                            $args = $event->getArguments();
                            foreach ($attributes as $attribute) {
                                // Attributeで指定したクラスが引数に存在しない場合はスキップします
                                if (!isset($paramIndexes[$attribute->class])) {
                                    continue;
                                }

                                // SymfonyのRouteで指定したキーに対応した値を取得します
                                // #[Route('/{hoge}')] の場合、routeKeyに hoge を指定することで取得可能です
                                $id = $request->attributes->getInt($attribute->routeKey);

                                // クラスに応じてオブジェクト生成方法はスイッチさせる必要があります
                                switch ($class) {
                                    case MyObj::class:
                                        $entity = $this->repository->find($id);
                                        break;
                                }
                                if ($entity === null) {
                                    continue;
                                }

                                // 先ほど作成したインデックスでコントローラ引数にオブジェクトを設定します
                                $args[$paramIndexes[$attribute->class]] = $entity;
                            }

                            // コントローラ引数を設定しなおして完了です
                            $event->setArguments($args);
                        }
                    }
                
            

これでMyObjMyRepositoryを適当に用意すれば冒頭のコントローラでオブジェクトが設定されるようになります。