feature #21530 [DependencyInjection] Add "instanceof" section for local interface-defined configs (nicolas-grekas, dunglas)

This PR was merged into the 3.3-dev branch.

Discussion
----------

[DependencyInjection] Add "instanceof" section for local interface-defined configs

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

This is a direction follow up of #21357 on which we're working together with @dunglas. From the description posted there:

There is some work being done to include features of [DunglasActionBundle](https://github.com/dunglas/DunglasActionBundle) in the core of Symfony. The goal of all those PRs is to improve the developper experience of the framework, allow to develop faster while preserving all benefits of using Symfony (strictness, modularity, extensibility...) and make it easier to learn for newcomers.

This PR implements the tagging feature of ActionBundle in a more generic way. It will help to get rid of `AppBundle` in the the standard edition and to register automatically some classes including commands.

Here is an example of config (that can be embedded in the standard edition) to enable those features:

```yaml
# config/services.yml
services:
    _defaults:
        autowire: ['get*', 'set*'] # Enable constructor, getter and setter autowiring for all services defined in this file

    _instanceof:
        Symfony\Component\Console\Command: # Add the console.command tag to all services defined in this file having this type
            tags: ['console.command']
            # Set tags but also other settings like "public", "autowire" or "shared" here

        Twig_ExtensionInterface:
            tags: ['twig.extension']

        Symfony\Component\EventDispatcher\EventSubscriberInterface:
            tags: ['kernel.event_subscriber']

    App\: # Register all classes in the src/Controller directory as services
        psr4: ../src/{Controller,Command,Twig,EventSubscriber}
```

It's part of our 0 config initiative: controllers and commands will be automatically registered as services and "autowired", allowing the user to create and inject new services without having to write a single line of YAML or XML.
When refactoring changes are also automatically updated and don't require to update config files. It's a big win for rapid application development and prototyping.

Of course, this is fully compatible with the actual way of defining services and it's possible to switch (or mix) approaches very easily. It's even possible to start prototyping using 0config features then switch to explicit services definitions when the project becomes mature.

Commits
-------

773eca7794 [DependencyInjection] Tests + refacto for "instanceof" definitions
2fb601983f [DependencyInjection] Add "instanceof" section for local interface-defined configs
This commit is contained in:
Fabien Potencier 2017-02-17 10:45:15 -08:00
commit d47571f5ec
15 changed files with 367 additions and 49 deletions

View File

@ -4,6 +4,7 @@ CHANGELOG
3.3.0
-----
* [EXPERIMENTAL] added "instanceof" section for local interface-defined configs
* [EXPERIMENTAL] added "service-locator" argument for lazy loading a set of identified values and services
* [EXPERIMENTAL] added prototype services for PSR4-based discovery and registration
* added `ContainerBuilder::getReflectionClass()` for retrieving and tracking reflection class info

View File

@ -119,6 +119,16 @@ class ChildDefinition extends Definition
return parent::setFile($file);
}
/**
* {@inheritdoc}
*/
public function setShared($boolean)
{
$this->changes['shared'] = true;
return parent::setShared($boolean);
}
/**
* {@inheritdoc}
*/
@ -139,6 +149,16 @@ class ChildDefinition extends Definition
return parent::setLazy($boolean);
}
/**
* {@inheritdoc}
*/
public function setAbstract($boolean)
{
$this->changes['abstract'] = true;
return parent::setAbstract($boolean);
}
/**
* {@inheritdoc}
*/

View File

@ -42,6 +42,7 @@ class PassConfig
$this->beforeOptimizationPasses = array(
100 => array(
$resolveClassPass = new ResolveClassPass(),
new ResolveDefinitionInheritancePass(),
),
);

View File

