[DI] Add "psr4" service attribute for PSR4-based discovery and registration

This commit is contained in:
Nicolas Grekas 2017-01-14 12:46:46 +01:00
parent 5a388042bb
commit 03470b788e
15 changed files with 273 additions and 17 deletions

View File

@ -32,7 +32,7 @@ abstract class FileLoader extends Loader
*/
protected $locator;
private $currentDir;
protected $currentDir;
/**
* Constructor.

View File

@ -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

View File

@ -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 @@ abstract class FileLoader extends BaseFileLoader
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;
}
}
}
}

View File

@ -121,14 +121,19 @@ class XmlFileLoader extends FileLoader
$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);
}
}
}
}

View File

@ -36,7 +36,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
*/
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 @@ class YamlFileLoader extends FileLoader
$this->loadFromExtensions($content);
// services
$this->setCurrentDir(dirname($path));
$this->parseDefinitions($content, $resource);
}
@ -188,12 +215,11 @@ class YamlFileLoader extends FileLoader
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 @@ class YamlFileLoader extends FileLoader
}
}
$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 @@ class YamlFileLoader extends FileLoader
*/
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);
}
}
}

View File

@ -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" />

View File

@ -0,0 +1,7 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
class Foo
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
class MissingParent extends NotExistingParent
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\Sub;
class Bar
{
}

View File

@ -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>

View File

@ -0,0 +1,3 @@
services:
Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\:
resource: ../Prototype

View File

@ -20,7 +20,10 @@ use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
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 @@ class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase
$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.

View File

@ -20,7 +20,10 @@ use Symfony\Component\DependencyInjection\Loader\IniFileLoader;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
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 YamlFileLoaderTest extends \PHPUnit_Framework_TestCase
@ -372,6 +375,26 @@ class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals(CaseSensitiveClass::class, $container->getDefinition(CaseSensitiveClass::class)->getClass());
}
public function testPrototype()
{
$container = new ContainerBuilder();
$loader = new YamlFileLoader($container, new FileLocator(self::$fixturesPath.'/yaml'));
$loader->load('services_prototype.yml');
$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.'yaml'.DIRECTORY_SEPARATOR.'services_prototype.yml'), $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);
}
public function testDefaults()
{
$container = new ContainerBuilder();

View File

@ -27,12 +27,14 @@
"suggest": {
"symfony/yaml": "",
"symfony/config": "",
"symfony/finder": "For using double-star glob patterns or when GLOB_BRACE portability is required",
"symfony/expression-language": "For using expressions in service container configuration",
"symfony/proxy-manager-bridge": "Generate service proxies to lazy load them"
},
"conflict": {
"symfony/yaml": "<3.3",
"symfony/config": "<3.3"
"symfony/config": "<3.3",
"symfony/finder": "<3.3",
"symfony/yaml": "<3.3"
},
"provide": {
"psr/container-implementation": "1.0"

View File

@ -61,7 +61,7 @@ class Glob
$firstByte = '/' === $car;
if ($firstByte && $strictWildcardSlash && isset($glob[$i + 3]) && '**/' === $glob[$i + 1].$glob[$i + 2].$glob[$i + 3]) {
$car = $strictLeadingDot ? '/((?=[^\.])[^/]+/)*' : '/([^/]+/)*';
$car = $strictLeadingDot ? '/(?:(?=[^\.])[^/]++/)*' : '/(?:[^/]++/)*';
$i += 3;
if ('/' === $delimiter) {
$car = str_replace('/', '\\/', $car);