Skip to content

Commit

Permalink
feature #43701 [HttpKernel] Simplifying Bundle/Extension config defin…
Browse files Browse the repository at this point in the history
…ition (yceruto)

This PR was merged into the 6.1 branch.

Discussion
----------

[HttpKernel] Simplifying Bundle/Extension config definition

| Q             | A
| ------------- | ---
| Branch?       | 6.1
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #40259, #42647, #43080
| License       | MIT
| Doc PR        | -

This PR aims to simplify DI extension/configuration definitions at the Bundle level (based on @Nyholm #40259 (comment))

Currently, the services and configuration definitions have to deal with some conventions:
 * Create the `DependencyInjection/` directory
 * Create the `DependencyInjection/Configuration.php` class to define the bundle config.
 * Create the `DependencyInjection/FooExtension.php` extension class and extend from `Extension`
 * In the `ExtensionInterface::load()` method to implement we have to:
    * Process the bundle configuration yourself `Configuration`, `Processor`, etc.
    * Create the specific `*FileLoader` & `FileLocator` instances to import services definition (have to deal with bundle path)
 * Prepend/append configs for other extensions requires implementing `PrependExtensionInterface`.
 *  Redefine `Bundle::$name` to change the extension alias.

Although it may not be a big problem to follow all these conventions (indeed, we have been doing it for years) it's true that there are limitations and it requires extra work to achieve them.

Note: The following improvements don't pretend to deprecate the actual convention (at least so far) but simplify it with some benefits.

---
To start using the following improvements your bundle must extend from the new abstract class `AbstractBundle` to autoconfigure all hooks and make this possible inside a bundle class.

**The first improvement** offers the possibility to configure your bundle DI extension within the bundle class itself using `loadExtension()` method and the fluent `ContainerConfigurator` helper:
```php
class FooBundle extends AbstractBundle
{
    public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
    {
        $container->parameters()
            ->set('foo', $config['foo']);

        $container->import('../config/services.php');

        if ('bar' === $config['foo']) {
            $container->services()
                ->set(Parser::class);
        }
    }
}
```
This new method `loadExtension()` (a same goal that `ExtensionInterface::load()`) contains now all new benefits you currently love for service definition/import/etc. Keep in mind that this configurator still works with a temporal container, so you can't access any extension config at this point (as before). And, the `$config` argument is the bundle's `Configuration` that you usually process on `ExtensionInterface::load()` but here it's given to you already merged and processed (ready to use).

---

**The next improvement** comes when you want to prepend/append an extension config before all extensions are loaded & merged, then use the `prependExtension()` method:
```php
class FooBundle extends AbstractBundle
{
    public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
    {
        // prepend
        $builder->prependExtensionConfig('framework', [
            'cache' => ['prefix_seed' => 'foo/bar'],
        ]);

        // append
        $container->extension('framework', [
            'cache' => ['prefix_seed' => 'foo/bar'],
        ])

        // append from file
        $container->import('../config/packages/cache.php');
    }
}
```
This is the improved alternative to `PrependExtensionInterface` that you normally implement on extension classes. But using this method has bonus points, you can now use the `ContainerConfigurator` to append an extension config from an external file in any format (including the new PHP fluent-config feature).

---

**Another improvement** is about `Configuration` definition. Here you can manage it directly within the bundle class using the `configuration()` method with new possibilities:
```php
class FooBundle extends AbstractBundle
{
    public function configure(DefinitionConfigurator $definition): void
    {
        // loads config definition from a file
        $definition->import('../config/definition.php');

        // loads config definition from multiple files (when it's too long you can split it)
        $definition->import('../config/definition/*.php');

        // defines config directly when it's short
        $definition->rootNode()
            ->children()
                ->scalarNode('foo')->defaultValue('bar')->end()
            ->end()
        ;
    }
}
```
You don't have to create the `TreeBuilder` instance yourself anymore and remember the proper extension alias. Instead, you will use a new `DefinitionConfigurator` with the possibility to import configuration definitions from an external PHP file, and this config file can now live outside the `src/` directory of the bundle if desired:
```php
// Acme/FooBundle/config/definition.php

use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;

return static function (DefinitionConfigurator $definition) {
    $definition->rootNode()
        ->children()
            ->scalarNode('foo')->defaultValue('bar')->end()
        ->end()
    ;
};
```
And why not, you could also split your definition into several files if it's too long, or simply define the config directly in the method if it's short.

---

**Last but not least** you can change the extension alias by redefining a new property that now belongs to the MicroBundle class:
```php
class AcmeFooBundle extends AbstractBundle
{
    protected string $extensionAlias = 'foo'; // alias used during the extension config loading

    // ...
}
```
The default alias will be determined from your bundle name (in this case `acme_foo`), so the new way allows you to change that alias without either touching your bundle name or overriding any method.

---

Note: The same feature has been implemented in a new `AbstractExtension` class for those applications applying the bundle-less approach and want to define configuration through an extension.

Combining all these benefits I believe we gain a more simplified bundle structure while decreasing the learning curve.

Commits
-------

7e8cf5d Simplifying bundle extension/config definition
  • Loading branch information
fabpot committed Mar 30, 2022
2 parents acee03f + 7e8cf5d commit 4e6b803
Show file tree
Hide file tree
Showing 25 changed files with 865 additions and 5 deletions.
2 changes: 2 additions & 0 deletions src/Symfony/Component/Config/CHANGELOG.md
Expand Up @@ -6,6 +6,8 @@ CHANGELOG

* Allow using environment variables in `EnumNode`
* Add Node's information in generated Config
* Add `DefinitionFileLoader` class to load a TreeBuilder definition from an external file
* Add `DefinitionConfigurator` helper

6.0
---
Expand Down
25 changes: 25 additions & 0 deletions src/Symfony/Component/Config/Definition/ConfigurableInterface.php
@@ -0,0 +1,25 @@
<?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\Config\Definition;

use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;

/**
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
interface ConfigurableInterface
{
/**
* Generates the configuration tree builder.
*/
public function configure(DefinitionConfigurator $definition): void;
}
45 changes: 45 additions & 0 deletions src/Symfony/Component/Config/Definition/Configuration.php
@@ -0,0 +1,45 @@
<?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\Config\Definition;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\Config\Definition\Loader\DefinitionFileLoader;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* @author Yonel Ceruto <yonelceruto@gmail.com>
*
* @final
*/
class Configuration implements ConfigurationInterface
{
public function __construct(
private ConfigurableInterface $subject,
private ?ContainerBuilder $container,
private string $alias,
) {
}

public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder($this->alias);
$file = (new \ReflectionObject($this->subject))->getFileName();
$loader = new DefinitionFileLoader($treeBuilder, new FileLocator(\dirname($file)), $this->container);
$configurator = new DefinitionConfigurator($treeBuilder, $loader, $file, $file);

$this->subject->configure($configurator);

return $treeBuilder;
}
}
@@ -0,0 +1,47 @@
<?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\Config\Definition\Configurator;