@ -0,0 +1,106 @@
<?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\Definition;
/**
* Applies tags and instanceof inheritance to definitions.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class ResolveDefinitionInheritancePass extends AbstractRecursivePass
{
protected function processValue($value, $isRoot = false)
{
if (!$value instanceof Definition) {
return parent::processValue($value, $isRoot);
}
if ($value instanceof ChildDefinition) {
$this->resolveDefinition($value);
}
$class = $value->getClass();
if (!$class || false !== strpos($class, '%') || !$instanceof = $value->getInstanceofConditionals()) {
return parent::processValue($value, $isRoot);
}
foreach ($instanceof as $interface => $definition) {
if ($interface !== $class && (!$this->container->getReflectionClass($interface) || !$this->container->getReflectionClass($class))) {
continue;
}
if ($interface === $class || is_subclass_of($class, $interface)) {
$this->mergeDefinition($value, $definition);
}
}
return parent::processValue($value, $isRoot);
}
/**
* Populates the class and tags from parent definitions.
*/
private function resolveDefinition(ChildDefinition $definition)
{
if (!$this->container->has($parent = $definition->getParent())) {
return;
}
$parentDef = $this->container->findDefinition($parent);
if ($parentDef instanceof ChildDefinition) {
$this->resolveDefinition($parentDef);
}
if (!isset($definition->getChanges()['class'])) {
$definition->setClass($parentDef->getClass());
}
// append parent tags when inheriting is enabled
if ($definition->getInheritTags()) {
foreach ($parentDef->getTags() as $k => $v) {
foreach ($v as $v) {
$definition->addTag($k, $v);
}
}
}
$definition->setInheritTags(false);
}
private function mergeDefinition(Definition $def, ChildDefinition $definition)
{
$changes = $definition->getChanges();
if (isset($changes['shared'])) {
$def->setShared($definition->isShared());
}
if (isset($changes['abstract'])) {
$def->setAbstract($definition->isAbstract());
}
if (isset($changes['autowired_calls'])) {
$autowiredCalls = $def->getAutowiredCalls();
}
ResolveDefinitionTemplatesPass::mergeDefinition($def, $definition);
// merge autowired calls
if (isset($changes['autowired_calls'])) {
$def->setAutowiredCalls(array_merge($autowiredCalls, $def->getAutowiredCalls()));
}
// merge tags
foreach ($definition->getTags() as $k => $v) {
foreach ($v as $v) {
$def->addTag($k, $v);
}
}
}
}

View File

@ -103,6 +103,26 @@ class ResolveDefinitionTemplatesPass extends AbstractRecursivePass
$def->setLazy($parentDef->isLazy());
$def->setAutowiredCalls($parentDef->getAutowiredCalls());
self::mergeDefinition($def, $definition);
// merge autowiring types
foreach ($definition->getAutowiringTypes(false) as $autowiringType) {
$def->addAutowiringType($autowiringType);
}
// these attributes are always taken from the child
$def->setAbstract($definition->isAbstract());
$def->setShared($definition->isShared());
$def->setTags($definition->getTags());
return $def;
}
/**
* @internal
*/
public static function mergeDefinition(Definition $def, ChildDefinition $definition)
{
// overwrite with values specified in the decorator
$changes = $definition->getChanges();
if (isset($changes['class'])) {
@ -168,26 +188,5 @@ class ResolveDefinitionTemplatesPass extends AbstractRecursivePass
foreach ($definition->getOverriddenGetters() as $k => $v) {
$def->setOverriddenGetter($k, $v);
}
// merge autowiring types
foreach ($definition->getAutowiringTypes(false) as $autowiringType) {
$def->addAutowiringType($autowiringType);
}
// these attributes are always taken from the child
$def->setAbstract($definition->isAbstract());
$def->setShared($definition->isShared());
$def->setTags($definition->getTags());
// append parent tags when inheriting is enabled
if ($definition->getInheritTags()) {
foreach ($parentDef->getTags() as $k => $v) {
foreach ($v as $v) {
$def->addTag($k, $v);
}
}
}
return $def;
}
}

View File

@ -30,6 +30,7 @@ class Definition
private $properties = array();
private $calls = array();
private $getters = array();
private $instanceof = array();
private $configurator;
private $tags = array();
private $public = true;
@ -363,6 +364,32 @@ class Definition
return $this->getters;
}
/**
* Sets the definition templates to conditionally apply on the current definition, keyed by parent interface/class.
*
* @param $instanceof ChildDefinition[]
*
* @experimental in version 3.3
*/
public function setInstanceofConditionals(array $instanceof)
{
$this->instanceof = $instanceof;
return $this;
}
/**
* Gets the definition templates to conditionally apply on the current definition, keyed by parent interface/class.
*
* @return ChildDefinition[]
*
* @experimental in version 3.3
*/
public function getInstanceofConditionals()
{
return $this->instanceof;
}
/**
* Sets tags for this definition.
*
@ -736,9 +763,7 @@ class Definition
*/
public function setAutowired($autowired)
{
$this->autowiredCalls = $autowired ? array('__construct') : array();
return $this;
return $this->setAutowiredCalls($autowired ? array('__construct') : array());
}
/**

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\Config\Exception\FileLocatorFileNotFoundException;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
@ -29,6 +30,8 @@ use Symfony\Component\Finder\Glob;
abstract class FileLoader extends BaseFileLoader
{
protected $container;
protected $isLoadingInstanceof = false;
protected $instanceof = array();
/**
* @param ContainerBuilder $container A ContainerBuilder instance
@ -80,7 +83,22 @@ abstract class FileLoader extends BaseFileLoader
$prototype = serialize($prototype);
foreach ($classes as $class) {
$this->container->setDefinition($class, unserialize($prototype));
$this->setDefinition($class, unserialize($prototype));
}
}
/**
* @experimental in version 3.3
*/
protected function setDefinition($id, Definition $definition)
{
if ($this->isLoadingInstanceof) {
if (!$definition instanceof ChildDefinition) {
throw new InvalidArgumentException(sprintf('Invalid type definition "%s": ChildDefinition expected, "%s" given.', $id, get_class($definition)));
}
$this->instanceof[$id] = $definition;
} else {
$this->container->setDefinition($id, $definition->setInstanceofConditionals($this->instanceof));
}
}

