feature #33851 [EventDispatcher] Allow to omit the event name when registering listeners (derrabus)

This PR was merged into the 4.4 branch.

Discussion
----------

[EventDispatcher] Allow to omit the event name when registering listeners

| Q             | A
| ------------- | ---
| Branch?       | 4.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | #33453 (kind of)
| License       | MIT
| Doc PR        | TODO

After #30801 and #33485, this is another attempt at taking advantage of FQCN events for simplifying the registration of event listeners by inferring the event name from the parameter type declaration of the listener. This is my last attempt, I promise. 🙈

This time, I'd like to make the `event` attribute of the `kernel.event_listener` tag optional. This would allow us to build listeners like the following one without adding any attributes to the `kernel.event_listener` tag.

```php
namespace App\EventListener;

final class MyRequestListener
{
    public function __invoke(RequestEvent $event): void
    {
        // do something
    }
}
```

This in turn allows us to register a whole namespace of such listeners without having to configure each listener individually:

```YAML
services:
    App\EventListener\:
        resource: ../src/EventListener/*
        tags: [kernel.event_listener]
```

Commits
-------

6f32584c76 [EventDispatcher] Allow to omit the event name when registering listeners.
This commit is contained in:
Nicolas Grekas 2019-10-09 09:04:55 +02:00
commit 0094884b17
3 changed files with 179 additions and 1 deletions

View File

@ -5,6 +5,7 @@ CHANGELOG
-----
* `AddEventAliasesPass` has been added, allowing applications and bundles to extend the event alias mapping used by `RegisterListenersPass`.
* Made the `event` attribute of the `kernel.event_listener` tag optional for FQCN events.
4.3.0
-----

View File

@ -16,8 +16,10 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\Event as LegacyEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Compiler pass to register tagged services for an event dispatcher.
@ -67,8 +69,14 @@ class RegisterListenersPass implements CompilerPassInterface
$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 ($container->getDefinition($id)->hasTag($this->subscriberTag)) {
continue;
}
$event['method'] = $event['method'] ?? '__invoke';
$event['event'] = $this->getEventFromTypeDeclaration($container, $id, $event['method']);
}
$event['event'] = $aliases[$event['event']] ?? $event['event'];
if (!isset($event['method'])) {
@ -122,6 +130,24 @@ class RegisterListenersPass implements CompilerPassInterface
ExtractingEventDispatcher::$aliases = [];
}
}
private function getEventFromTypeDeclaration(ContainerBuilder $container, string $id, string $method): string
{
if (
null === ($class = $container->getDefinition($id)->getClass())
|| !($r = $container->getReflectionClass($class, false))
|| !$r->hasMethod($method)
|| 1 > ($m = $r->getMethod($method))->getNumberOfParameters()
|| !($type = $m->getParameters()[0]->getType())
|| $type->isBuiltin()
|| Event::class === ($name = $type->getName())
|| LegacyEvent::class === $name
) {
throw new InvalidArgumentException(sprintf('Service "%s" must define the "event" attribute on "%s" tags.', $id, $this->listenerTag));
}
return $name;
}
}
/**

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\EventDispatcher\Tests\DependencyInjection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\DependencyInjection\AddEventAliasesPass;
use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass;
@ -244,6 +245,116 @@ class RegisterListenersPassTest extends TestCase
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}
public function testOmitEventNameOnTypedListener(): void
{
$container = new ContainerBuilder();
$container->setParameter('event_dispatcher.event_aliases', [AliasedEvent::class => 'aliased_event']);
$container->register('foo', TypedListener::class)->addTag('kernel.event_listener', ['method' => 'onEvent']);
$container->register('bar', TypedListener::class)->addTag('kernel.event_listener');
$container->register('event_dispatcher');
$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
$definition = $container->getDefinition('event_dispatcher');
$expectedCalls = [
[
'addListener',
[
CustomEvent::class,
[new ServiceClosureArgument(new Reference('foo')), 'onEvent'],
0,
],
],
[
'addListener',
[
'aliased_event',
[new ServiceClosureArgument(new Reference('bar')), '__invoke'],
0,
],
],
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}
public function testOmitEventNameOnUntypedListener(): void
{
$container = new ContainerBuilder();
$container->register('foo', InvokableListenerService::class)->addTag('kernel.event_listener', ['method' => 'onEvent']);
$container->register('event_dispatcher');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Service "foo" must define the "event" attribute on "kernel.event_listener" tags.');
$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
}
public function testOmitEventNameAndMethodOnUntypedListener(): void
{
$container = new ContainerBuilder();
$container->register('foo', InvokableListenerService::class)->addTag('kernel.event_listener');
$container->register('event_dispatcher');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Service "foo" must define the "event" attribute on "kernel.event_listener" tags.');
$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
}
/**
* @requires PHP 7.2
*/
public function testOmitEventNameAndMethodOnGenericListener(): void
{
$container = new ContainerBuilder();
$container->register('foo', GenericListener::class)->addTag('kernel.event_listener');
$container->register('event_dispatcher');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Service "foo" must define the "event" attribute on "kernel.event_listener" tags.');
$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
}
public function testOmitEventNameOnSubscriber(): void
{
$container = new ContainerBuilder();
$container->register('subscriber', IncompleteSubscriber::class)
->addTag('kernel.event_subscriber')
->addTag('kernel.event_listener')
->addTag('kernel.event_listener', ['event' => 'bar', 'method' => 'onBar'])
;
$container->register('event_dispatcher');
$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
$definition = $container->getDefinition('event_dispatcher');
$expectedCalls = [
[
'addListener',
[
'bar',
[new ServiceClosureArgument(new Reference('subscriber')), 'onBar'],
0,
],
],
[
'addListener',
[
'foo',
[new ServiceClosureArgument(new Reference('subscriber')), 'onFoo'],
0,
],
],
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}
}
class SubscriberService implements EventSubscriberInterface
@ -285,3 +396,43 @@ final class AliasedEvent
final class CustomEvent
{
}
final class TypedListener
{
public function __invoke(AliasedEvent $event): void
{
}
public function onEvent(CustomEvent $event): void
{
}
}
final class GenericListener
{
public function __invoke(object $event): void
{
}
}
final class IncompleteSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'foo' => 'onFoo',
];
}
public function onFoo(): void
{
}
public function onBar(): void
{
}
public function __invoke(CustomEvent $event): void
{
}
}