diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 9de0854cc5..3a6f34a896 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -173,6 +173,8 @@ final class RouteBindingInitializer implements DynamicInitializer } ``` +The container remembers the outcome of `canInitialize` for every class and tag, so it should only depend on those two arguments. + ## Autowired dependencies When you need to assign a default implementation to an interface without any specific instantiation steps, creating an initializer class for a single line of code might feel excessive. diff --git a/packages/container/src/DynamicInitializer.php b/packages/container/src/DynamicInitializer.php index 6bfe2aefe6..0a4d857700 100644 --- a/packages/container/src/DynamicInitializer.php +++ b/packages/container/src/DynamicInitializer.php @@ -9,6 +9,9 @@ interface DynamicInitializer { + /** + * Determines whether this initializer can initialize the given class and tag. The outcome is remembered by the container, so it should only depend on the class and tag. + */ public function canInitialize(ClassReflector $class, string|UnitEnum|null $tag): bool; public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): object; diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 8901e4900b..40037bfa2e 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -5,6 +5,7 @@ namespace Tempest\Container; use ArrayIterator; +use ArrayObject; use Closure; use Psr\Container\ContainerInterface; use ReflectionFunction; @@ -18,6 +19,7 @@ use Tempest\Reflection\FunctionReflector; use Tempest\Reflection\MethodReflector; use Tempest\Reflection\ParameterReflector; +use Tempest\Reflection\PropertyReflector; use Tempest\Reflection\TypeReflector; use Throwable; use UnitEnum; @@ -28,8 +30,42 @@ final class GenericContainer implements Container { use HasInstance; - /** @var array, DynamicInitializer> */ - private array $resolvedDynamicInitializers = []; + /** + * Which initializer can initialize a given class and tag, or `false` when there is none. Unlike resolved + * instances, this is kept when the container is reset, since it only depends on the registered initializers. + * + * These are objects so that they are shared with clones of this container, and are only created once needed, + * to keep constructing a container cheap. + * + * @var ArrayObject|false>|null + */ + private ?ArrayObject $initializerLookups = null; + + /** @var ArrayObject|null */ + private ?ArrayObject $resolvedInitializers = null; + + /* + * Reflection data only depends on class definitions, which can't change at runtime, + * so it is cached once and shared between all containers. + */ + + /** @var array */ + private static array $classReflectors = []; + + /** @var array */ + private static array $constructors = []; + + /** @var array> */ + private static array $parameters = []; + + /** @var array> */ + private static array $injectedProperties = []; + + /** @var array */ + private static array $autowiredSingletons = []; + + /** @var array */ + private static array $initializerSingletons = []; public function __construct( /** @var ArrayIterator $definitions */ @@ -77,6 +113,7 @@ public function setSingletons(array $singletons): self public function setInitializers(array $initializers): self { $this->initializers = new ArrayIterator($initializers); + $this->initializerLookups?->exchangeArray([]); return $this; } @@ -84,7 +121,7 @@ public function setInitializers(array $initializers): self public function setDynamicInitializers(array $dynamicInitializers): self { $this->dynamicInitializers = new ArrayIterator($dynamicInitializers); - $this->resolvedDynamicInitializers = []; + $this->initializerLookups?->exchangeArray([]); return $this; } @@ -313,6 +350,8 @@ public function addInitializer(ClassReflector|string $initializerClass): Contain // First, we check whether this is a DynamicInitializer, // which don't have a one-to-one mapping + $this->initializerLookups?->exchangeArray([]); + if ($initializerClass->getType()->matches(DynamicInitializer::class)) { $this->dynamicInitializers[] = $initializerClass->getName(); @@ -348,12 +387,15 @@ public function removeInitializer(ClassReflector|string $initializerClass): Cont ); unset($this->dynamicInitializers[$index]); - unset($this->resolvedDynamicInitializers[$initializerClass->getName()]); + $this->resolvedInitializers?->offsetUnset($initializerClass->getName()); + $this->initializerLookups?->exchangeArray([]); return $this; } unset($this->initializers[$initializerClass->getName()]); + $this->resolvedInitializers?->offsetUnset($initializerClass->getName()); + $this->initializerLookups?->exchangeArray([]); return $this; } @@ -368,6 +410,11 @@ public function addDecorator(ClassReflector|string $decoratorClass, ClassReflect return $this; } + /** + * @template TClassName of object + * @param class-string $className + * @return TClassName + */ private function resolve(string $className, string|UnitEnum|null $tag = null, mixed ...$params): object { $instance = $this->resolveDependency($className, $tag, ...$params); @@ -381,8 +428,6 @@ private function resolve(string $className, string|UnitEnum|null $tag = null, mi private function resolveDependency(string $className, string|UnitEnum|null $tag = null, mixed ...$params): object { - $class = new ClassReflector($className); - $dependencyName = $this->resolveTaggedName($className, $tag); // Check if a resolved singleton is present. A singleton is only registered once it is fully @@ -391,6 +436,8 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag return $instance; } + $class = self::reflect($className); + // Check if the class has been registered as a singleton. if ($singletonDefinition = $this->singletonDefinitions[$dependencyName] ?? null) { $this->resolveChain()->add($class); @@ -419,7 +466,7 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag // Next we check if any of our default initializers can initialize this class. if (($initializer = $this->initializerForClass($class, $tag)) !== null) { - $initializerClass = new ClassReflector($initializer); + $initializerClass = self::reflect($initializer::class); $this->resolveChain()->add($initializerClass); @@ -432,9 +479,7 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $this->resolveChain()->pop(); } - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class); - - if ($singleton !== null) { + if (self::initializerSingleton($initializerClass) !== null) { $this->singleton($className, $object, $tag); } @@ -457,16 +502,40 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag return $this->autowire($className, null, ...$params); } - private function initializerForBuiltin(TypeReflector $target, string $tag): ?Initializer + private function initializerForBuiltin(TypeReflector $target, string|UnitEnum $tag): ?Initializer { if ($initializerClass = $this->initializers[$this->resolveTaggedName($target->getName(), $tag)] ?? null) { - return $this->resolve($initializerClass); + return $this->resolveInitializer($initializerClass); } return null; } private function initializerForClass(ClassReflector $target, string|UnitEnum|null $tag = null): Initializer|DynamicInitializer|null + { + // Most classes don't have an initializer, and finding out requires asking every dynamic initializer, + // so the outcome is remembered for every class and tag. + $lookup = $this->resolveTaggedName($target->getName(), $tag); + + $this->initializerLookups ??= new ArrayObject(); + + if (! isset($this->initializerLookups[$lookup])) { + $this->initializerLookups[$lookup] = $this->findInitializerClass($target, $tag) ?? false; + } + + $initializerClass = $this->initializerLookups[$lookup]; + + if ($initializerClass === false) { + return null; + } + + return $this->resolveInitializer($initializerClass); + } + + /** + * @return class-string|null + */ + private function findInitializerClass(ClassReflector $target, string|UnitEnum|null $tag = null): ?string { // Initializers themselves can't be initialized, // otherwise you'd end up with infinite loops @@ -475,7 +544,7 @@ private function initializerForClass(ClassReflector $target, string|UnitEnum|nul } if ($initializerClass = $this->initializers[$this->resolveTaggedName($target->getName(), $tag)] ?? null) { - return $this->resolve($initializerClass); + return $initializerClass; } // Check if dynamic tags are allowed @@ -485,34 +554,36 @@ private function initializerForClass(ClassReflector $target, string|UnitEnum|nul $singleton = $class->getAttribute(Singleton::class) ?? $class->getMethod('initialize')->getAttribute(Singleton::class); if ($singleton?->dynamicTags) { - return $this->resolve($class->getName()); + return $class->getName(); } } // Loop through the registered initializers to see if // we have something to handle this class. foreach ($this->dynamicInitializers as $initializerClass) { - if (! isset($this->resolvedDynamicInitializers[$initializerClass])) { - $this->resolvedDynamicInitializers[$initializerClass] = $this->resolve($initializerClass); + if ($this->resolveInitializer($initializerClass)->canInitialize(class: $target, tag: $tag)) { + return $initializerClass; } - - $initializer = $this->resolvedDynamicInitializers[$initializerClass]; - - if (! $initializer->canInitialize(class: $target, tag: $tag)) { - continue; - } - - return $initializer; } return null; } + /** + * @param class-string $initializerClass + */ + private function resolveInitializer(string $initializerClass): Initializer|DynamicInitializer + { + $this->resolvedInitializers ??= new ArrayObject(); + + return $this->resolvedInitializers[$initializerClass] ??= $this->resolve($initializerClass); + } + private function autowire(string $className, string|UnitEnum|null $tag, mixed ...$params): object { - $classReflector = new ClassReflector($className); + $classReflector = self::reflect($className); - $constructor = $classReflector->getConstructor(); + $constructor = self::constructor($classReflector); if (! $classReflector->isInstantiable()) { throw new DependencyCouldNotBeInstantiated($classReflector, $this->chain); @@ -528,29 +599,25 @@ private function autowire(string $className, string|UnitEnum|null $tag, mixed .. // Otherwise, use our autowireDependencies helper to automagically : $classReflector->newInstanceWithoutConstructor(); - if ( - ! $classReflector->getType()->matches(Initializer::class) - && ! $classReflector->getType()->matches(DynamicInitializer::class) - && $classReflector->hasAttribute(Singleton::class) - ) { + if (self::isAutowiredSingleton($classReflector)) { $this->singleton($className, $instance, $tag); } - foreach ($classReflector->getProperties() as $property) { - $inject = $property->getAttribute(Inject::class); - - if ($inject && ! $property->isInitialized($instance)) { - if ($property->hasAttribute(Proxy::class)) { - $property->set($instance, $property - ->getType() - ->asClass() - ->getReflection() - ->newLazyProxy( - fn () => $this->get($property->getType()->getName(), $inject->tag), - )); - } else { - $property->set($instance, $this->get($property->getType()->getName(), $inject->tag)); - } + foreach (self::injectedProperties($classReflector) as [$property, $inject, $lazy]) { + if ($property->isInitialized($instance)) { + continue; + } + + if ($lazy) { + $property->set($instance, $property + ->getType() + ->asClass() + ->getReflection() + ->newLazyProxy( + fn () => $this->get($property->getType()->getName(), $inject->tag), + )); + } else { + $property->set($instance, $this->get($property->getType()->getName(), $inject->tag)); } } @@ -569,10 +636,11 @@ private function autowireDependencies(MethodReflector|FunctionReflector $method, // Build the class by iterating through its // dependencies and resolving them. try { - foreach ($method->getParameters() as $parameter) { + foreach (self::parameters($method) as [$parameter, $tag, $lazy]) { $dependencies[] = $this->clone()->autowireDependency( parameter: $parameter, - tag: $parameter->getAttribute(Tag::class)?->name, + tag: $tag, + lazy: $lazy, providedValue: $parameters[$parameter->getName()] ?? null, ); } @@ -583,20 +651,18 @@ private function autowireDependencies(MethodReflector|FunctionReflector $method, return $dependencies; } - private function autowireDependency(ParameterReflector $parameter, string|UnitEnum|null $tag, mixed $providedValue = null): mixed + private function autowireDependency(ParameterReflector $parameter, string|UnitEnum|null $tag, bool $lazy, mixed $providedValue = null): mixed { $parameterType = $parameter->getType(); // If the parameter is a built-in type, skip reflection and attempt to provide the value by // tagged initializer, a default value or null value. if ($parameterType->isBuiltin()) { - return $this->autowireBuiltinDependency($parameter, $providedValue); + return $this->autowireBuiltinDependency($parameter, $tag, $providedValue); } - // Support lazy initialization - $lazy = $parameter->hasAttribute(Proxy::class); // Loop through each type until we hit a match. - foreach ($parameter->getType()->split() as $type) { + foreach ($parameterType->split() as $type) { try { return $this->autowireObjectDependency( type: $type, @@ -643,20 +709,15 @@ private function autowireObjectDependency(TypeReflector $type, string|UnitEnum|n return $this->resolve(className: $type->getName(), tag: $tag); } - private function autowireBuiltinDependency(ParameterReflector $parameter, mixed $providedValue): mixed + private function autowireBuiltinDependency(ParameterReflector $parameter, string|UnitEnum|null $tag, mixed $providedValue): mixed { $typeName = $parameter->getType()->getName(); - $tag = $parameter->getAttribute(Tag::class); - - if ($tag !== null && ($initializer = $this->initializerForBuiltin($parameter->getType(), $tag->name))) { - $initializerClass = new ClassReflector($initializer); + if ($tag !== null && ($initializer = $this->initializerForBuiltin($parameter->getType(), $tag))) { $object = $initializer->initialize($this->clone()); - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class); - - if ($singleton !== null) { - $this->singleton($typeName, $object, $tag->name); + if (self::initializerSingleton(self::reflect($initializer::class)) !== null) { + $this->singleton($typeName, $object, $tag); } return $object; @@ -694,6 +755,10 @@ private function autowireBuiltinDependency(ParameterReflector $parameter, mixed private function clone(): self { + // Created before cloning, so that the clone shares them instead of creating its own. + $this->initializerLookups ??= new ArrayObject(); + $this->resolvedInitializers ??= new ArrayObject(); + return clone $this; } @@ -735,8 +800,7 @@ private function resolveTaggedName(string $className, string|UnitEnum|null $tag) private function resolveDecorator(string $className, mixed $instance, string|UnitEnum|null $tag = null, mixed ...$params): object { foreach ($this->decorators[$className] ?? [] as $decoratorClass) { - $decoratorClassReflector = new ClassReflector($decoratorClass); - $constructor = $decoratorClassReflector->getConstructor(); + $constructor = self::constructor(self::reflect($decoratorClass)); $parameters = $constructor?->getParameters(); // we look for parameter holding decorated instance @@ -763,6 +827,78 @@ private function resolveDecorator(string $className, mixed $instance, string|Uni return $instance; } + private static function reflect(string $className): ClassReflector + { + return self::$classReflectors[$className] ??= new ClassReflector($className); + } + + private static function constructor(ClassReflector $class): ?MethodReflector + { + $className = $class->getName(); + + if (! array_key_exists($className, self::$constructors)) { + self::$constructors[$className] = $class->getConstructor(); + } + + return self::$constructors[$className]; + } + + /** + * @return list Each parameter along with its tag and whether it should be lazy. + */ + private static function parameters(MethodReflector|FunctionReflector $method): array + { + $resolve = static fn (): array => array_map( + static fn (ParameterReflector $parameter): array => [ + $parameter, + $parameter->getAttribute(Tag::class)?->name, + $parameter->hasAttribute(Proxy::class), + ], + iterator_to_array($method->getParameters(), preserve_keys: false), + ); + + // Functions and closures can't be identified by name, so only the parameters of methods are cached. + if (! $method instanceof MethodReflector) { + return $resolve(); + } + + return self::$parameters[$method->getReflection()->class . '::' . $method->getName()] ??= $resolve(); + } + + /** + * @return list Each injected property along with its attribute and whether it should be lazy. + */ + private static function injectedProperties(ClassReflector $class): array + { + return self::$injectedProperties[$class->getName()] ??= array_values(array_filter(array_map( + static function (PropertyReflector $property): ?array { + $inject = $property->getAttribute(Inject::class); + + return $inject === null ? null : [$property, $inject, $property->hasAttribute(Proxy::class)]; + }, + $class->getProperties(), + ))); + } + + private static function isAutowiredSingleton(ClassReflector $class): bool + { + return self::$autowiredSingletons[$class->getName()] ??= + ! $class->getType()->matches(Initializer::class) && ! $class->getType()->matches(DynamicInitializer::class) && $class->hasAttribute(Singleton::class); + } + + private static function initializerSingleton(ClassReflector $initializerClass): ?Singleton + { + $className = $initializerClass->getName(); + + if (! array_key_exists($className, self::$initializerSingletons)) { + self::$initializerSingletons[$className] = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class); + } + + return self::$initializerSingletons[$className]; + } + public function addResettable(string|ClassReflector $resettableClass): Container { if ($resettableClass instanceof ClassReflector) { @@ -777,7 +913,7 @@ public function addResettable(string|ClassReflector $resettableClass): Container public function reset(): self { $this->resolvedSingletons = new ArrayIterator(); - $this->resolvedDynamicInitializers = []; + $this->resolvedInitializers?->exchangeArray([]); foreach ($this->resettables as $resettableClass) { /** @var Resettable $resettable */ diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php index b37f7e8e6b..2009077300 100644 --- a/packages/container/tests/ContainerTest.php +++ b/packages/container/tests/ContainerTest.php @@ -36,6 +36,7 @@ use Tempest\Container\Tests\Fixtures\ContainerObjectE; use Tempest\Container\Tests\Fixtures\ContainerObjectEInitializer; use Tempest\Container\Tests\Fixtures\CountingDynamicInitializer; +use Tempest\Container\Tests\Fixtures\CountingInitializer; use Tempest\Container\Tests\Fixtures\DecoratedClass; use Tempest\Container\Tests\Fixtures\DecoratedInterface; use Tempest\Container\Tests\Fixtures\DecoratorClass; @@ -58,6 +59,7 @@ use Tempest\Container\Tests\Fixtures\InvokableClass; use Tempest\Container\Tests\Fixtures\InvokableClassWithDependencies; use Tempest\Container\Tests\Fixtures\InvokableClassWithParameters; +use Tempest\Container\Tests\Fixtures\LookupCountingDynamicInitializer; use Tempest\Container\Tests\Fixtures\NonDynamicTaggedDependency; use Tempest\Container\Tests\Fixtures\OptionalTypesClass; use Tempest\Container\Tests\Fixtures\ResettableDependency; @@ -73,6 +75,7 @@ use Tempest\Container\Tests\Fixtures\UnionInterfaceB; use Tempest\Container\Tests\Fixtures\UnionTypesClass; use Tempest\Reflection\ClassReflector; +use Throwable; use function Tempest\Reflection\reflect; @@ -184,6 +187,64 @@ public function dynamic_initializer_instances_are_reused(): void $this->assertSame(1, CountingDynamicInitializer::$instances); } + #[Test] + public function initializer_lookups_are_remembered_across_resets(): void + { + LookupCountingDynamicInitializer::reset(); + + $container = new GenericContainer(); + $container->addInitializer(LookupCountingDynamicInitializer::class); + + $container->get(ContainerObjectA::class); + $container->get(ContainerObjectA::class); + $container->reset(); + $container->get(ContainerObjectA::class); + + $this->assertSame(1, LookupCountingDynamicInitializer::$lookups[ContainerObjectA::class]); + } + + #[Test] + public function initializer_lookups_are_forgotten_when_initializers_change(): void + { + $container = new GenericContainer(); + + // The container now remembers that there is no initializer for ContainerObjectD + try { + $container->get(ContainerObjectD::class); + $this->fail('ContainerObjectD should not be autowireable.'); + } catch (Throwable $throwable) { + $this->assertInstanceOf(DependencyCouldNotBeAutowired::class, $throwable); + } + + $container->addInitializer(ContainerObjectDInitializer::class); + + $this->assertSame('test', $container->get(ContainerObjectD::class)->prop); + + $container->removeInitializer(ContainerObjectDInitializer::class); + $container->addInitializer(CountingInitializer::class); + + $this->assertSame('counted', $container->get(ContainerObjectD::class)->prop); + } + + #[Test] + public function initializer_instances_are_reused_until_reset(): void + { + CountingInitializer::$instances = 0; + + $container = new GenericContainer(); + $container->addInitializer(CountingInitializer::class); + + $container->get(ContainerObjectD::class); + $container->get(ContainerObjectD::class); + + $this->assertSame(1, CountingInitializer::$instances); + + $container->reset(); + $container->get(ContainerObjectD::class); + + $this->assertSame(2, CountingInitializer::$instances); + } + #[Test] public function arrays_are_automatically_created(): void { diff --git a/packages/container/tests/Fixtures/CountingInitializer.php b/packages/container/tests/Fixtures/CountingInitializer.php new file mode 100644 index 0000000000..ea81637943 --- /dev/null +++ b/packages/container/tests/Fixtures/CountingInitializer.php @@ -0,0 +1,23 @@ + */ + public static array $lookups = []; + + public static function reset(): void + { + self::$lookups = []; + } + + public function canInitialize(ClassReflector $class, string|UnitEnum|null $tag): bool + { + self::$lookups[$class->getName()] = (self::$lookups[$class->getName()] ?? 0) + 1; + + return false; + } + + public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): object + { + throw new LogicException('LookupCountingDynamicInitializer should not initialize objects.'); + } +}