1
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?

SymfonyAdvent Calendar 2024

Day 1

Symfonyのアトリビュートを使って、依存をいろいろ注入しまくる

Last updated at Posted at 2024-11-30

Symfony Component Advent Calendar 2024の1日目の記事です。

Symfonyはオートワイヤリングの機能を使って、あれこれ自動でDIしてくれます。例えば

SomeService.php
<?php

namespace App\Service;

use App\Entity\Item;

class ItemFactory
{
    public function create(string $name, int $price): Item
    {
        $item = new Item();
        $item
            ->setName($name)
            ->setPrice($price)
        ;

        return $item;
    }
}
ItemController.php
<?php

namespace App\Controller;

use App\Entity\Item;
use App\Service\ItemFactory;
use Symfony\Component\HttpFoundation\Resquest;
use Symfony\Component\HttpFoundation\Response;

class ItemController
{
    public function __construct(private readonly ItemFactory $factory)
    {
    }

    public function register(): Response
    {
        $item = $this->factory->create('商品名', 1000);
        ...
    }
}    

この場合、ItemController::__construct()ItemFactory を引数にしていますが、Symfonyではオートワイヤリングにより、ItemFactory インスタンスが自動で注入されます。

ここまでは、Laravelも同様の機能があります。が、Symfonyはアトリビュートの力を使って、もっといろいろな方法で注入することができます!

#[Autowire]で注入しまくる

#[Autowire] アトリビュートは、引数を指定することで、普段はオートワイヤリングされないものを注入できます。今までは config/services.yaml に記述して設定いたことが、クラス内に記述できるようになりました。

パラメータ

Symfonyは前述の config/services.yaml にパラメータを設定することができます。これはLaravelの config() に相当します。Laravelでは config() を使えばどこでも(※) 値を取得して利用することができますが、Symfonyではできません。そこで #[Autowire] の出番です。

※どこでも値を取得できると言って、あんなところやこんなところで取得すると、後で大変です。

SomeService.php
<?php

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class SomeService
{
    public function __construct(
        #[Autowire(param: 'app.product_name')] private readonly string $productName,
    )
    {
    }

    public function getProductName(): string
    {
        return $this->productName;
    }
}
config/services.yaml
parameters:
    app:
        product_name: サービスの名前

#[Autowire(param: 'app.product_name')] と指定した引数に、 config/services.yaml に設定した、 param で指定した app.product_name の値が自動で注入されます。これにより、 getProductName() を実行すれば、『サービスの名前』が返ってきます。

環境変数

時にはパラメータではなく、環境変数を注入したいこともあるでしょう。Symfonyの場合、今まではパラメータに環境変数を設定してたりしました。今は直接注入できます。

SomeService.php
<?php

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class SomeService
{
    public function __construct(
        #[Autowire(env: 'PRODUCT_NAME')] private readonly string $productName,
        #[Autowire(env: 'bool:IS_ACTIVE'] private readonly bool $isActive,
    )
    {
    }

    public function getProductName(): string
    {
        if ($this->isActive) {
            return $this->productName;
        }

        return '工事中';
    }
}
.env
PRODUCT_NAME=サービスの名前
IS_ACTIVE=1

今度は #[Autowire(env: 'PRODUCT_NAME')] と使う引数が env に変わりました。これを利用すると、環境変数や.envの値を自動で注入します。この場合、PRODUCT_NAME=サービスの名前 の値を取得します。また env: 'bool:IS_ACTIVE' のように型を指定することで、キャストできます。 IS_ACTIVEは1ですが、bool値として注入できます。

ExpressionLanguage

Symfony ExpressionLanguageコンポーネントは、指定された式を評価・実行することができるコンポーネントです。この式の結果を注入することができます。

SomeService.php
<?php

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class SomeService
{
    public function __construct(
        #[Autowire(expression: '1 + 3')] private readonly int $result,
        #[Autowire(expression: 'service("App\\\Service\\\AttiveChecker").isActive()'] private readonly bool $isActive,
    )
    {
    }
}

次は引数に expression を使っています。これを利用すると、指定した式の結果を注入します。この場合、 #[Autowire(expression: '1 + 3')] の式の結果『4』が $result に注入されます。

また、 #[Autowire(expression: 'service("App\\\Service\\\AttiveChecker").isActive()'] とすれば、 ActiveChecker インスタンスの isActive() の実行結果が注入されます。

当然ながら、 ActiveChecker インスタンスに指定されている依存はオートワイヤリングによって自動注入されます。

#[When] で環境ごとに注入しまくる

環境ごとに違うクラスを注入したいことありませんか?例えば、とある外部サービスのAPIを使っているけど、開発環境やテスト環境はモックにしたいとか。今まではそういう場合、インターフェイスを作って config/services.yaml に各環境で何を渡すか設定していました。

QiitaApiClientInterface.php
<?php

namespace App\Service;

interface QiitaApiClientInterface
{
    public function getPost(string $postId): Post
}
QiitaApiClient.php
<?php

namespace App\Service;

class QiitaApiClient implements QiitaApiClientInterface
{
    public function getPost(string $postId): Post
    {
        ...
    }
}
QiitaApiClientMock.php
<?php

namespace App\Service;

class QiitaApiClientMock
{
    public function getPost(string $postId): Post
    {
        return new Post();
    }
}
SomeService.php
<?php

namespace App\Service;

class SomeService
{
    public function __construct(private readonly QiitaApiClientInterface $apiClient)
    {
    }
}

config/services.yaml
services:
    App\Service\SomeService:
        arguments:
            $apiClient: '@App\Service\QiitaApiClient'

when@dev:
    services:
        App\Service\SomeService:
            arguments:
                $apiClient: '@App\Service\QiitaApiClientMock'    

