diff --git a/README.md b/README.md index 75a6e15..61437e8 100644 --- a/README.md +++ b/README.md @@ -300,9 +300,41 @@ $provider = new Name\Space\Of\My\App\MyCompiledProvider($container); And boom! `$provider` is now a fully functional Provider you can pass to a Dispatcher. It will work just like any other, but faster. - But what if you want to have most of your listeners pre-registered, but have some that you add conditionally at runtime? Have a look at the FIG's [`AggregateProvider`](https://github.com/php-fig/event-dispatcher-util/blob/master/src/AggregateProvider.php), and combine your compiled Provider with an instance of `OrderedListenerProvider`. +### Compiler optimization + +The `ProviderBuilder` has one other trick. If you specify one or more events via the `optimizeEvent($class)` method, then the compiler will pre-compute what listeners apply to it based on its type, including its parent classes and interfaces. The result is a constant-time simple array lookup for those events, also known as "virtually instantaneous." + +```php +use Crell\Tukio\ProviderBuilder; +use Crell\Tukio\ProviderCompiler; + +$builder = new ProviderBuilder(); + +$builder->addListener('listenerA', 100); +$builder->addListenerAfter('listenerA', 'listenerB'); +$builder->addListener([Listen::class, 'listen']); +$builder->addListenerService('listeners', 'listen', CollectingEvent::class); +$builder->addSubscriber('subscriber', Subscriber::class); + +// Here's where you specify what events you know you will have. +// Returning the listeners for these events will be near instant. +$builder->optimizeEvent(EvenOne::class); +$builder->optimizeEvent(EvenTwo::class); + +$compiler = new ProviderCompiler(); + +// Write the generated compiler out to a file. +$filename = 'MyCompiledProvider.php'; +$out = fopen($filename, 'w'); + +// Here's the magic: +$compiler->compile($builder, $out, 'MyCompiledProvider', '\\Name\\Space\\Of\\My\\App'); + +fclose($out); +``` + ### Attribute-based registration If you are using PHP 8.0 or later, Tukio fully supports attributes as a means of registration for both `OrderedListenerProvider` and `ProviderBuilder`. There are four relevant attributes: `Listener`, `ListenerPriority`, `ListenerBefore`, and `ListenerAfter`. All can be used with sequential parameters or named parameters. In most cases, named parameters will be more self-documenting. All attributes are valid only on functions and methods. diff --git a/benchmarks/CompiledProviderBench.php b/benchmarks/CompiledProviderBench.php index feab0aa..96682d9 100644 --- a/benchmarks/CompiledProviderBench.php +++ b/benchmarks/CompiledProviderBench.php @@ -5,13 +5,18 @@ namespace Crell\Tukio\Benchmarks; use Crell\Tukio\CollectingEvent; +use Crell\Tukio\DummyEvent; use Crell\Tukio\MockContainer; use Crell\Tukio\ProviderBuilder; use Crell\Tukio\ProviderCompiler; use PhpBench\Benchmark\Metadata\Annotations\AfterClassMethods; use PhpBench\Benchmark\Metadata\Annotations\BeforeClassMethods; use PhpBench\Benchmark\Metadata\Annotations\Groups; -use Psr\EventDispatcher\ListenerProviderInterface; +use PhpBench\Benchmark\Metadata\Annotations\Iterations; +use PhpBench\Benchmark\Metadata\Annotations\OutputTimeUnit; +use PhpBench\Benchmark\Metadata\Annotations\RetryThreshold; +use PhpBench\Benchmark\Metadata\Annotations\Revs; +use PhpBench\Benchmark\Metadata\Annotations\Warmup; /** * @Groups({"Providers"}) @@ -20,19 +25,13 @@ */ class CompiledProviderBench extends ProviderBenchBase { - /** - * @var ListenerProviderInterface - */ - protected $provider; + protected static string $filename = 'compiled_provider.php'; - /** @var string */ - protected static $filename = 'compiled_provider.php'; + protected static string $className = 'CompiledProvider'; - /** @var string */ - protected static $className = 'CompiledProvider'; + protected static string $namespace = 'Test\\Space'; - /** @var string */ - protected static $namespace = 'Test\\Space'; + protected static array $optimizeClasses = []; public static function createCompiledProvider(): void { @@ -42,11 +41,14 @@ public static function createCompiledProvider(): void $priority = new \InfiniteIterator(new \ArrayIterator(static::$listenerPriorities)); $priority->next(); - foreach(range(1, static::$numListeners) as $counter) { + foreach(range(1, ceil(static::$numListeners/2)) as $counter) { $builder->addListener([static::class, 'fakeListener'], $priority->current()); + $builder->addListenerService('Foo', 'bar', DummyEvent::class, $priority->current()); $priority->next(); } + $builder->optimizeEvents(...static::$optimizeClasses); + // Write the generated compiler out to a temp file. $out = fopen(static::$filename, 'w'); $compiler->compile($builder, $out, static::$className, static::$namespace); @@ -60,7 +62,6 @@ public static function removeCompiledProvider(): void public function setUp(): void { - // Now include it. If there's a parse error PHP will throw a ParseError and PHPUnit will catch it for us. include static::$filename; $container = new MockContainer(); @@ -68,8 +69,4 @@ public function setUp(): void $compiledClassName = static::$namespace . '\\' . static::$className; $this->provider = new $compiledClassName($container); } - - public static function fakeListener(CollectingEvent $task): void - { - } } diff --git a/benchmarks/OptimizedCompiledProviderBench.php b/benchmarks/OptimizedCompiledProviderBench.php new file mode 100644 index 0000000..5399149 --- /dev/null +++ b/benchmarks/OptimizedCompiledProviderBench.php @@ -0,0 +1,17 @@ +provider = new OrderedListenerProvider(); @@ -27,7 +22,7 @@ public function setUp(): void $priority->next(); foreach(range(1, static::$numListeners) as $counter) { - $this->provider->addListener(static function(CollectingEvent $task): void {}, $priority->current()); + $this->provider->addListener([static::class, 'fakeListener'], $priority->current()); $priority->next(); } } diff --git a/benchmarks/ProviderBenchBase.php b/benchmarks/ProviderBenchBase.php index a0e1def..40db072 100644 --- a/benchmarks/ProviderBenchBase.php +++ b/benchmarks/ProviderBenchBase.php @@ -7,23 +7,25 @@ use Crell\Tukio\CollectingEvent; use PhpBench\Benchmark\Metadata\Annotations\Groups; use PhpBench\Benchmark\Metadata\Annotations\Iterations; +use PhpBench\Benchmark\Metadata\Annotations\OutputTimeUnit; +use PhpBench\Benchmark\Metadata\Annotations\RetryThreshold; use PhpBench\Benchmark\Metadata\Annotations\Revs; +use PhpBench\Benchmark\Metadata\Annotations\Warmup; use Psr\EventDispatcher\ListenerProviderInterface; /** * @Groups({"Providers"}) - * @Revs(1000) - * @Iterations(5) + * @Revs(50) + * @Iterations(10) + * @Warmup(2) + * @OutputTimeUnit("milliseconds", precision=4) + * @RetryThreshold(10.0) */ abstract class ProviderBenchBase extends TukioBenchmarks { - /** - * @var ListenerProviderInterface - */ - protected $provider; + protected ListenerProviderInterface $provider; - /** @var int */ - protected static $numListeners = 5000; + protected static int $numListeners = 1000; /** * @var array @@ -47,6 +49,10 @@ public function bench_match_provider(): void $listeners = $this->provider->getListenersForEvent($task); // Run out the generator. - iterator_to_array($listeners); + is_array($listeners) || iterator_to_array($listeners); + } + + public static function fakeListener(CollectingEvent $task): void + { } } diff --git a/docker/php/74/Dockerfile b/docker/php/74/Dockerfile index ae74d50..10cdff0 100644 --- a/docker/php/74/Dockerfile +++ b/docker/php/74/Dockerfile @@ -1,10 +1,8 @@ -FROM php:7.4.28-cli +FROM php:7.4-cli WORKDIR /usr/src/myapp +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + RUN apt-get update && apt-get install zip unzip git -y \ && pecl install xdebug \ - && php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ - && php -r "if (hash_file('sha384', 'composer-setup.php') === '906a84df04cea2aa72f40b5f787e49f22d4c2f19492ac310e8cba5b96ac8b64115ac402c8cd292b8a03482574915d1a8') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \ - && php composer-setup.php --install-dir=/usr/bin --filename=composer \ - && php -r "unlink('composer-setup.php');" \ - && mkdir /.composer && chmod 777 /.composer + && pecl install pcov diff --git a/docker/php/74/xdebug.ini b/docker/php/74/xdebug.ini index d2090a9..b1ec307 100644 --- a/docker/php/74/xdebug.ini +++ b/docker/php/74/xdebug.ini @@ -1,2 +1,2 @@ -zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20210902/xdebug.so +zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20190902/xdebug.so xdebug.output_dir=profiles diff --git a/phpbench.json b/phpbench.json index 2d096b9..f98f228 100644 --- a/phpbench.json +++ b/phpbench.json @@ -1,13 +1,7 @@ { - "bootstrap": "vendor/autoload.php", - "path": "benchmarks", - "retry_threshold": 5, - "reports": { - "short": { - "title": "Short summary", - "extends": "aggregate", - "cols": ["benchmark", "subject", "mode", "mean", "stdev", "rstdev", "mem_peak"], - "break": ["benchmark"] - } - } + "runner.path": "benchmarks", + "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.php_disable_ini": true, + "runner.file_pattern": "*Bench.php" } diff --git a/src/CompiledListenerProviderBase.php b/src/CompiledListenerProviderBase.php index 36228b2..114eb1e 100644 --- a/src/CompiledListenerProviderBase.php +++ b/src/CompiledListenerProviderBase.php @@ -4,9 +4,6 @@ namespace Crell\Tukio; -use Crell\Tukio\Entry\ListenerFunctionEntry; -use Crell\Tukio\Entry\ListenerServiceEntry; -use Crell\Tukio\Entry\ListenerStaticMethodEntry; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\ListenerProviderInterface; @@ -19,7 +16,10 @@ class CompiledListenerProviderBase implements ListenerProviderInterface // entry types in the classes seen in getListenerForEvent(). See each class's getProperties() method for the // exact structure. /** @var array */ - protected const LISTENERS = []; + protected array $listeners = []; + + /** @var array */ + protected array $optimized = []; public function __construct(ContainerInterface $container) { @@ -31,28 +31,18 @@ public function __construct(ContainerInterface $container) */ public function getListenersForEvent(object $event): iterable { - $count = count(static::LISTENERS); + // @todo Switch to ::class syntax in PHP 8. + if (isset($this->optimized[get_class($event)])) { + return $this->optimized[get_class($event)]; + } + + $count = count($this->listeners); $ret = []; for ($i = 0; $i < $count; ++$i) { /** @var array $listener */ - $listener = static::LISTENERS[$i]; + $listener = $this->listeners[$i]; if ($event instanceof $listener['type']) { - // Turn this into a match() in PHP 8. - switch ($listener['entryType']) { - case ListenerFunctionEntry::class: - $ret[] = $listener['listener']; - break; - case ListenerStaticMethodEntry::class: - $ret[] = [$listener['class'], $listener['method']]; - break; - case ListenerServiceEntry::class: - $ret[] = function (object $event) use ($listener): void { - $this->container->get($listener['serviceName'])->{$listener['method']}($event); - }; - break; - default: - throw new \RuntimeException(sprintf('No such listener type found in compiled container definition: %s', $listener['entryType'])); - } + $ret[] = $listener['callable']; } } return $ret; diff --git a/src/ProviderBuilder.php b/src/ProviderBuilder.php index 0516d75..ccf90ad 100644 --- a/src/ProviderBuilder.php +++ b/src/ProviderBuilder.php @@ -19,11 +19,34 @@ class ProviderBuilder implements OrderedProviderInterface, \IteratorAggregate */ protected OrderedCollection $listeners; + /** + * @var array + */ + protected array $optimizedEvents = []; + public function __construct() { $this->listeners = new OrderedCollection(); } + /** + * Pre-specify an event class that should have an optimized listener list built. + * + * @param class-string ...$events + */ + public function optimizeEvents(string ...$events): void + { + $this->optimizedEvents = [...$this->optimizedEvents, ...$events]; + } + + /** + * @return array + */ + public function getOptimizedEvents(): array + { + return $this->optimizedEvents; + } + public function addListener(callable $listener, ?int $priority = null, ?string $id = null, ?string $type = null): string { if ($attributes = $this->getAttributes($listener)) { diff --git a/src/ProviderCompiler.php b/src/ProviderCompiler.php index 81864a9..89e283f 100644 --- a/src/ProviderCompiler.php +++ b/src/ProviderCompiler.php @@ -5,6 +5,9 @@ namespace Crell\Tukio; use Crell\Tukio\Entry\CompileableListenerEntryInterface; +use Crell\Tukio\Entry\ListenerFunctionEntry; +use Crell\Tukio\Entry\ListenerServiceEntry; +use Crell\Tukio\Entry\ListenerStaticMethodEntry; class ProviderCompiler { @@ -18,22 +21,129 @@ class ProviderCompiler * @param string $namespace * the namespace for the compiled class. */ - public function compile(ProviderBuilder $listeners, $stream, string $class = 'CompiledListenerProvider', string $namespace = '\\Crell\\Tukio\\Compiled'): void - { + public function compile( + ProviderBuilder $listeners, + $stream, + string $class = 'CompiledListenerProvider', + string $namespace = '\\Crell\\Tukio\\Compiled' + ): void { fwrite($stream, $this->createPreamble($class, $namespace)); + $this->writeMainListenersList($listeners, $stream); + + $this->writeOptimizedList($listeners, $stream); + + fwrite($stream, $this->createClosing()); + } + + /** + * @param resource $stream + * A writeable stream to which to write the compiled code. + */ + protected function writeMainListenersList(ProviderBuilder $listeners, $stream): void + { + fwrite($stream, $this->startMainListenersList()); + /** @var CompileableListenerEntryInterface $listenerEntry */ foreach ($listeners as $listenerEntry) { $item = $this->createEntry($listenerEntry); fwrite($stream, $item); } - fwrite($stream, $this->createClosing()); + fwrite($stream, $this->endMainListenersList()); + } + + /** + * @param resource $stream + * A writeable stream to which to write the compiled code. + */ + protected function writeOptimizedList(ProviderBuilder $listeners, $stream): void + { + fwrite($stream, $this->startOptimizedList()); + + $listenerDefs = iterator_to_array($listeners, false); + + foreach ($listeners->getOptimizedEvents() as $event) { + $ancestors = $this->classAncestors($event); + + fwrite($stream, $this->startOptimizedEntry($event)); + + $relevantListeners = array_filter($listenerDefs, + static fn(CompileableListenerEntryInterface $entry) + => in_array($entry->getProperties()['type'], $ancestors, true) + ); + + /** @var CompileableListenerEntryInterface $listenerEntry */ + foreach ($relevantListeners as $listenerEntry) { + $item = $this->createOptimizedEntry($listenerEntry); + fwrite($stream, $item); + } + + fwrite($stream, $this->endOptimizedEntry()); + } + + fwrite($stream, $this->endOptimizedList()); + } + + protected function startOptimizedEntry(string $event): string + { + return << [ +END; + } + + protected function endOptimizedEntry(): string + { + return <<<'END' + ], +END; + } + + protected function createOptimizedEntry(CompileableListenerEntryInterface $listenerEntry): string + { + $listener = $listenerEntry->getProperties(); + switch ($listener['entryType']) { + case ListenerFunctionEntry::class: + $ret = "'{$listener['listener']}'"; + break; + case ListenerStaticMethodEntry::class: + $ret = var_export([$listener['class'], $listener['method']], true); + break; + case ListenerServiceEntry::class: + $ret = sprintf('fn(object $event) => $this->container->get(\'%s\')->%s($event)', $listener['serviceName'], $listener['method']); + break; + default: + throw new \RuntimeException(sprintf('No such listener type found in compiled container definition: %s', $listener['entryType'])); + } + + return $ret . ',' . PHP_EOL; } protected function createEntry(CompileableListenerEntryInterface $listenerEntry): string { - return var_export($listenerEntry->getProperties(), true) . ',' . PHP_EOL; + $listener = $listenerEntry->getProperties(); + switch ($listener['entryType']) { + case ListenerFunctionEntry::class: + $ret = var_export(['type' => $listener['type'], 'callable' => $listener['listener']], true); + break; + case ListenerStaticMethodEntry::class: + $ret = var_export(['type' => $listener['type'], 'callable' => [$listener['class'], $listener['method']]], true); + break; + case ListenerServiceEntry::class: + $callable = sprintf('fn(object $event) => $this->container->get(\'%s\')->%s($event)', $listener['serviceName'], $listener['method']); + $ret = << '{$listener['type']}', + 'callable' => $callable, + ] +END; + + break; + default: + throw new \RuntimeException(sprintf('No such listener type found in compiled container definition: %s', $listener['entryType'])); + } + + return $ret . ',' . PHP_EOL; } protected function createPreamble(string $class, string $namespace): string @@ -46,20 +156,73 @@ protected function createPreamble(string $class, string $namespace): string namespace {$namespace}; use Crell\Tukio\CompiledListenerProviderBase; +use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventInterface; class {$class} extends CompiledListenerProviderBase { - protected const LISTENERS = [ + public function __construct(ContainerInterface \$container) + { + parent::__construct(\$container); END; } - protected function createClosing(): string + + /** + * Returns a list of all class and interface parents of a class. + * + * @return array + */ + protected function classAncestors(string $class, bool $includeClass = true): array + { + // These methods both return associative arrays, making + safe. + $ancestors = class_parents($class) + class_implements($class); + return $includeClass + ? [$class => $class] + $ancestors + : $ancestors + ; + } + + protected function startOptimizedList(): string + { + return <<optimized = [ + +END; + } + + protected function endOptimizedList(): string { return <<<'END' ]; -} + +END; + } + + protected function startMainListenersList(): string + { + return <<listeners = [ + +END; + + } + + protected function endMainListenersList(): string + { + return <<<'END' + ]; + +END; + } + + protected function createClosing(): string + { + return <<<'END' + } // Close constructor +} // Close class + END; } } diff --git a/tests/CompiledListenerProviderTest.php b/tests/CompiledListenerProviderTest.php index b67c535..d919f62 100644 --- a/tests/CompiledListenerProviderTest.php +++ b/tests/CompiledListenerProviderTest.php @@ -155,5 +155,31 @@ public function test_explicit_id_on_compiled_provider(): void $this->assertEquals('BACD', implode($event->result())); } + public function test_optimize_event(): void + { + $class = 'OptimizedEventProvider'; + $namespace = 'Test\\Space'; + + $builder = new ProviderBuilder(); + $container = new MockContainer(); + + // Just to make the following lines shorter and easier to read. + $ns = '\\Crell\\Tukio\\'; + $builder->addListener("{$ns}event_listener_one", -4, 'id-1'); + $builder->addListenerBefore('id-1', "{$ns}event_listener_two", 'id-2'); + $builder->addListenerAfter('id-2', "{$ns}event_listener_three", 'id-3'); + $builder->addListenerAfter('id-3', "{$ns}event_listener_four"); + + $builder->optimizeEvents(CollectingEvent::class); + + $provider = $this->makeProvider($builder, $container, $class, $namespace); + + $event = new CollectingEvent(); + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $this->assertEquals('BACD', implode($event->result())); + } } diff --git a/tests/DummyEvent.php b/tests/DummyEvent.php new file mode 100644 index 0000000..a7936b6 --- /dev/null +++ b/tests/DummyEvent.php @@ -0,0 +1,10 @@ +