Skip to content
Permalink
Browse files

feature #21289 [DI] Add prototype services for PSR4-based discovery a…

…nd registration (nicolas-grekas)

This PR was merged into the 3.3-dev branch.

Discussion
----------

[DI] Add prototype services for PSR4-based discovery and registration

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

Talking with @dunglas, we wondered if this could be a good idea, as a more general approach to folder-based service registration as done in [DunglasActionBundle](https://github.com/dunglas/DunglasActionBundle/blob/master/DependencyInjection/DunglasActionExtension.php).

So here is the implementation.

This allows one to define a set of services as such:
```yaml
services:
    App\:
        resources: ../src/{Controller,Command} # relative to the current file as usual
        autowire: true # or any other attributes really
```

This looks for php files in the "src" folder, derivates PSR-4 class names from them, and uses `class_exists` for final discovery. The resulting services are named after the classes found this way.

The "resource" attribute can be a glob to select only a subset of the classes/files.

This approach has several advantages over [DunglasActionBundle](https://github.com/dunglas/DunglasActionBundle/blob/master/DependencyInjection/DunglasActionExtension.php):
- it is resilient to missing parent classes (see test case)
- it loads classes using the normal code path (the autoloader), thus play well with them (e.g. if one registered a special autoloader).
- it is more predictable, because it uses discovered paths as the only source of ids/classes to register - vs relying on `get_declared_classes`, which would make things context sensitive.

Fits well with current initiatives to me.

Commits
-------

03470b7 [DI] Add "psr4" service attribute for PSR4-based discovery and registration
  • Loading branch information...
fabpot committed Feb 13, 2017
2 parents 6d3d17d + 03470b7 commit 91904af9027f999ceb519a6c4594094f39902ed9
@@ -32,7 +32,7 @@ abstract class FileLoader extends Loader
*/
protected $locator;
private $currentDir;
protected $currentDir;
/**
* Constructor.
@@ -4,6 +4,7 @@ CHANGELOG
3.3.0
-----

* [EXPERIMENTAL] added prototype services for PSR4-based discovery and registration
* added `ContainerBuilder::getReflectionClass()` for retrieving and tracking reflection class info
* deprecated `ContainerBuilder::getClassResource()`, use `ContainerBuilder::getReflectionClass()` or `ContainerBuilder::addObjectResource()` instead
* added `ContainerBuilder::fileExists()` for checking and tracking file or directory existence
@@ -12,8 +12,13 @@
namespace Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\Config\Loader\FileLoader as BaseFileLoader;
use Symfony\Component\Config\FileLocatorInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\Glob;
/**
* FileLoader is the abstract class used by all built-in loaders that are file based.
@@ -34,4 +39,108 @@ public function __construct(ContainerBuilder $container, FileLocatorInterface $l
parent::__construct($locator);
}
/**
* Registers a set of classes as services using PSR-4 for discovery.
*
* @param Definition $prototype A definition to use as template
* @param string $namespace The namespace prefix of classes in the scanned directory
* @param string $resource The directory to look for classes, glob-patterns allowed
*
* @experimental in version 3.3
*/
public function registerClasses(Definition $prototype, $namespace, $resource)
{
if ('\\' !== substr($namespace, -1)) {
throw new InvalidArgumentException(sprintf('Namespace prefix must end with a "\\": %s.', $namespace));
}
if (!preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++$/', $namespace)) {
throw new InvalidArgumentException(sprintf('Namespace is not a valid PSR-4 prefix: %s.', $namespace));
}
$classes = $this->findClasses($namespace, $resource);
// prepare for deep cloning
$prototype = serialize($prototype);
foreach ($classes as $class) {
$this->container->setDefinition($class, unserialize($prototype));
}
}
private function findClasses($namespace, $resource)
{
$classes = array();
$extRegexp = defined('HHVM_VERSION') ? '/\\.(?:php|hh)$/' : '/\\.php$/';
foreach ($this->glob($resource, true, $prefixLen) as $path => $info) {
if (!preg_match($extRegexp, $path, $m) || !$info->isFile() || !$info->isReadable()) {
continue;
}
$class = $namespace.ltrim(str_replace('/', '\\', substr($path, $prefixLen, -strlen($m[0]))), '\\');
if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+$/', $class)) {
continue;
}
if (!$r = $this->container->getReflectionClass($class, true)) {
continue;
}
if (!$r->isInterface() && !$r->isTrait()) {
$classes[] = $class;
}
}
return $classes;
}
private function glob($resource, $recursive, &$prefixLen = null)
{
if (strlen($resource) === $i = strcspn($resource, '*?{[')) {
$resourcePrefix = $resource;
$resource = '';
} elseif (0 === $i) {
$resourcePrefix = '.';
$resource = '/'.$resource;
} else {
$resourcePrefix = dirname(substr($resource, 0, 1 + $i));
$resource = substr($resource, strlen($resourcePrefix));
}
$resourcePrefix = $this->locator->locate($resourcePrefix, $this->currentDir, true);
$resourcePrefix = realpath($resourcePrefix) ?: $resourcePrefix;
$prefixLen = strlen($resourcePrefix);
// track directories only for new & removed files
$this->container->fileExists($resourcePrefix, '/^$/');
if (false === strpos($resource, '/**/') && (defined('GLOB_BRACE') || false === strpos($resource, '{'))) {
foreach (glob($resourcePrefix.$resource, defined('GLOB_BRACE') ? GLOB_BRACE : 0) as $path) {
if ($recursive && is_dir($path)) {
$flags = \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS;
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, $flags)) as $path => $info) {
yield $path => $info;
}
} else {
yield $path => new \SplFileInfo($path);
}
}
return;
}
if (!class_exists(Finder::class)) {
throw new LogicException(sprintf('Extended glob pattern "%s" cannot be used as the Finder component is not installed.', $resource));
}
$finder = new Finder();
$regex = Glob::toRegex($resource);
if ($recursive) {
$regex = substr_replace($regex, '(/|$)', -2, 1);
}
foreach ($finder->followLinks()->in($resourcePrefix) as $path => $info) {
if (preg_match($regex, substr($path, $prefixLen))) {
yield $path => $info;
}
}
}
}
@@ -121,14 +121,19 @@ private function parseDefinitions(\DOMDocument $xml, $file)
$xpath = new \DOMXPath($xml);
$xpath->registerNamespace('container', self::NS);
if (false === $services = $xpath->query('//container:services/container:service')) {
if (false === $services = $xpath->query('//container:services/container:service|//container:services/container:prototype')) {
return;
}
$this->setCurrentDir(dirname($file));
$defaults = $this->getServiceDefaults($xml, $file);
foreach ($services as $service) {
if (null !== $definition = $this->parseDefinition($service, $file, $defaults)) {
$this->container->setDefinition((string) $service->getAttribute('id'), $definition);
if ('prototype' === $service->tagName) {
$this->registerClasses($definition, (string) $service->getAttribute('namespace'), (string) $service->getAttribute('resource'));
} else {
$this->container->setDefinition((string) $service->getAttribute('id'), $definition);
}
}
}
}
@@ -36,7 +36,7 @@
*/
class YamlFileLoader extends FileLoader
{
private static $keywords = array(
private static $serviceKeywords = array(
'alias' => 'alias',
'parent' => 'parent',
'class' => 'class',
@@ -62,6 +62,32 @@ class YamlFileLoader extends FileLoader
'autowiring_types' => 'autowiring_types',
);
private static $prototypeKeywords = array(
'resource' => 'resource',
'parent' => 'parent',
'shared' => 'shared',
'lazy' => 'lazy',
'public' => 'public',
'abstract' => 'abstract',
'deprecated' => 'deprecated',
'factory' => 'factory',
'arguments' => 'arguments',
'properties' => 'properties',
'getters' => 'getters',
'configurator' => 'configurator',
'calls' => 'calls',
'tags' => 'tags',
'inherit_tags' => 'inherit_tags',
'autowire' => 'autowire',
);
private static $defaultsKeywords = array(
'public' => 'public',
'tags' => 'tags',
'inherit_tags' => 'inherit_tags',
'autowire' => 'autowire',
);
private $yamlParser;
/**
@@ -98,6 +124,7 @@ public function load($resource, $type = null)
$this->loadFromExtensions($content);
// services
$this->setCurrentDir(dirname($path));
$this->parseDefinitions($content, $resource);
}
@@ -188,12 +215,11 @@ private function parseDefaults(array &$content, $file)
return array();
}
$defaultKeys = array('public', 'tags', 'inherit_tags', 'autowire');
unset($content['services']['_defaults']);
foreach ($defaults as $key => $default) {
if (!in_array($key, $defaultKeys)) {
throw new InvalidArgumentException(sprintf('The configuration key "%s" cannot be used to define a default value in "%s". Allowed keys are "%s".', $key, $file, implode('", "', $defaultKeys)));
if (!isset(self::$defaultsKeywords[$key])) {
throw new InvalidArgumentException(sprintf('The configuration key "%s" cannot be used to define a default value in "%s". Allowed keys are "%s".', $key, $file, implode('", "', self::$defaultsKeywords)));
}
}
if (!isset($defaults['tags'])) {
@@ -443,7 +469,14 @@ private function parseDefinition($id, $service, $file, array $defaults)
}
}
$this->container->setDefinition($id, $definition);
if (array_key_exists('resource', $service)) {
if (!is_string($service['resource'])) {
throw new InvalidArgumentException(sprintf('A "resource" attribute must be of type string for service "%s" in %s. Check your YAML syntax.', $id, $file));
}
$this->registerClasses($definition, $id, $service['resource']);
} else {
$this->container->setDefinition($id, $definition);
}
}
/**
@@ -660,13 +693,19 @@ private function loadFromExtensions(array $content)
*/
private static function checkDefinition($id, array $definition, $file)
{
if ($throw = isset($definition['resource'])) {
$keywords = static::$prototypeKeywords;
} else {
$keywords = static::$serviceKeywords;
}
foreach ($definition as $key => $value) {
if (!isset(static::$keywords[$key])) {
@trigger_error(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s". The YamlFileLoader object will raise an exception instead in Symfony 4.0 when detecting an unsupported service configuration key.', $key, $id, $file, implode('", "', static::$keywords)), E_USER_DEPRECATED);
// @deprecated Uncomment the following statement in Symfony 4.0
// and also update the corresponding unit test to make it expect
// an InvalidArgumentException exception.
//throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s".', $key, $id, $file, implode('", "', static::$keywords)));
if (!isset($keywords[$key])) {
if ($throw) {
throw new InvalidArgumentException(sprintf('The configuration key "%s" is unsupported for definition "%s" in "%s". Allowed configuration keys are "%s".', $key, $id, $file, implode('", "', $keywords)));
}
@trigger_error(sprintf('The configuration key "%s" is unsupported for service definition "%s" in "%s". Allowed configuration keys are "%s". The YamlFileLoader object will raise an exception instead in Symfony 4.0 when detecting an unsupported service configuration key.', $key, $id, $file, implode('", "', $keywords)), E_USER_DEPRECATED);
}
}
}
@@ -54,6 +54,7 @@
</xsd:annotation>
<xsd:choice maxOccurs="unbounded">
<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:choice>
</xsd:complexType>
@@ -136,6 +137,29 @@
<xsd:attribute name="inherit-tags" type="boolean" />
</xsd:complexType>

<xsd:complexType name="prototype">
<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="namespace" type="xsd:string" use="required" />
<xsd:attribute name="resource" 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="parent" type="xsd:string" />
<xsd:attribute name="autowire" type="boolean" />
<xsd:attribute name="inherit-tags" type="boolean" />
</xsd:complexType>

<xsd:complexType name="tag">
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:anyAttribute namespace="##any" processContents="lax" />
@@ -0,0 +1,7 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
class Foo
{
}
@@ -0,0 +1,7 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
class MissingParent extends NotExistingParent
{
}
@@ -0,0 +1,7 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub;
class Bar
{
}
@@ -0,0 +1,6 @@
<?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>
<prototype namespace="Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\" resource="../Prototype/*" />
</services>
</container>
@@ -0,0 +1,3 @@
services:
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\:
resource: ../Prototype
@@ -20,7 +20,10 @@
use Symfony\Component\DependencyInjection\Loader\IniFileLoader;
use Symfony\Component\Config\Loader\LoaderResolver;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Config\Resource\DirectoryResource;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass;
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
use Symfony\Component\ExpressionLanguage\Expression;
class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase
@@ -608,6 +611,26 @@ public function testClassFromId()
$this->assertEquals(CaseSensitiveClass::class, $container->getDefinition(CaseSensitiveClass::class)->getClass());
}
public function testPrototype()
{
$container = new ContainerBuilder();
$loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml'));
$loader->load('services_prototype.xml');
$ids = array_keys($container->getDefinitions());
sort($ids);
$this->assertSame(array(Prototype\Foo::class, Prototype\Sub\Bar::class), $ids);
$resources = $container->getResources();
$fixturesDir = dirname(__DIR__).DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR;
$this->assertTrue(false !== array_search(new FileResource($fixturesDir.'xml'.DIRECTORY_SEPARATOR.'services_prototype.xml'), $resources));
$this->assertTrue(false !== array_search(new DirectoryResource($fixturesDir.'Prototype', '/^$/'), $resources));
$resources = array_map('strval', $resources);
$this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Foo', $resources);
$this->assertContains('reflection.Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub\Bar', $resources);
}
/**
* @group legacy
* @expectedDeprecation Using the attribute "class" is deprecated for the service "bar" which is defined as an alias %s.

0 comments on commit 91904af

Please sign in to comment.
You can’t perform that action at this time.