diff --git a/doc/book/cookbook/automating-controller-factories.md b/doc/book/cookbook/automating-controller-factories.md new file mode 100644 index 000000000..a3570d1f6 --- /dev/null +++ b/doc/book/cookbook/automating-controller-factories.md @@ -0,0 +1,64 @@ +# Automating Controller Factories + +Writing a factory class for each and every controller that has dependencies +can be tedious, particularly in early development as you are still sorting +out dependencies. + +As of version 3.0.1, zend-mvc ships with `Zend\Mvc\Controller\LazyControllerAbstractFactory`, +which provides a reflection-based approach to controller instantiation, +resolving constructor dependencies to the relevant services. The factory may be +used as either an abstract factory, or mapped to specific controller names as a +factory: + +```php +use Zend\Mvc\Controller\LazyControllerAbstractFactory; + +return [ + /* ... */ + 'controllers' => [ + 'abstract_factories' => [ + LazyControllerAbstractFactory::class, + ], + 'factories' => [ + 'MyModule\Controller\FooController' => LazyControllerAbstractFactory::class, + ], + ], + /* ... */ +]; +``` + +Mapping controllers to the factory is more explicit and performant. + +The factory operates with the following constraints/features: + +- A parameter named `$config` typehinted as an array will receive the + application "config" service (i.e., the merged configuration). +- Parameters typehinted against array, but not named `$config`, will + be injected with an empty array. +- Scalar parameters will be resolved as null values. +- If a service cannot be found for a given typehint, the factory will + raise an exception detailing this. +- Some services provided by Zend Framework components do not have + entries based on their class name (for historical reasons); the + factory contains a map of these class/interface names to the + corresponding service name to allow them to resolve. These include: + - `Zend\Console\Adapter\AdapterInterface` maps to `ConsoleAdapter`, + - `Zend\Filter\FilterPluginManager` maps to `FilterManager`, + - `Zend\Hydrator\HydratorPluginManager` maps to `HydratorManager`, + - `Zend\InputFilter\InputFilterPluginManager` maps to `InputFilterManager`, + - `Zend\Log\FilterPluginManager` maps to `LogFilterManager`, + - `Zend\Log\FormatterPluginManager` maps to `LogFormatterManager`, + - `Zend\Log\ProcessorPluginManager` maps to `LogProcessorManager`, + - `Zend\Log\WriterPluginManager` maps to `LogWriterManager`, + - `Zend\Serializer\AdapterPluginManager` maps to `SerializerAdapterManager`, + - `Zend\Validator\ValidatorPluginManager` maps to `ValidatorManager`, + +`$options` passed to the factory are ignored in all cases, as we cannot +make assumptions about which argument(s) they might replace. + +Once your dependencies have stabilized, we recommend writing a dedicated +factory, as reflection can introduce performance overhead. + +## References + +This feature was inspired by [a blog post by Alexandre Lemaire](http://circlical.com/blog/2016/3/9/preparing-for-zend-f). diff --git a/mkdocs.yml b/mkdocs.yml index 0ccc7fb43..97a3b4509 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ pages: - 'v2.X to v2.7': migration/to-v2-7.md - 'v2.X to v3.0': migration/to-v3-0.md - Cookbook: + - 'Automating controller factories': cookbook/automating-controller-factories.md - 'Using middleware within event listeners': cookbook/middleware-in-listeners.md site_name: zend-mvc site_description: 'zend-mvc: MVC application provider' diff --git a/src/Controller/LazyControllerAbstractFactory.php b/src/Controller/LazyControllerAbstractFactory.php new file mode 100644 index 000000000..e9a871747 --- /dev/null +++ b/src/Controller/LazyControllerAbstractFactory.php @@ -0,0 +1,185 @@ + + * 'controllers' => [ + * 'abstract_factories' => [ + * LazyControllerAbstractFactory::class, + * ], + * ], + * + * + * Or as a factory, mapping a controller class name to it: + * + * + * 'controllers' => [ + * 'factories' => [ + * MyControllerWithDependencies::class => LazyControllerAbstractFactory::class, + * ], + * ], + * + * + * The latter approach is more explicit, and also more performant. + * + * The factory has the following constraints/features: + * + * - A parameter named `$config` typehinted as an array will receive the + * application "config" service (i.e., the merged configuration). + * - Parameters type-hinted against array, but not named `$config` will + * be injected with an empty array. + * - Scalar parameters will be resolved as null values. + * - If a service cannot be found for a given typehint, the factory will + * raise an exception detailing this. + * - Some services provided by Zend Framework components do not have + * entries based on their class name (for historical reasons); the + * factory contains a map of these class/interface names to the + * corresponding service name to allow them to resolve. + * + * `$options` passed to the factory are ignored in all cases, as we cannot + * make assumptions about which argument(s) they might replace. + */ +class LazyControllerAbstractFactory implements AbstractFactoryInterface +{ + /** + * Maps known classes/interfaces to the service that provides them; only + * required for those services with no entry based on the class/interface + * name. + * + * Extend the class if you wish to add to the list. + * + * @var string[] + */ + protected $aliases = [ + ConsoleAdapterInterface::class => 'ConsoleAdapter', + FilterPluginManager::class => 'FilterManager', + HydratorPluginManager::class => 'HydratorManager', + InputFilterPluginManager::class => 'InputFilterManager', + LogFilterManager::class => 'LogFilterManager', + LogFormatterManager::class => 'LogFormatterManager', + LogProcessorManager::class => 'LogProcessorManager', + LogWriterManager::class => 'LogWriterManager', + SerializerAdapterManager::class => 'SerializerAdapterManager', + ValidatorPluginManager::class => 'ValidatorManager', + ]; + + /** + * {@inheritDoc} + * + * @return DispatchableInterface + */ + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + $reflectionClass = new ReflectionClass($requestedName); + + if (null === ($constructor = $reflectionClass->getConstructor())) { + return new $requestedName(); + } + + $reflectionParameters = $constructor->getParameters(); + + if (empty($reflectionParameters)) { + return new $requestedName(); + } + + $parameters = array_map( + $this->resolveParameter($container, $requestedName), + $reflectionParameters + ); + + return new $requestedName(...$parameters); + } + + /** + * {@inheritDoc} + */ + public function canCreate(ContainerInterface $container, $requestedName) + { + if (! class_exists($requestedName)) { + return false; + } + + return in_array(DispatchableInterface::class, class_implements($requestedName), true); + } + + /** + * Resolve a parameter to a value. + * + * Returns a callback for resolving a parameter to a value. + * + * @param ContainerInterface $container + * @param string $requestedName + * @return callable + */ + private function resolveParameter(ContainerInterface $container, $requestedName) + { + /** + * @param ReflectionClass $parameter + * @return mixed + * @throws ServiceNotFoundException If type-hinted parameter cannot be + * resolved to a service in the container. + */ + return function (ReflectionParameter $parameter) use ($container, $requestedName) { + if ($parameter->isArray() + && $parameter->getName() === 'config' + && $container->has('config') + ) { + return $container->get('config'); + } + + if ($parameter->isArray()) { + return []; + } + + if (! $parameter->getClass()) { + return; + } + + $type = $parameter->getClass()->getName(); + $type = isset($this->aliases[$type]) ? $this->aliases[$type] : $type; + + if (! $container->has($type)) { + throw new ServiceNotFoundException(sprintf( + 'Unable to create controller "%s"; unable to resolve parameter "%s" using type hint "%s"', + $requestedName, + $parameter->getName(), + $type + )); + } + + return $container->get($type); + }; + } +} diff --git a/test/Controller/LazyControllerAbstractFactoryTest.php b/test/Controller/LazyControllerAbstractFactoryTest.php new file mode 100644 index 000000000..7777e5bcb --- /dev/null +++ b/test/Controller/LazyControllerAbstractFactoryTest.php @@ -0,0 +1,149 @@ +container = $this->prophesize(ContainerInterface::class); + } + + public function nonClassRequestedNames() + { + return [ + 'non-class-string' => ['non-class-string'], + ]; + } + + /** + * @dataProvider nonClassRequestedNames + */ + public function testCanCreateReturnsFalseForNonClassRequestedNames($requestedName) + { + $factory = new LazyControllerAbstractFactory(); + $this->assertFalse($factory->canCreate($this->container->reveal(), $requestedName)); + } + + public function testCanCreateReturnsFalseForClassesThatDoNotImplementDispatchableInterface() + { + $factory = new LazyControllerAbstractFactory(); + $this->assertFalse($factory->canCreate($this->container->reveal(), __CLASS__)); + } + + public function testFactoryInstantiatesClassDirectlyIfItHasNoConstructor() + { + $factory = new LazyControllerAbstractFactory(); + $controller = $factory($this->container->reveal(), TestAsset\SampleController::class); + $this->assertInstanceOf(TestAsset\SampleController::class, $controller); + } + + public function testFactoryInstantiatesClassDirectlyIfConstructorHasNoArguments() + { + $factory = new LazyControllerAbstractFactory(); + $controller = $factory($this->container->reveal(), TestAsset\ControllerWithEmptyConstructor::class); + $this->assertInstanceOf(TestAsset\ControllerWithEmptyConstructor::class, $controller); + } + + public function testFactoryRaisesExceptionWhenUnableToResolveATypeHintedService() + { + $this->container->has(TestAsset\SampleInterface::class)->willReturn(false); + $factory = new LazyControllerAbstractFactory(); + $this->setExpectedException( + ServiceNotFoundException::class, + sprintf( + 'Unable to create controller "%s"; unable to resolve parameter "sample" using type hint "%s"', + TestAsset\ControllerWithTypeHintedConstructorParameter::class, + TestAsset\SampleInterface::class + ) + ); + $factory($this->container->reveal(), TestAsset\ControllerWithTypeHintedConstructorParameter::class); + } + + public function testFactoryPassesNullForScalarParameters() + { + $factory = new LazyControllerAbstractFactory(); + $controller = $factory($this->container->reveal(), TestAsset\ControllerWithScalarParameters::class); + $this->assertInstanceOf(TestAsset\ControllerWithScalarParameters::class, $controller); + $this->assertNull($controller->foo); + $this->assertNull($controller->bar); + } + + public function testFactoryInjectsConfigServiceForConfigArgumentsTypeHintedAsArray() + { + $config = ['foo' => 'bar']; + $this->container->has('config')->willReturn(true); + $this->container->get('config')->willReturn($config); + + $factory = new LazyControllerAbstractFactory(); + $controller = $factory($this->container->reveal(), TestAsset\ControllerAcceptingConfigToConstructor::class); + $this->assertInstanceOf(TestAsset\ControllerAcceptingConfigToConstructor::class, $controller); + $this->assertEquals($config, $controller->config); + } + + public function testFactoryCanInjectKnownTypeHintedServices() + { + $sample = $this->prophesize(TestAsset\SampleInterface::class)->reveal(); + $this->container->has(TestAsset\SampleInterface::class)->willReturn(true); + $this->container->get(TestAsset\SampleInterface::class)->willReturn($sample); + + $factory = new LazyControllerAbstractFactory(); + $controller = $factory($this->container->reveal(), TestAsset\ControllerWithTypeHintedConstructorParameter::class); + $this->assertInstanceOf(TestAsset\ControllerWithTypeHintedConstructorParameter::class, $controller); + $this->assertSame($sample, $controller->sample); + } + + public function testFactoryResolvesTypeHintsForServicesToWellKnownServiceNames() + { + $validators = $this->prophesize(ValidatorPluginManager::class)->reveal(); + $this->container->has('ValidatorManager')->willReturn(true); + $this->container->get('ValidatorManager')->willReturn($validators); + + $factory = new LazyControllerAbstractFactory(); + $controller = $factory( + $this->container->reveal(), + TestAsset\ControllerAcceptingWellKnownServicesAsConstructorParameters::class + ); + $this->assertInstanceOf( + TestAsset\ControllerAcceptingWellKnownServicesAsConstructorParameters::class, + $controller + ); + $this->assertSame($validators, $controller->validators); + } + + public function testFactoryCanSupplyAMixOfParameterTypes() + { + $validators = $this->prophesize(ValidatorPluginManager::class)->reveal(); + $this->container->has('ValidatorManager')->willReturn(true); + $this->container->get('ValidatorManager')->willReturn($validators); + + $sample = $this->prophesize(TestAsset\SampleInterface::class)->reveal(); + $this->container->has(TestAsset\SampleInterface::class)->willReturn(true); + $this->container->get(TestAsset\SampleInterface::class)->willReturn($sample); + + $config = ['foo' => 'bar']; + $this->container->has('config')->willReturn(true); + $this->container->get('config')->willReturn($config); + + $factory = new LazyControllerAbstractFactory(); + $controller = $factory($this->container->reveal(), TestAsset\ControllerWithMixedConstructorParameters::class); + $this->assertInstanceOf(TestAsset\ControllerWithMixedConstructorParameters::class, $controller); + + $this->assertEquals($config, $controller->config); + $this->assertNull($controller->foo); + $this->assertEquals([], $controller->options); + $this->assertSame($sample, $controller->sample); + $this->assertSame($validators, $controller->validators); + } +} diff --git a/test/Controller/TestAsset/ControllerAcceptingConfigToConstructor.php b/test/Controller/TestAsset/ControllerAcceptingConfigToConstructor.php new file mode 100644 index 000000000..60acafeea --- /dev/null +++ b/test/Controller/TestAsset/ControllerAcceptingConfigToConstructor.php @@ -0,0 +1,20 @@ +config = $config; + } +} diff --git a/test/Controller/TestAsset/ControllerAcceptingWellKnownServicesAsConstructorParameters.php b/test/Controller/TestAsset/ControllerAcceptingWellKnownServicesAsConstructorParameters.php new file mode 100644 index 000000000..3a2d7ecfa --- /dev/null +++ b/test/Controller/TestAsset/ControllerAcceptingWellKnownServicesAsConstructorParameters.php @@ -0,0 +1,21 @@ +validators = $validators; + } +} diff --git a/test/Controller/TestAsset/ControllerWithEmptyConstructor.php b/test/Controller/TestAsset/ControllerWithEmptyConstructor.php new file mode 100644 index 000000000..c870db831 --- /dev/null +++ b/test/Controller/TestAsset/ControllerWithEmptyConstructor.php @@ -0,0 +1,17 @@ +sample = $sample; + $this->validators = $validators; + $this->config = $config; + $this->foo = $foo; + $this->options = $options; + } +} diff --git a/test/Controller/TestAsset/ControllerWithScalarParameters.php b/test/Controller/TestAsset/ControllerWithScalarParameters.php new file mode 100644 index 000000000..f6189827f --- /dev/null +++ b/test/Controller/TestAsset/ControllerWithScalarParameters.php @@ -0,0 +1,22 @@ +foo = $foo; + $this->bar = $bar; + } +} diff --git a/test/Controller/TestAsset/ControllerWithTypeHintedConstructorParameter.php b/test/Controller/TestAsset/ControllerWithTypeHintedConstructorParameter.php new file mode 100644 index 000000000..bbbb99f0f --- /dev/null +++ b/test/Controller/TestAsset/ControllerWithTypeHintedConstructorParameter.php @@ -0,0 +1,20 @@ +sample = $sample; + } +}