Symfony Component Advent Calendar 2024の1日目の記事です。
Symfonyはオートワイヤリングの機能を使って、あれこれ自動でDIしてくれます。例えば
<?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
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]
の出番です。
※どこでも値を取得できると言って、あんなところやこんなところで取得すると、後で大変です。
<?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;
}
}
parameters:
app:
product_name: サービスの名前
#[Autowire(param: 'app.product_name')]
と指定した引数に、 config/services.yaml
に設定した、 param
で指定した app.product_name
の値が自動で注入されます。これにより、 getProductName()
を実行すれば、『サービスの名前』が返ってきます。
環境変数
時にはパラメータではなく、環境変数を注入したいこともあるでしょう。Symfonyの場合、今まではパラメータに環境変数を設定してたりしました。今は直接注入できます。
<?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 '工事中';
}
}
PRODUCT_NAME=サービスの名前
IS_ACTIVE=1
今度は #[Autowire(env: 'PRODUCT_NAME')]
と使う引数が env
に変わりました。これを利用すると、環境変数や.envの値を自動で注入します。この場合、PRODUCT_NAME=サービスの名前
の値を取得します。また env: 'bool:IS_ACTIVE'
のように型を指定することで、キャストできます。 IS_ACTIVE
は1ですが、bool値として注入できます。
ExpressionLanguage
Symfony ExpressionLanguageコンポーネントは、指定された式を評価・実行することができるコンポーネントです。この式の結果を注入することができます。
<?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
に各環境で何を渡すか設定していました。
<?php
namespace App\Service;
interface QiitaApiClientInterface
{
public function getPost(string $postId): Post
}
<?php
namespace App\Service;
class QiitaApiClient implements QiitaApiClientInterface
{
public function getPost(string $postId): Post
{
...
}
}
<?php
namespace App\Service;
class QiitaApiClientMock
{
public function getPost(string $postId): Post
{
return new Post();
}
}
<?php
namespace App\Service;
class SomeService
{
public function __construct(private readonly QiitaApiClientInterface $apiClient)
{
}
}
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
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\When;
#[When('prod')]
class QiitaApiClient implements QiitaApiClientInterface
{
public function getPost(string $postId): Post
{
...
}
}
<?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
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ではタグという概念があり、クラスにタグを付与して、タグを用いて注入することができます。
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
namespace App\Service;
class FrameworkResolver
{
public function __construct(
private readonly array $frameworks
)
{
}
}
これをいちいち設定するのはめんどくさいですよね?ここで活躍するのが、 #[AutowireIterator]
と #[AutoconfigureTag]
です。
<?php
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
class FrameworkResolver
{
public function __construct(
#[AutowireIterator('app.framework')] private readonly array $frameworks
)
{
}
}
<?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
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
namespace App\Service;
interface ItemFetcherInterface
{
public function get(int $id): Item;
}
<?php
namespace App\Service;
class ItemManager implements ItemFetcherInterface
{
public function get(int $id): Item
{
...
}
public function update(Item $item, UpdateDto $dto): void
{
// ItemFetcherInterface経由では呼べない
...
}
}
<?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
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の開発がしやすくなります。