From 4c137143750e7ef407887f73ff4826a76fb40489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Thu, 28 Mar 2019 22:38:32 +0100 Subject: [PATCH] Autoconfigure event listeners --- .../FrameworkExtension.php | 3 + .../RegisterListenersPass.php | 63 +++++++++++++++++-- .../RegisterListenersPassTest.php | 56 +++++++++++++++++ .../EventListenerInterface.php | 23 +++++++ 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 src/Symfony/Contracts/EventDispatcher/EventListenerInterface.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 4831af368e879..39cc46978f471 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -116,6 +116,7 @@ use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand; use Symfony\Component\Yaml\Yaml; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\EventDispatcher\EventListenerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\Service\ResetInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -368,6 +369,8 @@ public function load(array $configs, ContainerBuilder $container) ->addTag('kernel.cache_clearer'); $container->registerForAutoconfiguration(CacheWarmerInterface::class) ->addTag('kernel.cache_warmer'); + $container->registerForAutoconfiguration(EventListenerInterface::class) + ->addTag('kernel.event_listener'); $container->registerForAutoconfiguration(EventSubscriberInterface::class) ->addTag('kernel.event_subscriber'); $container->registerForAutoconfiguration(ResetInterface::class) diff --git a/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php b/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php index 3806990f35fa7..8438845776e51 100644 --- a/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php +++ b/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php @@ -14,10 +14,12 @@ use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Contracts\EventDispatcher\EventListenerInterface; /** * Compiler pass to register tagged services for an event dispatcher. @@ -63,22 +65,41 @@ public function process(ContainerBuilder $container) $definition = $container->findDefinition($this->dispatcherService); foreach ($container->findTaggedServiceIds($this->listenerTag, true) as $id => $events) { + $reflection = null; foreach ($events as $event) { $priority = isset($event['priority']) ? $event['priority'] : 0; if (!isset($event['event'])) { - throw new InvalidArgumentException(sprintf('Service "%s" must define the "event" attribute on "%s" tags.', $id, $this->listenerTag)); + if (null === $reflection) { + $class = $container->getDefinition($id)->getClass(); + if (!$reflection = $container->getReflectionClass($class)) { + throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); + } + } + if (!$reflection->implementsInterface(EventListenerInterface::class)) { + throw new InvalidArgumentException(sprintf('Service "%s" must define the "event" attribute on "%s" tags or implements the "%s" interface.', $id, $this->listenerTag, EventListenerInterface::class)); + } + + $event['event'] = $this->guessListenedClass($reflection, $id); + $event['method'] = '__invoke'; + } else { + $event['event'] = $aliases[$event['event']] ?? $event['event']; } - $event['event'] = $aliases[$event['event']] ?? $event['event']; if (!isset($event['method'])) { $event['method'] = 'on'.preg_replace_callback([ '/(?<=\b)[a-z]/i', '/[^a-z0-9]/i', - ], function ($matches) { return strtoupper($matches[0]); }, $event['event']); + ], function ($matches) { return strtoupper($matches[0]); }, (false === $p = strrpos($event['event'], '\\')) ? $event['event'] : substr($event['event'], $p + 1)); $event['method'] = preg_replace('/[^a-z0-9]/i', '', $event['method']); - if (null !== ($class = $container->getDefinition($id)->getClass()) && ($r = $container->getReflectionClass($class, false)) && !$r->hasMethod($event['method']) && $r->hasMethod('__invoke')) { + if (null === $reflection) { + $class = $container->getDefinition($id)->getClass(); + if (!$reflection = $container->getReflectionClass($class)) { + throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); + } + } + if (!$reflection->hasMethod($event['method']) && $reflection->hasMethod('__invoke')) { $event['method'] = '__invoke'; } } @@ -122,6 +143,40 @@ public function process(ContainerBuilder $container) ExtractingEventDispatcher::$aliases = []; } } + + private function guessListenedClass(\ReflectionClass $handlerClass, string $serviceId): string + { + try { + $method = $handlerClass->getMethod('__invoke'); + } catch (\ReflectionException $e) { + throw new \InvalidArgumentException(sprintf('Invalid EventListener "%s": class "%s" must have an "__invoke()" method.', $serviceId, $handlerClass->getName())); + } + + $parameters = $method->getParameters(); + if (1 > \count($parameters)) { + throw new \InvalidArgumentException(sprintf('Invalid EventListener "%s": method "%s::__invoke()" must have, at least, one argument corresponding to the event it handles.', $serviceId, $handlerClass->getName())); + } + + if (!$type = $parameters[0]->getType()) { + throw new \InvalidArgumentException(sprintf('Invalid EventListener "%s": argument "$%s" of method "%s::__invoke()" must have a type-hint corresponding to the event class it handles.', $serviceId, $parameters[0]->getName(), $handlerClass->getName())); + } + + if ($type->isBuiltin()) { + throw new \InvalidArgumentException(sprintf('Invalid EventListener "%s": type-hint of argument "$%s" in method "%s::__invoke()" must be a class, "%s" given.', $serviceId, $parameters[0]->getName(), $handlerClass->getName(), $type)); + } + + return $parameters[0]->getType(); + } + + private function registerListenerCall(ContainerBuilder $container, Definition $definition, string $listerId, string $eventName, string $method, int $priority) + { + $callable = [new ServiceClosureArgument(new Reference($listerId)), $method]; + $definition->addMethodCall('addListener', [$eventName, $callable, $priority]); + + if (isset($this->hotPathEvents[$eventName])) { + $container->getDefinition($listerId)->addTag($this->hotPathTagName); + } + } } /** diff --git a/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php index d665f426e0236..c43c86a1dcf6a 100644 --- a/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php +++ b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php @@ -16,6 +16,8 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass; +use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Contracts\EventDispatcher\EventListenerInterface; class RegisterListenersPassTest extends TestCase { @@ -148,6 +150,9 @@ public function testInvokableEventListener() $container->register('foo', \stdClass::class)->addTag('kernel.event_listener', ['event' => 'foo.bar']); $container->register('bar', InvokableListenerService::class)->addTag('kernel.event_listener', ['event' => 'foo.bar']); $container->register('baz', InvokableListenerService::class)->addTag('kernel.event_listener', ['event' => 'event']); + $container->register('fqdn', InvokableListenerService::class)->addTag('kernel.event_listener', ['event' => KernelEvent::class]); + $container->register('autoconfigured', AutoconfiguredListenerService::class)->addTag('kernel.event_listener'); + $container->register('hybride', AutoconfiguredListenerService::class)->addTag('kernel.event_listener', ['event' => 'foo'])->addTag('kernel.event_listener'); $container->register('event_dispatcher', \stdClass::class); $registerListenersPass = new RegisterListenersPass(); @@ -179,6 +184,38 @@ public function testInvokableEventListener() 0, ], ], + [ + 'addListener', + [ + 'Symfony\Component\HttpKernel\Event\KernelEvent', + [new ServiceClosureArgument(new Reference('fqdn')), 'onKernelEvent'], + 0, + ], + ], + [ + 'addListener', + [ + 'Symfony\Component\EventDispatcher\Tests\DependencyInjection\FooEvent', + [new ServiceClosureArgument(new Reference('autoconfigured')), '__invoke'], + 0, + ], + ], + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('hybride')), 'onFoo'], + 0, + ], + ], + [ + 'addListener', + [ + 'Symfony\Component\EventDispatcher\Tests\DependencyInjection\FooEvent', + [new ServiceClosureArgument(new Reference('hybride')), '__invoke'], + 0, + ], + ], ]; $this->assertEquals($expectedCalls, $definition->getMethodCalls()); } @@ -203,4 +240,23 @@ public function __invoke() public function onEvent() { } + + public function onKernelEvent() + { + } +} + +class AutoconfiguredListenerService implements EventListenerInterface +{ + public function __invoke(FooEvent $e) + { + } + + public function onFoo(FooEvent $e) + { + } +} + +class FooEvent +{ } diff --git a/src/Symfony/Contracts/EventDispatcher/EventListenerInterface.php b/src/Symfony/Contracts/EventDispatcher/EventListenerInterface.php new file mode 100644 index 0000000000000..bfc55ad23bf2f --- /dev/null +++ b/src/Symfony/Contracts/EventDispatcher/EventListenerInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\EventDispatcher; + +/** + * Marker interface for event listeners. + * + * @method void __invoke(object $event, string $eventName, EventDispatcherInterface $dispatcher) + * + * @author Jérémy Derussé + */ +interface EventListenerInterface +{ +}