feature #39804 [DependencyInjection] Add #[Autoconfigure] to help define autoconfiguration rules (nicolas-grekas)

This PR was merged into the 5.3-dev branch.

Discussion
----------

[DependencyInjection] Add `#[Autoconfigure]` to help define autoconfiguration rules

| Q             | A
| ------------- | ---
| Branch?       | 5.x
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Tickets       | -
| License       | MIT
| Doc PR        | -

Being inspired by the discussion with @derrabus in #39776.

This PR allows declaring autoconfiguration rules using an attribute on classes/interfaces, eg:
`#[Autoconfigure(bind: ['$foo' => 'bar'], tags: [...], calls: [...])]`

This should typically be added on a base class/interface to tell *how* implementations of such a base type should be autoconfigured. The attribute is parsed when autoconfiguration is enabled, except when a definition has the `container.ignore_attributes` tag, which allows opting out from this behavior.

As usual, the corresponding rules are applied only to services that have autoconfiguration enabled.

In practice, this means that this enables auto-tagging of all implementations of this interface:
```php
#[Autoconfigure(tags: ['my_tag'])]
interface MyInterface {...}
```

Of course, all auto-configurable settings are handled (calls, bindings, etc.)

This PR adds another attribute: `#[AutoconfigureTag()]`.

It extends `#[Autoconfigure]` and allows for specifically defining tags to attach by autoconfiguration.

The name of the tag is optional and defaults to the name of the tagged type (typically the FQCN of an interface). This should ease with writing locators/iterators of tagged services.

```php
#[AutoconfigureTag()]
interface MyInterface {...}
```

Commits
-------

64ab6a2850 [DependencyInjection] Add `#[Autoconfigure]` to help define autoconfiguration rules
This commit is contained in:
Nicolas Grekas 2021-02-16 11:14:56 +01:00
commit 4d91b8f5c2
12 changed files with 303 additions and 5 deletions

View File