use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\Loader\DefinitionFileLoader;

/**
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class DefinitionConfigurator
{
public function __construct(
private TreeBuilder $treeBuilder,
private DefinitionFileLoader $loader,
private string $path,
private string $file,
) {
}

public function import(string $resource, string $type = null, bool $ignoreErrors = false): void
{
$this->loader->setCurrentDir(\dirname($this->path));
$this->loader->import($resource, $type, $ignoreErrors, $this->file);
}

public function rootNode(): NodeDefinition|ArrayNodeDefinition
{
return $this->treeBuilder->getRootNode();
}

public function setPathSeparator(string $separator): void
{
$this->treeBuilder->setPathSeparator($separator);
}
}
@@ -0,0 +1,109 @@
<?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\Config\Definition\Loader;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\Config\FileLocatorInterface;
use Symfony\Component\Config\Loader\FileLoader;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* DefinitionFileLoader loads config definitions from a PHP file.
*
* The PHP file is required.
*
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class DefinitionFileLoader extends FileLoader
{
public function __construct(
private TreeBuilder $treeBuilder,
FileLocatorInterface $locator,
private ?ContainerBuilder $container = null,
) {
parent::__construct($locator);
}

/**
* {@inheritdoc}
*/
public function load(mixed $resource, string $type = null): mixed
{
// the loader variable is exposed to the included file below
$loader = $this;

$path = $this->locator->locate($resource);
$this->setCurrentDir(\dirname($path));
$this->container?->fileExists($path);

// the closure forbids access to the private scope in the included file
$load = \Closure::bind(static function ($file) use ($loader) {
return include $file;
}, null, ProtectedDefinitionFileLoader::class);

$callback = $load($path);

if (\is_object($callback) && \is_callable($callback)) {
$this->executeCallback($callback, new DefinitionConfigurator($this->treeBuilder, $this, $path, $resource), $path);
}

return null;
}

/**
* {@inheritdoc}
*/
public function supports(mixed $resource, string $type = null): bool
{
if (!\is_string($resource)) {
return false;
}

if (null === $type && 'php' === pathinfo($resource, \PATHINFO_EXTENSION)) {
return true;
}

return 'php' === $type;
}

