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

[Symfony Component Advent Calendar 2024](https://qiita.com/advent-calendar/2024/symfony)の1日目の記事です。

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

```php: 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;
    }
}
```

```php: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]` の出番です。

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

```php: 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;
    }
}
```

```yaml: config/services.yaml
parameters:
    app:
        product_name: サービスの名前
```

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

### 環境変数

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

```php: 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:.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コンポーネント](https://qiita.com/ippey_s/items/8fbadf36ffba5dfcfaf8)は、指定された式を評価・実行することができるコンポーネントです。この式の結果を注入することができます。

```php: 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()` の実行結果が注入されます。

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

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

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

```php:QiitaApiClientInterface.php
<?php

namespace App\Service;

interface QiitaApiClientInterface
{
    public function getPost(string $postId): Post
}
```

```php:QiitaApiClient.php
<?php

namespace App\Service;

class QiitaApiClient implements QiitaApiClientInterface
{
    public function getPost(string $postId): Post
    {
        ...
    }
}
```

```php:QiitaApiClientMock.php
<?php

namespace App\Service;

class QiitaApiClientMock
{
    public function getPost(string $postId): Post
    {
        return new Post();
    }
}
```

```php:SomeService.php
<?php

namespace App\Service;

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

```

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

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

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

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

```php:QiitaApiClient.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\When;

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

```php: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();
    }
}
```

このようにすれば、 `QiitaApiClientMock` は `dev`, `test` の場合に、 `QiitaApiClient` は `prod` の場合にサービスコンテナに登録されます。

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

```php: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ではタグという概念があり、クラスにタグを付与して、タグを用いて注入することができます。

```yaml: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']
```

```php:FrameworkResolver.php
<?php

namespace App\Service;

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

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

```php:FrameworkResolver.php
<?php

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

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

```php: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]` を使うことで優先順位をつけることができます。

```php: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を用意するのが手っ取り早いやり方でした。

```php:ItemFetcherInterface.php
<?php

namespace App\Service;

interface ItemFetcherInterface
{
    public function get(int $id): Item;
}
```

```php: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経由では呼べない
        ...
    }
}
```

```php: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]`。こいつは、特定のクラスの特定のメソッド**だけ**を注入することができます。

```php: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)` のように記述することで、そのメソッドを実行できます。

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

## まとめ

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