diff --git a/src/Symfony/Component/EventDispatcher/CompiledEventDispatcher.php b/src/Symfony/Component/EventDispatcher/CompiledEventDispatcher.php new file mode 100644 index 0000000000000..63407768d39cf --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/CompiledEventDispatcher.php @@ -0,0 +1,249 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher; + +use Symfony\Component\DependencyInjection\IntrospectableContainerInterface; + +/** + * A performance optimized container aware event dispatcher. + * + * This version of the event dispatcher contains the following optimizations + * in comparison to the Symfony event dispatcher component: + * + *
+ *
Faster instantiation of the event dispatcher service
+ *
+ * Instead of calling addSubscriberService once for each + * subscriber, a precompiled array of listener definitions is passed + * directly to the constructor. This is faster by an order of magnitude. + * The listeners are collected and prepared using a compiler + * pass. + *
+ *
Lazy instantiation of listeners
+ *
+ * Services are only retrieved from the container just before invocation. + * Especially when dispatching the KernelEvents::REQUEST event, this leads + * to a more timely invocation of the first listener. Overall dispatch + * runtime is not affected by this change though. + *
+ *
+ */ +class CompiledEventDispatcher implements EventDispatcherInterface +{ + /** + * The service container. + * + * @var IntrospectableContainerInterface + */ + private $container; + + /** + * Listener definitions. + * + * A nested array of listener definitions keyed by event name and priority. + * A listener definition is an associative array with one of the following key + * value pairs: + * - callable: A callable listener + * - service: An array of the form + * array('id' => service id, 'method' => method name) + * + * A service entry will be resolved to a callable only just before its + * invocation. + * + * @var array + */ + private $listeners; + + /** + * Whether listeners need to be sorted prior to dispatch, keyed by event name. + * + * @var array + */ + private $unsorted = array(); + + /** + * Constructs a container aware event dispatcher. + * + * @param IntrospectableContainerInterface $container + * The service container. + * @param array $listeners + * A nested array of listener definitions keyed by event name and priority. + * The array is expected to be ordered by priority. A listener definition is + * an associative array with one of the following key value pairs: + * - callable: A callable listener + * - service: An array of the form + * array('id' => service id, 'method' => method name) + * A service entry will be resolved to a callable only just before its + * invocation. + */ + public function __construct(IntrospectableContainerInterface $container, array $listeners = array()) + { + $this->container = $container; + $this->listeners = $listeners; + } + + /** + * {@inheritdoc} + */ + public function dispatch($eventName, Event $event = null) + { + if (null === $event) { + $event = new Event(); + } + + $event->setDispatcher($this); + $event->setName($eventName); + + if (isset($this->listeners[$eventName])) { + // Sort listeners if necessary. + if (isset($this->unsorted[$eventName])) { + krsort($this->listeners[$eventName]); + unset($this->unsorted[$eventName]); + } + + // Invoke listeners and resolve callables if necessary. + foreach ($this->listeners[$eventName] as &$definitions) { + foreach ($definitions as &$definition) { + if (!isset($definition['callable'])) { + $definition['callable'] = array($this->container->get($definition['service']['id']), $definition['service']['method']); + } + + call_user_func($definition['callable'], $event, $eventName, $this); + if ($event->isPropagationStopped()) { + return $event; + } + } + } + } + + return $event; + } + + /** + * {@inheritdoc} + */ + public function getListeners($eventName = null) + { + $result = array(); + + if (null === $eventName) { + // If event name was omitted, collect all listeners of all events. + foreach (array_keys($this->listeners) as $eventName) { + $listeners = $this->getListeners($eventName); + if (!empty($listeners)) { + $result[$eventName] = $listeners; + } + } + + return $result; + } + + if (!isset($this->listeners[$eventName])) { + return $result; + } + + // Sort listeners if necessary. + if (isset($this->unsorted[$eventName])) { + krsort($this->listeners[$eventName]); + unset($this->unsorted[$eventName]); + } + + // Collect listeners and resolve callables if necessary. + foreach ($this->listeners[$eventName] as &$definitions) { + foreach ($definitions as &$definition) { + if (!isset($definition['callable'])) { + $definition['callable'] = array($this->container->get($definition['service']['id']), $definition['service']['method']); + } + + $result[] = $definition['callable']; + } + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function hasListeners($eventName = null) + { + return (bool) count($this->getListeners($eventName)); + } + + /** + * {@inheritdoc} + */ + public function addListener($eventName, $listener, $priority = 0) + { + $this->listeners[$eventName][$priority][] = array('callable' => $listener); + $this->unsorted[$eventName] = true; + } + + /** + * {@inheritdoc} + */ + public function removeListener($eventName, $listener) + { + if (!isset($this->listeners[$eventName])) { + return; + } + + foreach ($this->listeners[$eventName] as $priority => $definitions) { + foreach ($definitions as $key => $definition) { + if (!isset($definition['callable'])) { + if (!$this->container->initialized($definition['service']['id'])) { + continue; + } + $definition['callable'] = array($this->container->get($definition['service']['id']), $definition['service']['method']); + } + + if ($definition['callable'] === $listener) { + unset($this->listeners[$eventName][$priority][$key]); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function addSubscriber(EventSubscriberInterface $subscriber) + { + foreach ($subscriber->getSubscribedEvents() as $eventName => $params) { + if (is_string($params)) { + $this->addListener($eventName, array($subscriber, $params)); + } elseif (is_string($params[0])) { + $this->addListener($eventName, array($subscriber, $params[0]), isset($params[1]) ? $params[1] : 0); + } else { + foreach ($params as $listener) { + $this->addListener($eventName, array($subscriber, $listener[0]), isset($listener[1]) ? $listener[1] : 0); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function removeSubscriber(EventSubscriberInterface $subscriber) + { + foreach ($subscriber->getSubscribedEvents() as $eventName => $params) { + if (is_array($params) && is_array($params[0])) { + foreach ($params as $listener) { + $this->removeListener($eventName, array($subscriber, $listener[0])); + } + } else { + $this->removeListener($eventName, array($subscriber, is_string($params) ? $params : $params[0])); + } + } + } +} diff --git a/src/Symfony/Component/EventDispatcher/DependencyInjection/CompiledRegisterListenersPass.php b/src/Symfony/Component/EventDispatcher/DependencyInjection/CompiledRegisterListenersPass.php new file mode 100644 index 0000000000000..81dbd0c80982a --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/DependencyInjection/CompiledRegisterListenersPass.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; + +/** + * Compiler pass to register tagged services for a compiled event dispatcher. + */ +class CompiledRegisterListenersPass implements CompilerPassInterface +{ + /** + * Service name of the event dispatcher in processed container. + * + * @var string + */ + private $dispatcherService; + + /** + * Tag name used for listeners. + * + * @var string + */ + private $listenerTag; + + /** + * Tag name used for subscribers. + * + * @var string + */ + private $subscriberTag; + + /** + * Constructor. + * + * @param string $dispatcherService Service name of the event dispatcher in processed container + * @param string $listenerTag Tag name used for listeners + * @param string $subscriberTag Tag name used for subscribers + */ + public function __construct($dispatcherService = 'event_dispatcher', $listenerTag = 'kernel.event_listener', $subscriberTag = 'kernel.event_subscriber') + { + $this->dispatcherService = $dispatcherService; + $this->listenerTag = $listenerTag; + $this->subscriberTag = $subscriberTag; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition($this->dispatcherService) && !$container->hasAlias($this->dispatcherService)) { + return; + } + + $definition = $container->findDefinition($this->dispatcherService); + + $listeners = array(); + + foreach ($container->findTaggedServiceIds($this->listenerTag) as $id => $events) { + $def = $container->getDefinition($id); + if (!$def->isPublic()) { + throw new \InvalidArgumentException(sprintf('The service "%s" must be public as event listeners are lazy-loaded.', $id)); + } + + if ($def->isAbstract()) { + throw new \InvalidArgumentException(sprintf('The service "%s" must not be abstract as event listeners are lazy-loaded.', $id)); + } + + 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 (!isset($event['method'])) { + $event['method'] = 'on'.preg_replace_callback(array( + '/(?<=\b)[a-z]/i', + '/[^a-z0-9]/i', + ), function ($matches) { return strtoupper($matches[0]); }, $event['event']); + $event['method'] = preg_replace('/[^a-z0-9]/i', '', $event['method']); + } + + $listeners[$event['event']][$priority][] = array('service' => array('id' => $id, 'method' => $event['method'])); + } + } + + foreach ($container->findTaggedServiceIds($this->subscriberTag) as $id => $attributes) { + $def = $container->getDefinition($id); + if (!$def->isPublic()) { + throw new \InvalidArgumentException(sprintf('The service "%s" must be public as event subscribers are lazy-loaded.', $id)); + } + + if ($def->isAbstract()) { + throw new \InvalidArgumentException(sprintf('The service "%s" must not be abstract as event subscribers are lazy-loaded.', $id)); + } + + // We must assume that the class value has been correctly filled, even if the service is created by a factory + $class = $container->getParameterBag()->resolveValue($def->getClass()); + + $refClass = new \ReflectionClass($class); + $interface = 'Symfony\Component\EventDispatcher\EventSubscriberInterface'; + if (!$refClass->implementsInterface($interface)) { + throw new \InvalidArgumentException(sprintf('The service "%s" must implement interface "%s".', $id, $interface)); + } + + // Get all subscribed events. + foreach ($class::getSubscribedEvents() as $eventName => $params) { + if (is_string($params)) { + $priority = 0; + $listeners[$eventName][$priority][] = array('service' => array('id' => $id, 'method' => $params)); + } elseif (is_string($params[0])) { + $priority = isset($params[1]) ? $params[1] : 0; + $listeners[$eventName][$priority][] = array('service' => array('id' => $id, 'method' => $params[0])); + } else { + foreach ($params as $listener) { + $priority = isset($listener[1]) ? $listener[1] : 0; + $listeners[$eventName][$priority][] = array('service' => array('id' => $id, 'method' => $listener[0])); + } + } + } + } + + foreach (array_keys($listeners) as $eventName) { + krsort($listeners[$eventName]); + } + + $definition->addArgument($listeners); + } +} diff --git a/src/Symfony/Component/EventDispatcher/Tests/CompiledEventDispatcherTest.php b/src/Symfony/Component/EventDispatcher/Tests/CompiledEventDispatcherTest.php new file mode 100644 index 0000000000000..c5c89d2279a0d --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/Tests/CompiledEventDispatcherTest.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher\Tests; + +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\EventDispatcher\CompiledEventDispatcher; + +class CompiledEventDispatcherTest extends AbstractEventDispatcherTest +{ + protected function createEventDispatcher() + { + $container = new Container(); + + return new CompiledEventDispatcher($container); + } + + public function testGetListenersWithCallables() + { + // When passing in callables exclusively as listeners into the event + // dispatcher constructor, the event dispatcher must not attempt to + // resolve any services. + $container = $this->getMock('Symfony\Component\DependencyInjection\IntrospectableContainerInterface'); + $container->expects($this->never())->method($this->anything()); + + $firstListener = new CallableClass(); + $secondListener = function () {}; + $thirdListener = array(new TestEventListener(), 'preFoo'); + $listeners = array( + 'test_event' => array( + 0 => array( + array('callable' => $firstListener), + array('callable' => $secondListener), + array('callable' => $thirdListener), + ), + ), + ); + + $dispatcher = new CompiledEventDispatcher($container, $listeners); + $actualListeners = $dispatcher->getListeners(); + + $expectedListeners = array( + 'test_event' => array( + $firstListener, + $secondListener, + $thirdListener, + ), + ); + + $this->assertSame($expectedListeners, $actualListeners); + } + + public function testDispatchWithCallables() + { + // When passing in callables exclusively as listeners into the event + // dispatcher constructor, the event dispatcher must not attempt to + // resolve any services. + $container = $this->getMock('Symfony\Component\DependencyInjection\IntrospectableContainerInterface'); + $container->expects($this->never())->method($this->anything()); + + $firstListener = new CallableClass(); + $secondListener = function () {}; + $thirdListener = array(new TestEventListener(), 'preFoo'); + $listeners = array( + 'test_event' => array( + 0 => array( + array('callable' => $firstListener), + array('callable' => $secondListener), + array('callable' => $thirdListener), + ), + ), + ); + + $dispatcher = new CompiledEventDispatcher($container, $listeners); + $dispatcher->dispatch('test_event'); + + $this->assertTrue($thirdListener[0]->preFooInvoked); + } + + public function testGetListenersWithServices() + { + $container = new ContainerBuilder(); + $container->register('listener_service', 'Symfony\Component\EventDispatcher\Tests\TestEventListener'); + + $listeners = array( + 'test_event' => array( + 0 => array( + array('service' => array('id' => 'listener_service', 'method' => 'preFoo')), + ), + ), + ); + + $dispatcher = new CompiledEventDispatcher($container, $listeners); + $actualListeners = $dispatcher->getListeners(); + + $listenerService = $container->get('listener_service'); + $expectedListeners = array( + 'test_event' => array( + array($listenerService, 'preFoo'), + ), + ); + + $this->assertSame($expectedListeners, $actualListeners); + } + + public function testDispatchWithServices() + { + $container = new ContainerBuilder(); + $container->register('listener_service', 'Symfony\Component\EventDispatcher\Tests\TestEventListener'); + + $listeners = array( + 'test_event' => array( + 0 => array( + array('service' => array('id' => 'listener_service', 'method' => 'preFoo')), + ), + ), + ); + + $dispatcher = new CompiledEventDispatcher($container, $listeners); + + $dispatcher->dispatch('test_event'); + + $listenerService = $container->get('listener_service'); + $this->assertTrue($listenerService->preFooInvoked); + } + + public function testRemoveService() + { + $container = new ContainerBuilder(); + $container->register('listener_service', 'Symfony\Component\EventDispatcher\Tests\TestEventListener'); + $container->register('other_listener_service', 'Symfony\Component\EventDispatcher\Tests\TestEventListener'); + + $listeners = array( + 'test_event' => array( + 0 => array( + array('service' => array('id' => 'listener_service', 'method' => 'preFoo')), + array('service' => array('id' => 'other_listener_service', 'method' => 'preFoo')), + ), + ), + ); + + $dispatcher = new CompiledEventDispatcher($container, $listeners); + + $listenerService = $container->get('listener_service'); + $dispatcher->removeListener('test_event', array($listenerService, 'preFoo')); + + // Ensure that other service was not initialized during removal of the + // listener service. + $this->assertFalse($container->initialized('other_listener_service')); + + $dispatcher->dispatch('test_event'); + + $this->assertFalse($listenerService->preFooInvoked); + $otherService = $container->get('other_listener_service'); + $this->assertTrue($otherService->preFooInvoked); + } +} diff --git a/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/CompiledRegisterListenersPassTest.php b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/CompiledRegisterListenersPassTest.php new file mode 100644 index 0000000000000..d46ffb74b6b97 --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/CompiledRegisterListenersPassTest.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\EventDispatcher\Tests\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\EventDispatcher\DependencyInjection\CompiledRegisterListenersPass; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +class CompiledRegisterListenersPassTest extends \PHPUnit_Framework_TestCase +{ + public function testPassAddsConstructorArgument() + { + $container = new ContainerBuilder(); + $definition = $container->register('event_dispatcher', 'stdClass') + ->setArguments(array('foo', 'bar')); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + + $expected_arguments = array('foo', 'bar', array()); + $this->assertSame($expected_arguments, $definition->getArguments()); + } + + public function testPassAddsTaggedListenersAndSubscribers() + { + $container = new ContainerBuilder(); + $definition = $container->register('event_dispatcher', 'stdClass'); + + $container->register('test_subscriber', 'Symfony\Component\EventDispatcher\Tests\DependencyInjection\CompiledSubscriberService') + ->addTag('kernel.event_subscriber'); + + $container->register('test_listener', 'stdObject') + ->addTag('kernel.event_listener', array( + 'event' => 'test_event.multiple_listeners', + 'method' => 'methodWithMediumPriority', + 'priority' => 32, + )); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + + $expected_listeners = array( + 'test_event.multiple_listeners' => array( + 128 => array( + array( + 'service' => array('id' => 'test_subscriber', 'method' => 'methodWithHighestPriority'), + ), + ), + 32 => array( + array( + 'service' => array('id' => 'test_listener', 'method' => 'methodWithMediumPriority'), + ), + ), + 0 => array( + array( + 'service' => array('id' => 'test_subscriber', 'method' => 'methodWithoutPriority'), + ), + ), + ), + 'test_event.single_listener_with_priority' => array( + 64 => array( + array( + 'service' => array('id' => 'test_subscriber', 'method' => 'methodWithHighPriority'), + ), + ), + ), + 'test_event.single_listener_without_priority' => array( + 0 => array( + array( + 'service' => array('id' => 'test_subscriber', 'method' => 'methodWithoutPriority'), + ), + ), + ), + ); + $this->assertSame(array($expected_listeners), $definition->getArguments()); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The service "foo" must implement interface "Symfony\Component\EventDispatcher\EventSubscriberInterface". + */ + public function testEventSubscriberWithoutInterface() + { + $container = new ContainerBuilder(); + $container->register('foo', 'stdClass')->addTag('kernel.event_subscriber', array()); + $container->register('event_dispatcher', 'stdClass'); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The service "foo" must be public as event listeners are lazy-loaded. + */ + public function testPrivateEventListener() + { + $container = new ContainerBuilder(); + $container->register('foo', 'stdClass')->setPublic(false)->addTag('kernel.event_listener', array()); + $container->register('event_dispatcher', 'stdClass'); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The service "foo" must be public as event subscribers are lazy-loaded. + */ + public function testPrivateEventSubscriber() + { + $container = new ContainerBuilder(); + $container->register('foo', 'stdClass')->setPublic(false)->addTag('kernel.event_subscriber', array()); + $container->register('event_dispatcher', 'stdClass'); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The service "foo" must not be abstract as event listeners are lazy-loaded. + */ + public function testAbstractEventListener() + { + $container = new ContainerBuilder(); + $container->register('foo', 'stdClass')->setAbstract(true)->addTag('kernel.event_listener', array()); + $container->register('event_dispatcher', 'stdClass'); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The service "foo" must not be abstract as event subscribers are lazy-loaded. + */ + public function testAbstractEventSubscriber() + { + $container = new ContainerBuilder(); + $container->register('foo', 'stdClass')->setAbstract(true)->addTag('kernel.event_subscriber', array()); + $container->register('event_dispatcher', 'stdClass'); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + } + + public function testEventSubscriberResolvableClassName() + { + $container = new ContainerBuilder(); + + $container->setParameter('subscriber.class', 'Symfony\Component\EventDispatcher\Tests\DependencyInjection\CompiledSubscriberService'); + $container->register('foo', '%subscriber.class%')->addTag('kernel.event_subscriber', array()); + $container->register('event_dispatcher', 'stdClass'); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + + $definition = $container->getDefinition('event_dispatcher'); + $expected_arguments = array( + array( + 'test_event.multiple_listeners' => array( + 128 => array( + array( + 'service' => array( + 'id' => 'foo', + 'method' => 'methodWithHighestPriority', + ), + ), + ), + 0 => array( + array( + 'service' => array( + 'id' => 'foo', + 'method' => 'methodWithoutPriority', + ), + ), + ), + ), + 'test_event.single_listener_with_priority' => array( + 64 => array( + array( + 'service' => array( + 'id' => 'foo', + 'method' => 'methodWithHighPriority', + ), + ), + ), + ), + 'test_event.single_listener_without_priority' => array( + 0 => array( + array( + 'service' => array( + 'id' => 'foo', + 'method' => 'methodWithoutPriority', + ), + ), + ), + ), + ), + ); + $this->assertSame($expected_arguments, $definition->getArguments()); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage You have requested a non-existent parameter "subscriber.class" + */ + public function testEventSubscriberUnresolvableClassName() + { + $container = new ContainerBuilder(); + $container->register('foo', '%subscriber.class%')->addTag('kernel.event_subscriber', array()); + $container->register('event_dispatcher', 'stdClass'); + + $registerListenersPass = new CompiledRegisterListenersPass(); + $registerListenersPass->process($container); + } +} + +class CompiledSubscriberService implements EventSubscriberInterface +{ + public static function getSubscribedEvents() + { + return array( + 'test_event.multiple_listeners' => array( + array('methodWithHighestPriority', 128), + array('methodWithoutPriority'), + ), + 'test_event.single_listener_with_priority' => array( + array('methodWithHighPriority', 64), + ), + 'test_event.single_listener_without_priority' => array( + array('methodWithoutPriority'), + ), + ); + } +}