@ -0,0 +1,34 @@
<?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\Attribute;
/**
* An attribute to tell how a base type should be autoconfigured.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class Autoconfigure
{
public function __construct(
public ?array $tags = null,
public ?array $calls = null,
public ?array $bind = null,
public bool|string|null $lazy = null,
public ?bool $public = null,
public ?bool $shared = null,
public ?bool $autowire = null,
public ?array $properties = null,
public array|string|null $configurator = null,
) {
}
}

View File

@ -0,0 +1,30 @@
<?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\Attribute;
/**
* An attribute to tell how a base type should be tagged.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class AutoconfigureTag extends Autoconfigure
{
public function __construct(string $name = null, array $attributes = [])
{
parent::__construct(
tags: [
[$name ?? 0 => $attributes],
]
);
}
}

View File

@ -6,6 +6,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
5.2.0
-----

View File

@ -42,6 +42,7 @@ class PassConfig
$this->beforeOptimizationPasses = [
100 => [
new ResolveClassPass(),
new RegisterAutoconfigureAttributesPass(),
new ResolveInstanceofConditionalsPass(),
new RegisterEnvVarProcessorsPass(),
],

View File

@ -0,0 +1,92 @@
<?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\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
/**
* Reads #[Autoconfigure] attributes on definitions that are autoconfigured
* and don't have the "container.ignore_attributes" tag.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class RegisterAutoconfigureAttributesPass implements CompilerPassInterface
{
private $ignoreAttributesTag;
private $registerForAutoconfiguration;
public function __construct(string $ignoreAttributesTag = 'container.ignore_attributes')
{
if (80000 > \PHP_VERSION_ID) {
return;
}
$this->ignoreAttributesTag = $ignoreAttributesTag;
$parseDefinitions = new \ReflectionMethod(YamlFileLoader::class, 'parseDefinitions');
$parseDefinitions->setAccessible(true);
$yamlLoader = $parseDefinitions->getDeclaringClass()->newInstanceWithoutConstructor();
$this->registerForAutoconfiguration = static function (ContainerBuilder $container, \ReflectionClass $class, \ReflectionAttribute $attribute) use ($parseDefinitions, $yamlLoader) {
$attribute = (array) $attribute->newInstance();
foreach ($attribute['tags'] ?? [] as $i => $tag) {
if (\is_array($tag) && [0] === array_keys($tag)) {
$attribute['tags'][$i] = [$class->name => $tag[0]];
}
}
$parseDefinitions->invoke(
$yamlLoader,
[
'services' => [
'_instanceof' => [
$class->name => [$container->registerForAutoconfiguration($class->name)] + $attribute,
],
],
],
$class->getFileName()
);
};
}
/**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container)
{
if (80000 > \PHP_VERSION_ID) {
return;
}
foreach ($container->getDefinitions() as $id => $definition) {
if ($this->accept($definition) && null !== $class = $container->getReflectionClass($definition->getClass())) {
$this->processClass($container, $class);
}
}
}
public function accept(Definition $definition): bool
{
return 80000 <= \PHP_VERSION_ID && $definition->isAutoconfigured() && !$definition->hasTag($this->ignoreAttributesTag);
}
public function processClass(ContainerBuilder $container, \ReflectionClass $class)
{
foreach ($class->getAttributes(Autoconfigure::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
($this->registerForAutoconfiguration)($container, $class, $attribute);
}
}
}

View File

@ -18,6 +18,7 @@ use Symfony\Component\Config\Loader\FileLoader as BaseFileLoader;
use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\Config\Resource\GlobResource;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\RegisterAutoconfigureAttributesPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
@ -96,7 +97,8 @@ abstract class FileLoader extends BaseFileLoader
throw new InvalidArgumentException(sprintf('Namespace is not a valid PSR-4 prefix: "%s".', $namespace));
}
$classes = $this->findClasses($namespace, $resource, (array) $exclude);
$autoconfigureAttributes = new RegisterAutoconfigureAttributesPass();
$classes = $this->findClasses($namespace, $resource, (array) $exclude, $autoconfigureAttributes->accept($prototype) ? $autoconfigureAttributes : null);
// prepare for deep cloning
$serializedPrototype = serialize($prototype);
@ -149,7 +151,7 @@ abstract class FileLoader extends BaseFileLoader
}
}
private function findClasses(string $namespace, string $pattern, array $excludePatterns): array
private function findClasses(string $namespace, string $pattern, array $excludePatterns, ?RegisterAutoconfigureAttributesPass $autoconfigureAttributes): array
{
$parameterBag = $this->container->getParameterBag();
@ -207,6 +209,10 @@ abstract class FileLoader extends BaseFileLoader
if ($r->isInstantiable() || $r->isInterface()) {
$classes[$class] = null;
}
if ($autoconfigureAttributes && !$r->isInstantiable()) {
$autoconfigureAttributes->processClass($this->container, $r);
}
}
// track only for new & removed files

View File

@ -389,6 +389,9 @@ class YamlFileLoader extends FileLoader
];
}
$definition = isset($service[0]) && $service[0] instanceof Definition ? array_shift($service) : null;
$return = null === $definition ? $return : true;
$this->checkDefinition($id, $service, $file);
if (isset($service['alias'])) {
@ -423,7 +426,9 @@ class YamlFileLoader extends FileLoader
return $return ? $alias : $this->container->setAlias($id, $alias);
}
if ($this->isLoadingInstanceof) {
if (null !== $definition) {
// no-op
} elseif ($this->isLoadingInstanceof) {
$definition = new ChildDefinition('');
} elseif (isset($service['parent'])) {
if ('' !== $service['parent'] && '@' === $service['parent'][0]) {
@ -627,7 +632,8 @@ class YamlFileLoader extends FileLoader
if (isset($defaults['bind']) || isset($service['bind'])) {
// deep clone, to avoid multiple process of the same instance in the passes
$bindings = isset($defaults['bind']) ? unserialize(serialize($defaults['bind'])) : [];
$bindings = $definition->getBindings();
$bindings += isset($defaults['bind']) ? unserialize(serialize($defaults['bind'])) : [];
if (isset($service['bind'])) {
if (!\is_array($service['bind'])) {

View File

@ -0,0 +1,81 @@
<?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\Compiler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Argument\BoundArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\RegisterAutoconfigureAttributesPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfigureAttributed;
use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredInterface;
/**
* @requires PHP 8
*/
class RegisterAutoconfigureAttributesPassTest extends TestCase
{
public function testProcess()
{
$container = new ContainerBuilder();
$container->register('foo', AutoconfigureAttributed::class)
->setAutoconfigured(true);
(new RegisterAutoconfigureAttributesPass())->process($container);
$argument = new BoundArgument(1, true, BoundArgument::INSTANCEOF_BINDING, realpath(__DIR__.'/../Fixtures/AutoconfigureAttributed.php'));
$values = $argument->getValues();
--$values[1];
$argument->setValues($values);
$expected = (new ChildDefinition(''))
->setLazy(true)
->setPublic(true)
->setAutowired(true)
->setShared(true)
->setProperties(['bar' => 'baz'])
->setConfigurator(new Reference('bla'))
->addTag('a_tag')
->addTag('another_tag', ['attr' => 234])
->addMethodCall('setBar', [2, 3])
->setBindings(['$bar' => $argument])
;
$this->assertEquals([AutoconfigureAttributed::class => $expected], $container->getAutoconfiguredInstanceof());
}
public function testIgnoreAttribute()
{
$container = new ContainerBuilder();
$container->register('foo', AutoconfigureAttributed::class)
->addTag('container.ignore_attributes')
->setAutoconfigured(true);
(new RegisterAutoconfigureAttributesPass())->process($container);
$this->assertSame([], $container->getAutoconfiguredInstanceof());
}
public function testAutoconfiguredTag()
{
$container = new ContainerBuilder();
$container->register('foo', AutoconfiguredInterface::class)
->setAutoconfigured(true);
(new RegisterAutoconfigureAttributesPass())->process($container);
$expected = (new ChildDefinition(''))
->addTag(AutoconfiguredInterface::class, ['foo' => 123])
;
$this->assertEquals([AutoconfiguredInterface::class => $expected], $container->getAutoconfiguredInstanceof());
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
#[Autoconfigure(
lazy: true,
public: true,
autowire: true,
shared: true,
properties: [
'bar' => 'baz',
],
configurator: '@bla',
tags: [
'a_tag',
['another_tag' => ['attr' => 234]],
],
calls: [
['setBar' => [2, 3]]
],
bind: [
'$bar' => 1,
],
)]
class AutoconfigureAttributed
{
}

View File

@ -0,0 +1,10 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag(attributes: ['foo' => 123])]
interface AutoconfiguredInterface
{
}

View File

@ -2,6 +2,9 @@
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
#[Autoconfigure(tags: ['foo'])]
interface FooInterface
{
}

View File

@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface as PsrContainerInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Config\Loader\LoaderResolver;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition;
@ -171,7 +172,7 @@ class FileLoaderTest extends TestCase
$container = new ContainerBuilder();
$loader = new TestFileLoader($container, new FileLocator(self::$fixturesPath.'/Fixtures'));
$prototype = new Definition();
$prototype = (new Definition())->setAutoconfigured(true);
$loader->registerClasses($prototype, 'Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\\', 'Prototype/*');
$this->assertTrue($container->has(Bar::class));
@ -191,6 +192,10 @@ class FileLoaderTest extends TestCase
$this->assertSame(Foo::class, (string) $alias);
$this->assertFalse($alias->isPublic());
$this->assertTrue($alias->isPrivate());
if (\PHP_VERSION_ID >= 80000) {
$this->assertEquals([FooInterface::class => (new ChildDefinition(''))->addTag('foo')], $container->getAutoconfiguredInstanceof());
}
}
public function testMissingParentClass()