[DependencyInjection] Autoconfigurable attributes

This commit is contained in:
Alexander M. Turek 2021-02-11 17:42:06 +01:00
parent f50e6afd7d
commit 2ab3caf080
17 changed files with 522 additions and 4 deletions

View File

@ -58,6 +58,7 @@ use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\EventDispatcher\Attribute\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\Finder\Finder;
@ -549,6 +550,10 @@ class FrameworkExtension extends Extension
$container->registerForAutoconfiguration(LoggerAwareInterface::class)
->addMethodCall('setLogger', [new Reference('logger')]);
$container->registerAttributeForAutoconfiguration(EventListener::class, static function (ChildDefinition $definition, EventListener $attribute): void {
$definition->addTag('kernel.event_listener', get_object_vars($attribute));
});
if (!$container->getParameter('kernel.debug')) {
// remove tagged iterator argument for resource checkers
$container->getDefinition('config_cache_factory')->setArguments([]);

View File

@ -7,6 +7,7 @@ CHANGELOG
* Add `ServicesConfigurator::remove()` in the PHP-DSL
* Add `%env(not:...)%` processor to negate boolean values
* Add support for loading autoconfiguration rules via the `#[Autoconfigure]` and `#[AutoconfigureTag]` attributes on PHP 8
* Add autoconfigurable attributes
5.2.0
-----

View File

@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* @author Alexander M. Turek <me@derrabus.de>
*/
final class AttributeAutoconfigurationPass implements CompilerPassInterface
{
private $ignoreAttributesTag;
public function __construct(string $ignoreAttributesTag = 'container.ignore_attributes')
{
$this->ignoreAttributesTag = $ignoreAttributesTag;
}
public function process(ContainerBuilder $container): void
{
if (80000 > \PHP_VERSION_ID) {
return;
}
$autoconfiguredAttributes = $container->getAutoconfiguredAttributes();
foreach ($container->getDefinitions() as $id => $definition) {
if (!$definition->isAutoconfigured()
|| $definition->isAbstract()
|| $definition->hasTag($this->ignoreAttributesTag)
|| !($reflector = $container->getReflectionClass($definition->getClass(), false))
) {
continue;
}
$instanceof = $definition->getInstanceofConditionals();
$conditionals = $instanceof[$reflector->getName()] ?? new ChildDefinition('');
foreach ($reflector->getAttributes() as $attribute) {
if ($configurator = $autoconfiguredAttributes[$attribute->getName()] ?? null) {
$configurator($conditionals, $attribute->newInstance(), $reflector);
}
}
$instanceof[$reflector->getName()] = $conditionals;
$definition->setInstanceofConditionals($instanceof);
}
}
}

View File

@ -43,6 +43,7 @@ class PassConfig
100 => [
new ResolveClassPass(),
new RegisterAutoconfigureAttributesPass(),
new AttributeAutoconfigurationPass(),
new ResolveInstanceofConditionalsPass(),
new RegisterEnvVarProcessorsPass(),
],

View File

@ -123,6 +123,11 @@ class ContainerBuilder extends Container implements TaggedContainerInterface
private $autoconfiguredInstanceof = [];
/**
* @var callable[]
*/
private $autoconfiguredAttributes = [];
private $removedIds = [];
private $removedBindingIds = [];
@ -671,6 +676,14 @@ class ContainerBuilder extends Container implements TaggedContainerInterface
$this->autoconfiguredInstanceof[$interface] = $childDefinition;
}
foreach ($container->getAutoconfiguredAttributes() as $attribute => $configurator) {
if (isset($this->autoconfiguredAttributes[$attribute])) {
throw new InvalidArgumentException(sprintf('"%s" has already been autoconfigured and merge() does not support merging autoconfiguration for the same attribute.', $attribute));
}
$this->autoconfiguredAttributes[$attribute] = $configurator;
}
}
/**
@ -1309,6 +1322,16 @@ class ContainerBuilder extends Container implements TaggedContainerInterface
return $this->autoconfiguredInstanceof[$interface];
}
/**
* Registers an attribute that will be used for autoconfiguring annotated classes.
*
* The configurator will receive a Definition instance and an instance of the attribute, in that order.
*/
public function registerAttributeForAutoconfiguration(string $attributeClass, callable $configurator): void
{
$this->autoconfiguredAttributes[$attributeClass] = $configurator;
}
/**
* Registers an autowiring alias that only binds to a specific argument name.
*
@ -1338,6 +1361,14 @@ class ContainerBuilder extends Container implements TaggedContainerInterface
return $this->autoconfiguredInstanceof;
}
/**
* @return callable[]
*/
public function getAutoconfiguredAttributes(): array
{
return $this->autoconfiguredAttributes;
}
/**
* Resolves env parameter placeholders in a string or an array.
*

View File

@ -16,14 +16,22 @@ use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Alias;
use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration;
use Symfony\Component\DependencyInjection\Tests\Fixtures\BarTagClass;
use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedClass;
use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedForDefaultPriorityClass;
use Symfony\Component\DependencyInjection\Tests\Fixtures\FooTagClass;
use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService1;
use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService2;
use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService3;
use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService3Configurator;
use Symfony\Contracts\Service\ServiceProviderInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
@ -506,6 +514,109 @@ class IntegrationTest extends TestCase
];
$this->assertSame($expected, ['baz' => $serviceLocator->get('baz')]);
}
/**
* @requires PHP 8
*/
public function testTagsViaAttribute()
{
$container = new ContainerBuilder();
$container->registerAttributeForAutoconfiguration(
CustomAutoconfiguration::class,
static function (ChildDefinition $definition, CustomAutoconfiguration $attribute, \ReflectionClass $reflector) {
$definition->addTag('app.custom_tag', get_object_vars($attribute) + ['class' => $reflector->getName()]);
}
);
$container->register('one', TaggedService1::class)
->setPublic(true)
->setAutoconfigured(true);
$container->register('two', TaggedService2::class)
->addTag('app.custom_tag', ['info' => 'This tag is not autoconfigured'])
->setPublic(true)
->setAutoconfigured(true);
$collector = new TagCollector();
$container->addCompilerPass($collector);
$container->compile();
self::assertSame([
'one' => [
['someAttribute' => 'one', 'priority' => 0, 'class' => TaggedService1::class],
['someAttribute' => 'two', 'priority' => 0, 'class' => TaggedService1::class],
],
'two' => [
['info' => 'This tag is not autoconfigured'],
['someAttribute' => 'prio 100', 'priority' => 100, 'class' => TaggedService2::class],
],
], $collector->collectedTags);
}
/**
* @requires PHP 8
*/
public function testAttributesAreIgnored()
{
$container = new ContainerBuilder();
$container->registerAttributeForAutoconfiguration(
CustomAutoconfiguration::class,
static function (Definition $definition, CustomAutoconfiguration $attribute) {
$definition->addTag('app.custom_tag', get_object_vars($attribute));
}
);
$container->register('one', TaggedService1::class)
->setPublic(true)
->addTag('container.ignore_attributes')
->setAutoconfigured(true);
$container->register('two', TaggedService2::class)
->setPublic(true)
->setAutoconfigured(true);
$collector = new TagCollector();
$container->addCompilerPass($collector);
$container->compile();
self::assertSame([
'two' => [
['someAttribute' => 'prio 100', 'priority' => 100],
],
], $collector->collectedTags);
}
/**
* @requires PHP 8
*/
public function testAutoconfigureViaAttribute()
{
$container = new ContainerBuilder();
$container->registerAttributeForAutoconfiguration(
CustomAutoconfiguration::class,
static function (ChildDefinition $definition) {
$definition
->addMethodCall('doSomething', [1, 2, 3])
->setBindings(['string $foo' => 'bar'])
->setConfigurator(new Reference('my_configurator'))
;
}
);
$container->register('my_configurator', TaggedService3Configurator::class);
$container->register('three', TaggedService3::class)
->setPublic(true)
->setAutoconfigured(true);
$container->compile();
/** @var TaggedService3 $service */
$service = $container->get('three');
self::assertSame('bar', $service->foo);
self::assertSame(6, $service->sum);
self::assertTrue($service->hasBeenConfigured);
}
}
class ServiceSubscriberStub implements ServiceSubscriberInterface
@ -566,3 +677,13 @@ class IntegrationTestStubParent
{
}
}
final class TagCollector implements CompilerPassInterface
{
public $collectedTags;
public function process(ContainerBuilder $container): void
{
$this->collectedTags = $container->findTaggedServiceIds('app.custom_tag');
}
}

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
final class CustomAutoconfiguration
{
public function __construct(
public string $someAttribute,
public int $priority = 0,
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration;
#[CustomAutoconfiguration(someAttribute: 'one')]
#[CustomAutoconfiguration(someAttribute: 'two')]
final class TaggedService1
{
}

View File

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration;
#[CustomAutoconfiguration(someAttribute: 'prio 100', priority: 100)]
final class TaggedService2
{
}

View File

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomAutoconfiguration;
#[CustomAutoconfiguration(someAttribute: 'three')]
final class TaggedService3
{
public int $sum = 0;
public bool $hasBeenConfigured = false;
public function __construct(
public string $foo,
) {
}
public function doSomething(int $a, int $b, int $c): void
{
$this->sum = $a + $b + $c;
}
}

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
final class TaggedService3Configurator
{
public function __invoke(TaggedService3 $service)
{
$service->hasBeenConfigured = true;
}
}

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\EventDispatcher\Attribute;
/**
* Service tag to autoconfigure event listeners.
*
* @author Alexander M. Turek <me@derrabus.de>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class EventListener
{
public function __construct(
public ?string $event = null,
public ?string $method = null,
public int $priority = 0
) {
}
}

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
5.3
---
* Add `EventListener` attribute for declaring listeners on PHP 8.
5.1.0
-----

View File

@ -13,12 +13,19 @@ namespace Symfony\Component\EventDispatcher\Tests\DependencyInjection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\AttributeAutoconfigurationPass;
use Symfony\Component\DependencyInjection\Compiler\ResolveInstanceofConditionalsPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\Attribute\EventListener;
use Symfony\Component\EventDispatcher\DependencyInjection\AddEventAliasesPass;
use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\EventDispatcher\Tests\Fixtures\CustomEvent;
use Symfony\Component\EventDispatcher\Tests\Fixtures\TaggedInvokableListener;
use Symfony\Component\EventDispatcher\Tests\Fixtures\TaggedMultiListener;
class RegisterListenersPassTest extends TestCase
{
@ -231,6 +238,90 @@ class RegisterListenersPassTest extends TestCase
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}
/**
* @requires PHP 8
*/
public function testTaggedInvokableEventListener()
{
if (!class_exists(AttributeAutoconfigurationPass::class)) {
self::markTestSkipped('This test requires Symfony DependencyInjection >= 5.3');
}
$container = new ContainerBuilder();
$container->registerAttributeForAutoconfiguration(EventListener::class, static function (ChildDefinition $definition, EventListener $attribute): void {
$definition->addTag('kernel.event_listener', get_object_vars($attribute));
});
$container->register('foo', TaggedInvokableListener::class)->setAutoconfigured(true);
$container->register('event_dispatcher', \stdClass::class);
(new AttributeAutoconfigurationPass())->process($container);
(new ResolveInstanceofConditionalsPass())->process($container);
(new RegisterListenersPass())->process($container);
$definition = $container->getDefinition('event_dispatcher');
$expectedCalls = [
[
'addListener',
[
CustomEvent::class,
[new ServiceClosureArgument(new Reference('foo')), '__invoke'],
0,
],
],
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}
/**
* @requires PHP 8
*/
public function testTaggedMultiEventListener()
{
if (!class_exists(AttributeAutoconfigurationPass::class)) {
self::markTestSkipped('This test requires Symfony DependencyInjection >= 5.3');
}
$container = new ContainerBuilder();
$container->registerAttributeForAutoconfiguration(EventListener::class, static function (ChildDefinition $definition, EventListener $attribute): void {
$definition->addTag('kernel.event_listener', get_object_vars($attribute));
});
$container->register('foo', TaggedMultiListener::class)->setAutoconfigured(true);
$container->register('event_dispatcher', \stdClass::class);
(new AttributeAutoconfigurationPass())->process($container);
(new ResolveInstanceofConditionalsPass())->process($container);
(new RegisterListenersPass())->process($container);
$definition = $container->getDefinition('event_dispatcher');
$expectedCalls = [
[
'addListener',
[
CustomEvent::class,
[new ServiceClosureArgument(new Reference('foo')), 'onCustomEvent'],
0,
],
],
[
'addListener',
[
'foo',
[new ServiceClosureArgument(new Reference('foo')), 'onFoo'],
42,
],
],
[
'addListener',
[
'bar',
[new ServiceClosureArgument(new Reference('foo')), 'onBarEvent'],
0,
],
],
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}
public function testAliasedEventListener()
{
$container = new ContainerBuilder();
@ -416,10 +507,6 @@ final class AliasedEvent
{
}
final class CustomEvent
{
}
final class TypedListener
{
public function __invoke(AliasedEvent $event): void

View File

@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\EventDispatcher\Tests\Fixtures;
final class CustomEvent
{
}

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\EventDispatcher\Tests\Fixtures;
use Symfony\Component\EventDispatcher\Attribute\EventListener;
#[EventListener]
final class TaggedInvokableListener
{
public function __invoke(CustomEvent $event): void
{
}
}

View File

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\EventDispatcher\Tests\Fixtures;
use Symfony\Component\EventDispatcher\Attribute\EventListener;
#[EventListener(event: CustomEvent::class, method: 'onCustomEvent')]
#[EventListener(event: 'foo', priority: 42)]
#[EventListener(event: 'bar', method: 'onBarEvent')]
final class TaggedMultiListener
{
public function onCustomEvent(CustomEvent $event): void
{
}
public function onFoo(): void
{
}
public function onBarEvent(): void
{
}
}