From 2d74a76b023079e24e3e8831a272a2fe8b6596e1 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 8 Mar 2023 14:39:23 +0100 Subject: [PATCH] [DependencyInjection] Add support for generating lazy closures --- .../Argument/LazyClosure.php | 60 ++++++++++++++++++ .../DependencyInjection/CHANGELOG.md | 1 + .../DependencyInjection/ContainerBuilder.php | 34 +++++++++- .../DependencyInjection/Dumper/PhpDumper.php | 21 +++++++ .../Tests/ContainerBuilderTest.php | 19 ++++++ .../Tests/Dumper/PhpDumperTest.php | 26 ++++++++ .../Fixtures/includes/autowiring_classes.php | 6 +- .../Tests/Fixtures/php/lazy_closure.php | 62 +++++++++++++++++++ 8 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/lazy_closure.php diff --git a/src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php b/src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php new file mode 100644 index 0000000000000..7b001352ac8bd --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Argument/LazyClosure.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Argument; + +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class LazyClosure +{ + public readonly object $service; + + public function __construct( + private \Closure $initializer, + ) { + unset($this->service); + } + + public function __get(mixed $name): mixed + { + if ('service' !== $name) { + throw new InvalidArgumentException(sprintf('Cannot read property "%s" from a lazy closure.', $name)); + } + + if (isset($this->initializer)) { + $this->service = ($this->initializer)(); + unset($this->initializer); + } + + return $this->service; + } + + public static function getCode(string $initializer, ?\ReflectionClass $r, string $method, ?string $id): string + { + if (!$r || !$r->hasMethod($method)) { + throw new RuntimeException(sprintf('Cannot create lazy closure for service "%s" because its corresponding callable is invalid.', $id)); + } + + $signature = ProxyHelper::exportSignature($r->getMethod($method)); + $signature = preg_replace('/: static$/', ': \\'.$r->name, $signature); + + return '(new class('.$initializer.') extends \\'.self::class.' { ' + .$signature.' { return $this->service->'.$method.'(...\func_get_args()); } ' + .'})->'.$method.'(...)'; + } +} diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index 59be45c99780d..d4b14d68d43a9 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -15,6 +15,7 @@ CHANGELOG * Allow to trim XML service parameters value by using `trim="true"` attribute * Allow extending the `Autowire` attribute * Add `#[Exclude]` to skip autoregistering a class + * Add support for generating lazy closures * Add support for autowiring services as closures using `#[AutowireCallable]` or `#[AutowireServiceClosure]` * Deprecate `#[MapDecorated]`, use `#[AutowireDecorated]` instead * Deprecate the `@required` annotation, use the `Symfony\Contracts\Service\Attribute\Required` attribute instead diff --git a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php index e920980b286e7..57c0234725f7c 100644 --- a/src/Symfony/Component/DependencyInjection/ContainerBuilder.php +++ b/src/Symfony/Component/DependencyInjection/ContainerBuilder.php @@ -22,6 +22,7 @@ use Symfony\Component\Config\Resource\ResourceInterface; use Symfony\Component\DependencyInjection\Argument\AbstractArgument; use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Argument\LazyClosure; use Symfony\Component\DependencyInjection\Argument\RewindableGenerator; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Argument\ServiceLocator; @@ -1050,13 +1051,40 @@ private function createService(Definition $definition, array &$inlineServices, b } $parameterBag = $this->getParameterBag(); + $class = ($parameterBag->resolveValue($definition->getClass()) ?: (['Closure', 'fromCallable'] === $definition->getFactory() ? 'Closure' : null)); - if (true === $tryProxy && $definition->isLazy() && !$tryProxy = !($proxy = $this->proxyInstantiator ??= new LazyServiceInstantiator()) || $proxy instanceof RealServiceInstantiator) { + if ('Closure' === $class && $definition->isLazy() && ['Closure', 'fromCallable'] === $definition->getFactory()) { + $callable = $parameterBag->unescapeValue($parameterBag->resolveValue($definition->getArgument(0))); + + if ($callable instanceof Reference || $callable instanceof Definition) { + $callable = [$callable, '__invoke']; + } + + if (\is_array($callable) && ( + $callable[0] instanceof Reference + || $callable[0] instanceof Definition && !isset($inlineServices[spl_object_hash($callable[0])]) + )) { + $containerRef = $this->containerRef ??= \WeakReference::create($this); + $class = ($callable[0] instanceof Reference ? $this->findDefinition($callable[0]) : $callable[0])->getClass(); + $initializer = static function () use ($containerRef, $callable, &$inlineServices) { + return $containerRef->get()->doResolveServices($callable[0], $inlineServices); + }; + + $proxy = eval('return '.LazyClosure::getCode('$initializer', $this->getReflectionClass($class), $callable[1], $id).';'); + $this->shareService($definition, $proxy, $id, $inlineServices); + + return $proxy; + } + } + + if (true === $tryProxy && $definition->isLazy() && 'Closure' !== $class + && !$tryProxy = !($proxy = $this->proxyInstantiator ??= new LazyServiceInstantiator()) || $proxy instanceof RealServiceInstantiator + ) { $containerRef = $this->containerRef ??= \WeakReference::create($this); $proxy = $proxy->instantiateProxy( $this, (clone $definition) - ->setClass($parameterBag->resolveValue($definition->getClass())) + ->setClass($class) ->setTags(($definition->hasTag('proxy') ? ['proxy' => $parameterBag->resolveValue($definition->getTag('proxy'))] : []) + $definition->getTags()), $id, static function ($proxy = false) use ($containerRef, $definition, &$inlineServices, $id) { return $containerRef->get()->createService($definition, $inlineServices, true, $id, $proxy); @@ -1105,7 +1133,7 @@ private function createService(Definition $definition, array &$inlineServices, b } } } else { - $r = new \ReflectionClass($parameterBag->resolveValue($definition->getClass())); + $r = new \ReflectionClass($class); if (\is_object($tryProxy)) { if ($r->getConstructor()) { diff --git a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php index b7ded580ebaf6..7f8054675a749 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php @@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Argument\AbstractArgument; use Symfony\Component\DependencyInjection\Argument\ArgumentInterface; use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Argument\LazyClosure; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Argument\ServiceLocator; use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; @@ -1179,6 +1180,22 @@ private function addNewInstance(Definition $definition, string $return = '', str throw new RuntimeException(sprintf('Cannot dump definition because of invalid factory method (%s).', $callable[1] ?: 'n/a')); } + if (['...'] === $arguments && $definition->isLazy() && 'Closure' === ($definition->getClass() ?? 'Closure') && ( + $callable[0] instanceof Reference + || ($callable[0] instanceof Definition && !$this->definitionVariables->contains($callable[0])) + )) { + $class = ($callable[0] instanceof Reference ? $this->container->findDefinition($callable[0]) : $callable[0])->getClass(); + + if (str_contains($initializer = $this->dumpValue($callable[0]), '$container')) { + $this->addContainerRef = true; + $initializer = sprintf('function () use ($containerRef) { $container = $containerRef; return %s; }', $initializer); + } else { + $initializer = 'fn () => '.$initializer; + } + + return $return.LazyClosure::getCode($initializer, $this->container->getReflectionClass($class), $callable[1], $id).$tail; + } + if ($callable[0] instanceof Reference || ($callable[0] instanceof Definition && $this->definitionVariables->contains($callable[0])) ) { @@ -2327,6 +2344,10 @@ private function isProxyCandidate(Definition $definition, ?bool &$asGhostObject, { $asGhostObject = false; + if ('Closure' === ($definition->getClass() ?: (['Closure', 'fromCallable'] === $definition->getFactory() ? 'Closure' : null))) { + return null; + } + if (!$definition->isLazy() || !$this->hasProxyDumper) { return null; } diff --git a/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php b/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php index 4ec0624b86288..1491e8843687e 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/ContainerBuilderTest.php @@ -1985,6 +1985,25 @@ public function testNamedArgumentBeforeCompile() $this->assertSame(1, $e->first); } + + public function testLazyClosure() + { + $container = new ContainerBuilder(); + $container->register('closure', 'Closure') + ->setPublic('true') + ->setFactory(['Closure', 'fromCallable']) + ->setLazy(true) + ->setArguments([[new Reference('foo'), 'cloneFoo']]); + $container->register('foo', Foo::class); + $container->compile(); + + $cloned = Foo::$counter; + $this->assertInstanceOf(\Closure::class, $container->get('closure')); + $this->assertSame($cloned, Foo::$counter); + $this->assertInstanceOf(Foo::class, $container->get('closure')()); + $this->assertSame(1 + $cloned, Foo::$counter); + $this->assertSame(1, (new \ReflectionFunction($container->get('closure')))->getNumberOfParameters()); + } } class FooClass diff --git a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php index 1cc9d514fc37a..9eb3f40eb338e 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php @@ -1719,6 +1719,32 @@ public function testAutowireClosure() $this->assertInstanceOf(Foo::class, $fooClone = ($bar->buz)()); $this->assertNotSame($container->get('foo'), $fooClone); } + + public function testLazyClosure() + { + $container = new ContainerBuilder(); + $container->register('closure', 'Closure') + ->setPublic('true') + ->setFactory(['Closure', 'fromCallable']) + ->setLazy(true) + ->setArguments([[new Reference('foo'), 'cloneFoo']]); + $container->register('foo', Foo::class); + $container->compile(); + $dumper = new PhpDumper($container); + + $this->assertStringEqualsFile(self::$fixturesPath.'/php/lazy_closure.php', $dumper->dump(['class' => 'Symfony_DI_PhpDumper_Test_Lazy_Closure'])); + + require self::$fixturesPath.'/php/lazy_closure.php'; + + $container = new \Symfony_DI_PhpDumper_Test_Lazy_Closure(); + + $cloned = Foo::$counter; + $this->assertInstanceOf(\Closure::class, $container->get('closure')); + $this->assertSame($cloned, Foo::$counter); + $this->assertInstanceOf(Foo::class, $container->get('closure')()); + $this->assertSame(1 + $cloned, Foo::$counter); + $this->assertSame(1, (new \ReflectionFunction($container->get('closure')))->getNumberOfParameters()); + } } class Rot13EnvVarProcessor implements EnvVarProcessorInterface diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php index 70c46ecb4fe64..edbf86bafe6c3 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php @@ -26,9 +26,13 @@ public function cloneFoo(): static class Foo { + public static int $counter = 0; + #[Required] - public function cloneFoo(): static + public function cloneFoo(\stdClass $bar = null): static { + ++self::$counter; + return clone $this; } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/lazy_closure.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/lazy_closure.php new file mode 100644 index 0000000000000..e7783b6f90a1a --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/lazy_closure.php @@ -0,0 +1,62 @@ +ref = \WeakReference::create($this); + $this->services = $this->privates = []; + $this->methodMap = [ + 'closure' => 'getClosureService', + ]; + + $this->aliases = []; + } + + public function compile(): void + { + throw new LogicException('You cannot compile a dumped container that was already compiled.'); + } + + public function isCompiled(): bool + { + return true; + } + + public function getRemovedIds(): array + { + return [ + 'foo' => true, + ]; + } + + protected function createProxy($class, \Closure $factory) + { + return $factory(); + } + + /** + * Gets the public 'closure' shared service. + * + * @return \Closure + */ + protected static function getClosureService($container, $lazyLoad = true) + { + return $container->services['closure'] = (new class(fn () => new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo()) extends \Symfony\Component\DependencyInjection\Argument\LazyClosure { public function cloneFoo(?\stdClass $bar = null): \Symfony\Component\DependencyInjection\Tests\Compiler\Foo { return $this->service->cloneFoo(...\func_get_args()); } })->cloneFoo(...); + } +}