View File

@ -57,7 +57,11 @@ class XmlFileLoader extends FileLoader
$this->loadFromExtensions($xml);
// services
$this->parseDefinitions($xml, $path);
try {
$this->parseDefinitions($xml, $path);
} finally {
$this->instanceof = array();
}
}
/**
@ -126,13 +130,21 @@ class XmlFileLoader extends FileLoader
}
$this->setCurrentDir(dirname($file));
$this->instanceof = array();
$this->isLoadingInstanceof = true;
$instanceof = $xpath->query('//container:services/container:instanceof');
foreach ($instanceof as $service) {
$this->setDefinition((string) $service->getAttribute('id'), $this->parseDefinition($service, $file, array()));
}
$this->isLoadingInstanceof = false;
$defaults = $this->getServiceDefaults($xml, $file);
foreach ($services as $service) {
if (null !== $definition = $this->parseDefinition($service, $file, $defaults)) {
if ('prototype' === $service->tagName) {
$this->registerClasses($definition, (string) $service->getAttribute('namespace'), (string) $service->getAttribute('resource'));
} else {
$this->container->setDefinition((string) $service->getAttribute('id'), $definition);
$this->setDefinition((string) $service->getAttribute('id'), $definition);
}
}
}
@ -209,7 +221,9 @@ class XmlFileLoader extends FileLoader
return;
}
if ($parent = $service->getAttribute('parent')) {
if ($this->isLoadingInstanceof) {
$definition = new ChildDefinition('');
} elseif ($parent = $service->getAttribute('parent')) {
$definition = new ChildDefinition($parent);
if ($value = $service->getAttribute('inherit-tags')) {
@ -247,7 +261,7 @@ class XmlFileLoader extends FileLoader
$definition->setDeprecated(true, $deprecated[0]->nodeValue ?: null);
}
$definition->setArguments($this->getArgumentsAsPhp($service, 'argument', false, (bool) $parent));
$definition->setArguments($this->getArgumentsAsPhp($service, 'argument', false, $definition instanceof ChildDefinition));
$definition->setProperties($this->getArgumentsAsPhp($service, 'property'));
$definition->setOverriddenGetters($this->getArgumentsAsPhp($service, 'getter'));
@ -422,7 +436,7 @@ class XmlFileLoader extends FileLoader
uksort($definitions, 'strnatcmp');
foreach (array_reverse($definitions) as $id => list($domElement, $file, $wild)) {
if (null !== $definition = $this->parseDefinition($domElement, $file)) {
$this->container->setDefinition($id, $definition);
$this->setDefinition($id, $definition);
}
if (true === $wild) {

View File

@ -81,6 +81,22 @@ class YamlFileLoader extends FileLoader
'autowire' => 'autowire',
);
private static $instanceofKeywords = array(
'shared' => 'shared',
'lazy' => 'lazy',
'public' => 'public',
'abstract' => 'abstract',
'deprecated' => 'deprecated',
'factory' => 'factory',
'arguments' => 'arguments',
'properties' => 'properties',
'getters' => 'getters',
'configurator' => 'configurator',
'calls' => 'calls',
'tags' => 'tags',
'autowire' => 'autowire',
);
private static $defaultsKeywords = array(
'public' => 'public',
'tags' => 'tags',
@ -125,7 +141,11 @@ class YamlFileLoader extends FileLoader
// services
$this->setCurrentDir(dirname($path));
$this->parseDefinitions($content, $resource);
try {
$this->parseDefinitions($content, $resource);
} finally {
$this->instanceof = array();
}
}
/**
@ -187,6 +207,22 @@ class YamlFileLoader extends FileLoader
throw new InvalidArgumentException(sprintf('The "services" key should contain an array in %s. Check your YAML syntax.', $file));
}
if ($this->isUnderscoredParamValid($content, '_instanceof', $file)) {
$this->instanceof = array();
$this->isLoadingInstanceof = true;
foreach ($content['services']['_instanceof'] as $id => $service) {
if (!$service || !is_array($service)) {
throw new InvalidArgumentException(sprintf('Type definition "%s" must be a non-empty array within "_instanceof" in %s. Check your YAML syntax.', $id, $file));
}
if (is_string($service) && 0 === strpos($service, '@')) {
throw new InvalidArgumentException(sprintf('Type definition "%s" cannot be an alias within "_instanceof" in %s. Check your YAML syntax.', $id, $file));
}
$this->parseDefinition($id, $service, $file, array());
}
unset($content['services']['_instanceof']);
}
$this->isLoadingInstanceof = false;
$defaults = $this->parseDefaults($content, $file);
foreach ($content['services'] as $id => $service) {
$this->parseDefinition($id, $service, $file, $defaults);
@ -203,18 +239,11 @@ class YamlFileLoader extends FileLoader
*/
private function parseDefaults(array &$content, $file)
{
if (!isset($content['services']['_defaults'])) {
return array();
}
if (!is_array($defaults = $content['services']['_defaults'])) {
throw new InvalidArgumentException(sprintf('Service defaults must be an array, "%s" given in "%s".', gettype($defaults), $file));
}
if (isset($defaults['alias']) || isset($defaults['class']) || isset($defaults['factory'])) {
// @deprecated code path, to be removed in 4.0
if (!$this->isUnderscoredParamValid($content, '_defaults', $file)) {
return array();
}
$defaults = $content['services']['_defaults'];
unset($content['services']['_defaults']);
foreach ($defaults as $key => $default) {
@ -254,6 +283,21 @@ class YamlFileLoader extends FileLoader
return $defaults;
}
private function isUnderscoredParamValid($content, $name, $file)
{
if (!isset($content['services'][$name])) {
return false;
}
if (!is_array($underscoreParam = $content['services'][$name])) {
throw new InvalidArgumentException(sprintf('Service "%s" key must be an array, "%s" given in "%s".', $name, gettype($underscoreParam), $file));
}
// @deprecated condition, to be removed in 4.0
return !isset($underscoreParam['alias']) && !isset($underscoreParam['class']) && !isset($underscoreParam['factory']);
}
/**
* @param array $service
*
@ -304,7 +348,7 @@ class YamlFileLoader extends FileLoader
throw new InvalidArgumentException(sprintf('A service definition must be an array or a string starting with "@" but %s found for service "%s" in %s. Check your YAML syntax.', gettype($service), $id, $file));
}
static::checkDefinition($id, $service, $file);
$this->checkDefinition($id, $service, $file);
if (isset($service['alias'])) {
$public = array_key_exists('public', $service) ? (bool) $service['public'] : (isset($defaults['public']) ? $defaults['public'] : true);
@ -319,7 +363,9 @@ class YamlFileLoader extends FileLoader
return;
}
if (isset($service['parent'])) {
if ($this->isLoadingInstanceof) {
$definition = new ChildDefinition('');
} elseif (isset($service['parent'])) {
$definition = new ChildDefinition($service['parent']);
$inheritTag = isset($service['inherit_tags']) ? $service['inherit_tags'] : (isset($defaults['inherit_tags']) ? $defaults['inherit_tags'] : null);
@ -494,7 +540,7 @@ class YamlFileLoader extends FileLoader
}
$this->registerClasses($definition, $id, $service['resource']);
} else {
$this->container->setDefinition($id, $definition);
$this->setDefinition($id, $definition);
}
}
@ -723,12 +769,14 @@ class YamlFileLoader extends FileLoader
* @param array $definition The service definition to check
* @param string $file The loaded YAML file
*/
private static function checkDefinition($id, array $definition, $file)
private function checkDefinition($id, array $definition, $file)
{
if ($throw = isset($definition['resource'])) {
$keywords = static::$prototypeKeywords;
if ($throw = $this->isLoadingInstanceof) {
$keywords = self::$instanceofKeywords;
} elseif ($throw = isset($definition['resource'])) {
$keywords = self::$prototypeKeywords;
} else {
$keywords = static::$serviceKeywords;
$keywords = self::$serviceKeywords;
}
foreach ($definition as $key => $value) {

View File

@ -56,6 +56,7 @@
<xsd:element name="service" type="service" minOccurs="1" />
<xsd:element name="prototype" type="prototype" minOccurs="0" />
<xsd:element name="defaults" type="defaults" minOccurs="0" maxOccurs="1" />
<xsd:element name="instanceof" type="instanceof" minOccurs="0" />
</xsd:choice>
</xsd:complexType>
@ -137,6 +138,26 @@
<xsd:attribute name="inherit-tags" type="boolean" />
</xsd:complexType>
<xsd:complexType name="instanceof">
<xsd:choice maxOccurs="unbounded">
<xsd:element name="argument" type="argument" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="configurator" type="callable" minOccurs="0" maxOccurs="1" />
<xsd:element name="factory" type="callable" minOccurs="0" maxOccurs="1" />
<xsd:element name="deprecated" type="xsd:string" minOccurs="0" maxOccurs="1" />
<xsd:element name="call" type="call" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="tag" type="tag" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="property" type="property" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="getter" type="getter" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="autowire" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice>
<xsd:attribute name="id" type="xsd:string" use="required" />
<xsd:attribute name="shared" type="boolean" />
<xsd:attribute name="public" type="boolean" />
<xsd:attribute name="lazy" type="boolean" />
<xsd:attribute name="abstract" type="boolean" />
<xsd:attribute name="autowire" type="boolean" />
</xsd:complexType>
<xsd:complexType name="prototype">
<xsd:choice maxOccurs="unbounded">
<xsd:element name="argument" type="argument" minOccurs="0" maxOccurs="unbounded" />

View File

@ -22,6 +22,7 @@ class PassConfigTest extends \PHPUnit_Framework_TestCase
public function testPassOrdering()
{
$config = new PassConfig();
$config->setBeforeOptimizationPasses(array());
$pass1 = $this->getMockBuilder(CompilerPassInterface::class)->getMock();
$config->addPass($pass1, PassConfig::TYPE_BEFORE_OPTIMIZATION, 10);
@ -30,7 +31,7 @@ class PassConfigTest extends \PHPUnit_Framework_TestCase
$config->addPass($pass2, PassConfig::TYPE_BEFORE_OPTIMIZATION, 30);
$passes = $config->getBeforeOptimizationPasses();
$this->assertSame($pass2, $passes[1]);
$this->assertSame($pass1, $passes[2]);
$this->assertSame($pass2, $passes[0]);
$this->assertSame($pass1, $passes[1]);
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<instanceof id="Symfony\Component\DependencyInjection\Tests\Loader\BarInterface" lazy="true">
<autowire>set*</autowire>
<tag name="foo" />
<tag name="bar" />
</instanceof>
<service id="Symfony\Component\DependencyInjection\Tests\Loader\Bar" class="Symfony\Component\DependencyInjection\Tests\Loader\Bar" autowire="true" />
</services>
</container>

View File

@ -0,0 +1,10 @@
services:
_instanceof:
Symfony\Component\DependencyInjection\Tests\Loader\FooInterface:
autowire: true
lazy: true
tags:
- { name: foo }
- { name: bar }
Symfony\Component\DependencyInjection\Tests\Loader\Foo: ~

View File

@ -719,4 +719,25 @@ class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals(array(null, 'ABCD'), $container->getDefinition(NamedArgumentsDummy::class)->getArguments());
$this->assertEquals(array(array('setApiKey', array('123'))), $container->getDefinition(NamedArgumentsDummy::class)->getMethodCalls());
}
public function testInstanceof()
{
$container = new ContainerBuilder();
$loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml'));
$loader->load('services_instanceof.xml');
$container->compile();
$definition = $container->getDefinition(Bar::class);
$this->assertSame(array('__construct', 'set*'), $definition->getAutowiredCalls());
$this->assertTrue($definition->isLazy());
$this->assertSame(array('foo' => array(array()), 'bar' => array(array())), $definition->getTags());
}
}
interface BarInterface
{
}
class Bar implements BarInterface
{
}

View File

@ -469,6 +469,19 @@ class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals(array(array('setApiKey', array('123'))), $container->getDefinition('another_one')->getMethodCalls());
}
public function testInstanceof()
{
$container = new ContainerBuilder();
$loader = new YamlFileLoader($container, new FileLocator(self::$fixturesPath.'/yaml'));
$loader->load('services_instanceof.yml');
$container->compile();
$definition = $container->getDefinition(Foo::class);
$this->assertTrue($definition->isAutowired());
$this->assertTrue($definition->isLazy());
$this->assertSame(array('foo' => array(array()), 'bar' => array(array())), $definition->getTags());
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage The value of the "decorates" option for the "bar" service must be the id of the service without the "@" prefix (replace "@foo" with "foo").
@ -500,3 +513,11 @@ class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase
$loader->load('services_underscore.yml');
}
}
interface FooInterface
{
}
class Foo implements FooInterface
{
}