Symfony Advent Calendar 2018 2日目の記事です!
昨日も私の記事 これからのSymfonyバージョン戦略決定版 でした。 Symfony Advent Calendarはまだまだ枠が空いてますので、Symfonyネタでなにか書きたい人はぜひ登録を!
前提
とあるSymfony4プロジェクト(Symfonyは4.1.6を利用)で、autowiringを利用しています。
とある処理の接続先をInterfaceの実装によって切り替えられるようにしたく(というか実装Aから実装Bへ切り替えを実際に行うための作業をしていた)、1つのInterfaceに対して2つの実装を用意しました。
そして、実際に利用したい対象のInterfaceの実装クラスに対しては、DI時にcalls指定が必要です。
Symfonyドキュメント “Dealing with Multiple Implementations of the Same Type” によると、Interfaceのエイリアスとして使いたいInterface実装クラスのサービス名を指定することでできるようでした。
実装クラスのサービス名は、Symfony4では、多くの場合実装クラスのFQCNをそのまま使うことが多いですね。
# config/services.yaml
services:
# ...
App\Util\Rot13Transformer: ~
App\Util\UppercaseTransformer: ~
# the ``App\Util\Rot13Transformer`` service will be injected when
# a ``App\Util\TransformerInterface`` type-hint is detected
App\Util\TransformerInterface: '@App\Util\Rot13Transformer'
App\Service\TwitterClient:
# the Rot13Transformer will be passed as the $transformer argument
autowire: true
# If you wanted to choose the non-default service, wire it manually
# arguments:
# $transformer: '@App\Util\UppercaseTransformer'
# ...
最初に書いたコードと設定
App\FooInterfaceを実装した Foo1
, Foo2
クラスがあり、
<?php
namespace App;
interface FooInterface
{
public function getName(): string;
}
<?php
namespace App\Foo;
use App\FooInterface;
class Foo1 implements FooInterface
{
public function getName(): string
{
return 'foo1';
}
}
<?php
namespace App\Foo;
use App\Bar\Bar;
use App\FooInterface;
class Foo2 implements FooInterface
{
/**
* @var Bar[]
*/
private $bars = [];
public function getName(): string
{
return sprintf('foo2 with %d bar(s)', count($this->bars));
}
public function addBar(Bar $bar): void
{
$this->bars[] = $bar;
}
}
autowiringによってApp\FooInterfaceが注入されることを期待しているSomeServiceがあります。
<?php
namespace App\Service;
use App\FooInterface;
class SomeService
{
/**
* @var FooInterface
*/
private $foo;
public function __construct(FooInterface $foo)
{
$this->foo = $foo;
}
public function getFooName(): string
{
return $this->foo->getName();
}
}
config/services.yamlには、次のようにDI設定を書きました。
# config/services.yaml
services:
_defaults:
autowire: true
App\Service\:
resource: "../src/Service"
public: true
bar1:
class: App\Bar\Bar
arguments: [1]
bar2:
class: App\Bar\Bar
arguments: [2]
App\Foo\Foo1:
class: App\Foo\Foo1
arguments: ["%foo_param%"]
App\Foo\Foo2:
class: App\Foo\Foo2
calls:
- ["addBar", ["@bar1"]]
- ["addBar", ["@bar2"]]
App\FooInterface: "@App\\Foo\\Foo2"
期待した動作
calls指定が効いて、 Foo2::$bars
に2つのBarを持つ Foo2
が、App\FooInterfaceとして注入されるはずですね!
実際の現象
<?php
namespace App\Tests\Functional\Service;
use App\Service\SomeService;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class SomeServiceTest extends KernelTestCase
{
public function test()
{
static::bootKernel();
$SUT = self::$container->get(SomeService::class);
$this->assertEquals('foo2 with 2 bar(s)', $SUT->getFooName());
}
}
ところが、予想に反して $SUT->getFooName()
の返り値は foo2 with 0 bar(s)
でした (T_T)
実際に効いた修正
SomeServiceに対するAutowiringを解除するのが一番良いのはわかっていたのですが、実際のSomeServiceは依存が多めのクラスなので、できればAutowiringを解除したくありません。
サービスの定義順を変えてみたり、キャッシュを削除してみたり、いろいろと試してみましたが埒が明かず…。
最終的に、services.yaml上で実装クラスにFQCNではない別の名前をつけることで、意図した通りのFoo2を注入させることができました。
# config/services.yaml
services:
_defaults:
autowire: true
App\Service\:
resource: "../src/Service"
public: true
bar1:
class: App\Bar\Bar
arguments: [1]
bar2:
class: App\Bar\Bar
arguments: [2]
- App\Foo\Foo1:
+ foo1:
class: App\Foo\Foo1
arguments: ["%foo_param%"]
- App\Foo\Foo2:
+ foo2:
class: App\Foo\Foo2
calls:
- ["addBar", "@bar1"]
- ["addBar", "@bar2"]
- App\FooInterface: "@App\\Foo\\Foo2"
+ App\FooInterface: "@foo2"
結論
現在のところ、別の名前をつけるか、autowire: falseに設定して自前で注入する指定を書くしかないようです。
若干面倒ですが、Symfony2までバリバリにサービス定義を書きまくっていたのに比べるとまだ楽に解決できたうちに入るかなと思います。
将来的にSymfonyのバージョンが上がることで修正されるかもしれませんが、2018年12月時点の状況としてまとめておきました。