private function executeCallback(callable $callback, DefinitionConfigurator $configurator, string $path): void
{
$callback = $callback(...);

$arguments = [];
$r = new \ReflectionFunction($callback);

foreach ($r->getParameters() as $parameter) {
$reflectionType = $parameter->getType();

if (!$reflectionType instanceof \ReflectionNamedType) {
throw new \InvalidArgumentException(sprintf('Could not resolve argument "$%s" for "%s". You must typehint it (for example with "%s").', $parameter->getName(), $path, DefinitionConfigurator::class));
}

$arguments[] = match ($reflectionType->getName()) {
DefinitionConfigurator::class => $configurator,
TreeBuilder::class => $this->treeBuilder,
FileLoader::class, self::class => $this,
};
}

$callback(...$arguments);
}
}

/**
* @internal
*/
final class ProtectedDefinitionFileLoader extends DefinitionFileLoader
{
}
@@ -0,0 +1,42 @@
<?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\Config\Tests\Definition\Loader;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\BaseNode;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\Loader\DefinitionFileLoader;
use Symfony\Component\Config\FileLocator;

class DefinitionFileLoaderTest extends TestCase
{
public function testSupports()
{
$loader = new DefinitionFileLoader(new TreeBuilder('test'), new FileLocator());

$this->assertTrue($loader->supports('foo.php'), '->supports() returns true if the resource is loadable');
$this->assertFalse($loader->supports('foo.foo'), '->supports() returns false if the resource is not loadable');
$this->assertTrue($loader->supports('with_wrong_ext.yml', 'php'), '->supports() returns true if the resource with forced type is loadable');
}

public function testLoad()
{
$loader = new DefinitionFileLoader($treeBuilder = new TreeBuilder('test'), new FileLocator());
$loader->load(__DIR__.'/../../Fixtures/Loader/node_simple.php');

$children = $treeBuilder->buildTree()->getChildren();

$this->assertArrayHasKey('foo', $children);
$this->assertInstanceOf(BaseNode::class, $children['foo']);
$this->assertSame('test.foo', $children['foo']->getPath(), '->load() loads a PHP file resource');
}
}
10 changes: 10 additions & 0 deletions src/Symfony/Component/Config/Tests/Fixtures/Loader/node_simple.php
@@ -0,0 +1,10 @@
<?php

use Symfony\Component\Config\Definition\Builder\TreeBuilder;

return static function (TreeBuilder $treeBuilder) {
$treeBuilder->getRootNode()
->children()
->scalarNode('foo')->end()
->end();
};
1 change: 1 addition & 0 deletions src/Symfony/Component/DependencyInjection/CHANGELOG.md
Expand Up @@ -11,6 +11,7 @@ CHANGELOG
* Allow using expressions as service factories
* Add argument type `closure` to help passing closures to services
* Deprecate `ReferenceSetArgumentTrait`
* Add `AbstractExtension` class for DI configuration/definition on a single file

6.0
---
Expand Down
@@ -0,0 +1,65 @@
<?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\Extension;

use Symfony\Component\Config\Definition\Configuration;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

/**
* An Extension that provides configuration hooks.
*
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
abstract class AbstractExtension extends Extension implements ConfigurableExtensionInterface, PrependExtensionInterface
{
use ExtensionTrait;

public function configure(DefinitionConfigurator $definition): void
{
}

public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
{
}

public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
}

public function getConfiguration(array $config, ContainerBuilder $container): ?ConfigurationInterface
{
return new Configuration($this, $container, $this->getAlias());
}

final public function prepend(ContainerBuilder $container): void
{
$callback = function (ContainerConfigurator $configurator) use ($container) {
$this->prependExtension($configurator, $container);
};

$this->executeConfiguratorCallback($container, $callback, $this);
}

final public function load(array $configs, ContainerBuilder $container): void
{
$config = $this->processConfiguration($this->getConfiguration([], $container), $configs);

$callback = function (ContainerConfigurator $configurator) use ($config, $container) {
$this->loadExtension($config, $configurator, $container);
};

$this->executeConfiguratorCallback($container, $callback, $this);
}
}
@@ -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\DependencyInjection\Extension;

use Symfony\Component\Config\Definition\ConfigurableInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

/**
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
interface ConfigurableExtensionInterface extends ConfigurableInterface
{
/**
* Allow an extension to prepend the extension configurations.
*/
public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void;

/**
* Loads a specific configuration.
*/
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void;
}

0 comments on commit 4e6b803

Please sign in to comment.