このようにすると、.envAPP_ENVの値が dev の時は QiitaApiClientMock が、それ以外の場合は QiitaApiClient が注入されます。でもこの設定めんどくさいですよね。

そこで登場するのが #[When] アトリビュートです。これを使うと、指定された環境でのみサービスコンテナに登録されます。

QiitaApiClient.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\When;

#[When('prod')]
class QiitaApiClient implements QiitaApiClientInterface
{
    public function getPost(string $postId): Post
    {
        ...
    }
}
QiitaApiClientMock.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\When;

#[When('dev')]
#[When('test')]
class QiitaApiClientMock
{
    public function getPost(string $postId): Post
    {
        return new Post();
    }
}

このようにすれば、 QiitaApiClientMockdev, test の場合に、 QiitaApiClientprod の場合にサービスコンテナに登録されます。

また、Symfon 7.2から #[WhenNot] アトリビュートが追加されました。

QiitaApiClientMock.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\WhenNot;

#[WhenNot('prod')]
class QiitaApiClientMock
{
    public function getPost(string $postId): Post
    {
        return new Post();
    }
}

これを使えば、 prod ではサービスコンテナに登録されない( = dev, test では登録される)ようになります。

#[AutowireIterator] でたくさん注入しまくる

Symfonyではタグという概念があり、クラスにタグを付与して、タグを用いて注入することができます。

config/services.yaml

services:
    App\Service\FrameworkResolver:
        arguments:
            $frameworks: !tagged_iterator {tag: app.framework}
    App\Service\Laravel:
        tags: ['app.framework']
    App\Service\Symfony:
        tags: ['app.framework']
    App\Service\CakePhp:
        tags: ['app.framework']
FrameworkResolver.php
<?php

namespace App\Service;

class FrameworkResolver
{
    public function __construct(
        private readonly array $frameworks
    )
    {
    }
}

これをいちいち設定するのはめんどくさいですよね?ここで活躍するのが、 #[AutowireIterator]#[AutoconfigureTag] です。

FrameworkResolver.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

class FrameworkResolver
{
    public function __construct(
        #[AutowireIterator('app.framework')] private readonly array $frameworks
    )
    {
    }
}
Symfony.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.framework')]
class Symfony
{
    public function getName(): string
    {
        return 'Symfony';
    }
}

#[AutowireIterator] は指定したタグのサービスを集めて配列として自動注入してくれます。 #[AutoconfigureTag] は指定したタグをクラスに付与します。この2つを駆使すれば、とくに設定せずともResolverみたいなクラスを自動注入できます。

優先順位

自動で配列にしてくれるのはいいものの、時には優先順位をつけたくなります。そのようなときは #[AsTaggedItem] を使うことで優先順位をつけることができます。

Symfony.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AsTaggedItem('app.framework', priority: 100)]
#[AutoconfigureTag('app.framework')]
class Symfony
{
    public function getName(): string
    {
        return 'Symfony';
    }
}

priority の値が大きければ大きいほど優先順位が高くなります。デフォルト値は 0 なので、正の値であればデフォルトよりも優先順位が上がり、負の値であればデフォルトよりも優先順位が下がります。

#[AutowireMethodOf] でちょっとだけ注入しまくる

時には、クラスの一部分だけ注入したい時もあるでしょう。例えばDB操作系で、ある取得メソッドだけ使いたいけど他はいらない、うっかり更新処理を使わないようにしたいなど。今までであれば、Interfaceを用意するのが手っ取り早いやり方でした。

ItemFetcherInterface.php
<?php

namespace App\Service;

interface ItemFetcherInterface
{
    public function get(int $id): Item;
}
ItemManager.php
<?php

namespace App\Service;

class ItemManager implements ItemFetcherInterface
{
    public function get(int $id): Item
    {
        ...
    }

    public function update(Item $item, UpdateDto $dto): void
    {
        // ItemFetcherInterface経由では呼べない
        ...
    }
}
SomeService.php
<?php

namespace App\Service;

class SomeService
{
    public function __construct(
        private readonly ItemFetcherInterface $itemFetcher
    )
    {
    }

    public function invoke(int $id): Item
    {
        $item = $this->itemFetcher->get($id);
    }
}

でもでも、いくつもInterface作るのは大変といえば大変です。そこで便利なのが #[AutowireMethodOf]。こいつは、特定のクラスの特定のメソッドだけを注入することができます。

SomeService.php
<?php

namespace App\Service;

class SomeService
{
    public function __construct(
        #[AutowireMethodOf('App\Service\ItemManager')] private readonly \Closure $get
    )
    {
    }

    public function invoke(int $id): Item
    {
        $item = ($this->get)($id);
    }
}

まず #[AutowireMethodOf] に使いたいクラス名を渡します。依存を少なくするために、クラス名は文字列で記載する(useしない)か、エイリアスをつけるとよいでしょう。

次に使いたいメソッド名を変数名にします。この場合、 get() メソッドを使いたいので変数名を $get にしています。すると、指定のクラスの特定のメソッドだけが変数に注入されます。あとは、 ($this->get)($id) のように記述することで、そのメソッドを実行できます。

Closureなので、戻り値が mixed になっています。PHPStanに怒られるので、assert()やアノテーションをつけて逃げましょう。

まとめ

このように、Symfonyのアトリビュートを使えば、色々な方法で注入しまくれます。DIにしておくことで、責務を分けやすくなりますし、テストもしやすくなります。当然今まで通りの config/services.yaml に記述するやり方もできるので、プロダクトや状況に応じて使い分けることで、さらにSymfonyの開発がしやすくなります。

1
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
1
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?