From 39e5a441bf5417b02feb1ceb0e871c60e6f256fe Mon Sep 17 00:00:00 2001 From: Gary Green Date: Mon, 30 Aug 2021 20:23:56 +0100 Subject: [PATCH 1/6] Completely lazy load console commands when resolving --- src/Illuminate/Console/Application.php | 12 +++---- .../Console/ContainerCommandLoader.php | 31 ++++++++++++++----- src/Illuminate/Foundation/Console/Kernel.php | 4 +-- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index 36e77ffbdc80..d5251bf3ba18 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -54,11 +54,11 @@ class Application extends SymfonyApplication implements ApplicationContract protected static $bootstrappers = []; /** - * A map of command names to classes. + * The lazy command loader. * - * @var array + * @var \Illuminate\Console\ContainerCommandLoader */ - protected $commandMap = []; + protected $commandLoader; /** * Create a new Artisan console application. @@ -265,8 +265,8 @@ protected function addToParent(SymfonyCommand $command) */ public function resolve($command) { - if (class_exists($command) && ($commandName = $command::getDefaultName())) { - $this->commandMap[$commandName] = $command; + if (class_exists($command) && $this->commandLoader) { + $this->commandLoader->add($command); return null; } @@ -298,7 +298,7 @@ public function resolveCommands($commands) */ public function setContainerCommandLoader() { - $this->setCommandLoader(new ContainerCommandLoader($this->laravel, $this->commandMap)); + $this->setCommandLoader($this->commandLoader = new ContainerCommandLoader($this->laravel)); return $this; } diff --git a/src/Illuminate/Console/ContainerCommandLoader.php b/src/Illuminate/Console/ContainerCommandLoader.php index f770f6c7101f..4749644003bd 100644 --- a/src/Illuminate/Console/ContainerCommandLoader.php +++ b/src/Illuminate/Console/ContainerCommandLoader.php @@ -17,23 +17,32 @@ class ContainerCommandLoader implements CommandLoaderInterface protected $container; /** - * A map of command names to classes. + * A list of class names. * * @var array */ - protected $commandMap; + protected $classes = []; /** * Create a new command loader instance. * * @param \Psr\Container\ContainerInterface $container - * @param array $commandMap * @return void */ - public function __construct(ContainerInterface $container, array $commandMap) + public function __construct(ContainerInterface $container) { $this->container = $container; - $this->commandMap = $commandMap; + } + + /** + * Add class to the loader. + * + * @param string $name + * @return void + */ + public function add(string $name) + { + $this->classes[] = $name; } /** @@ -50,7 +59,7 @@ public function get(string $name): Command throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name)); } - return $this->container->get($this->commandMap[$name]); + return $this->container->get($name); } /** @@ -61,7 +70,7 @@ public function get(string $name): Command */ public function has(string $name): bool { - return $name && isset($this->commandMap[$name]); + return in_array($name, $this->classes); } /** @@ -71,6 +80,12 @@ public function has(string $name): bool */ public function getNames(): array { - return array_keys($this->commandMap); + $names = []; + + foreach ($this->classes as $class) { + $names[] = $class::getDefaultName(); + } + + return $names; } } diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index 002297c82ff1..c9b95e6a9236 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -328,8 +328,8 @@ protected function getArtisan() { if (is_null($this->artisan)) { $this->artisan = (new Artisan($this->app, $this->events, $this->app->version())) - ->resolveCommands($this->commands) - ->setContainerCommandLoader(); + ->setContainerCommandLoader() + ->resolveCommands($this->commands); } return $this->artisan; From c3130ac8db3c96cb604005809951bda35f56a997 Mon Sep 17 00:00:00 2001 From: Gary Green Date: Wed, 1 Sep 2021 21:58:51 +0100 Subject: [PATCH 2/6] Add tests for console command loader / lazy resolution, plus minor refactoring --- src/Illuminate/Console/Application.php | 12 ++++-- .../Console/ContainerCommandLoader.php | 18 ++++++++ tests/Console/ConsoleApplicationTest.php | 42 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index d5251bf3ba18..61d74a57fe08 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -12,6 +12,7 @@ use Illuminate\Support\ProcessUtils; use Symfony\Component\Console\Application as SymfonyApplication; use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArrayInput; @@ -265,7 +266,7 @@ protected function addToParent(SymfonyCommand $command) */ public function resolve($command) { - if (class_exists($command) && $this->commandLoader) { + if ($this->commandLoader && $this->commandLoader->accepts($command)) { $this->commandLoader->add($command); return null; @@ -294,11 +295,16 @@ public function resolveCommands($commands) /** * Set the container command loader for lazy resolution. * + * @param \Symfony\Component\Console\CommandLoader\CommandLoaderInterface|null * @return $this */ - public function setContainerCommandLoader() + public function setContainerCommandLoader(CommandLoaderInterface $loader = null) { - $this->setCommandLoader($this->commandLoader = new ContainerCommandLoader($this->laravel)); + if (is_null($loader)) { + $loader = new ContainerCommandLoader($this->laravel); + } + + $this->setCommandLoader($this->commandLoader = $loader); return $this; } diff --git a/src/Illuminate/Console/ContainerCommandLoader.php b/src/Illuminate/Console/ContainerCommandLoader.php index 4749644003bd..512b39a4f929 100644 --- a/src/Illuminate/Console/ContainerCommandLoader.php +++ b/src/Illuminate/Console/ContainerCommandLoader.php @@ -2,6 +2,7 @@ namespace Illuminate\Console; +use InvalidArgumentException; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; @@ -34,14 +35,31 @@ public function __construct(ContainerInterface $container) $this->container = $container; } + /** + * Determine if the class is accepted by the command loader. + * + * @param string $class + * @return bool + */ + public function accepts(string $class): bool + { + return class_exists($class); + } + /** * Add class to the loader. * * @param string $name * @return void + * + * @throws \InvalidArgumentException */ public function add(string $name) { + if (! $this->accepts($name)) { + throw new InvalidArgumentException(sprintf('Command "%s" was not accepted by the command loader.', $name)); + } + $this->classes[] = $name; } diff --git a/tests/Console/ConsoleApplicationTest.php b/tests/Console/ConsoleApplicationTest.php index 6189a375adda..955efef57c16 100755 --- a/tests/Console/ConsoleApplicationTest.php +++ b/tests/Console/ConsoleApplicationTest.php @@ -4,11 +4,13 @@ use Illuminate\Console\Application; use Illuminate\Console\Command; +use Illuminate\Console\ContainerCommandLoader; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Foundation\Application as ApplicationContract; use Mockery as m; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command as SymfonyCommand; +use TypeError; class ConsoleApplicationTest extends TestCase { @@ -50,6 +52,38 @@ public function testResolveAddsCommandViaApplicationResolution() $this->assertEquals($command, $result); } + public function testAddingToCommandLoaderAndDoesNotResolve() + { + $app = $this->getMockConsole([]); + $commandName = FoobarCommand::class; + + $loader = new ContainerCommandLoader($app->getLaravel()); + $app->setContainerCommandLoader($loader); + + $app->getLaravel()->shouldNotReceive('make'); + + $resolvedResult = $app->resolve($commandName); + $namesResult = $loader->getNames(); + + $this->assertNull($resolvedResult); + $this->assertEquals(['foobar'], $namesResult); + } + + public function testCommanderLoaderDoesntAddForClassThatDoesntExist() + { + $app = $this->getMockConsole([]); + $commandName = 'Some\Command\That\Doesnt\Exist'; + + $loader = new ContainerCommandLoader($app->getLaravel()); + $app->setContainerCommandLoader($loader); + + $app->getLaravel()->shouldReceive('make'); + + $this->expectException(TypeError::class); + + $resolvedResult = $app->resolve($commandName); + } + public function testCallFullyStringCommandLine() { $app = new Application( @@ -87,3 +121,11 @@ protected function getMockConsole(array $methods) ])->getMock(); } } + +class FoobarCommand extends Command +{ + public static function getDefaultName(): string + { + return 'foobar'; + } +} From 3198d9901e30055028ad416179039cef0619b2c9 Mon Sep 17 00:00:00 2001 From: Gary Green Date: Thu, 2 Sep 2021 19:39:35 +0100 Subject: [PATCH 3/6] Update setContainerCommandLoader docblock Co-authored-by: Julius Kiekbusch --- src/Illuminate/Console/Application.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index 61d74a57fe08..b0f65fec8bba 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -57,7 +57,7 @@ class Application extends SymfonyApplication implements ApplicationContract /** * The lazy command loader. * - * @var \Illuminate\Console\ContainerCommandLoader + * @var \Symfony\Component\Console\CommandLoader\CommandLoaderInterface */ protected $commandLoader; From 18830a5e3db86af6576f6dfddc7cc09f3ceac73e Mon Sep 17 00:00:00 2001 From: Gary Green Date: Wed, 8 Sep 2021 01:36:51 +0100 Subject: [PATCH 4/6] Lazy load Laravel's built in console commands --- src/Illuminate/Console/Application.php | 2 ++ .../Console/ContainerCommandLoader.php | 26 +++++++------------ src/Illuminate/Foundation/Console/Kernel.php | 1 - 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index b0f65fec8bba..a755a27ea60b 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -80,6 +80,8 @@ public function __construct(Container $laravel, Dispatcher $events, $version) $this->events->dispatch(new ArtisanStarting($this)); + $this->setContainerCommandLoader(); + $this->bootstrap(); } diff --git a/src/Illuminate/Console/ContainerCommandLoader.php b/src/Illuminate/Console/ContainerCommandLoader.php index 512b39a4f929..c91ba5ff46b0 100644 --- a/src/Illuminate/Console/ContainerCommandLoader.php +++ b/src/Illuminate/Console/ContainerCommandLoader.php @@ -18,11 +18,11 @@ class ContainerCommandLoader implements CommandLoaderInterface protected $container; /** - * A list of class names. + * A map of command names to classes. * * @var array */ - protected $classes = []; + protected $commandMap = []; /** * Create a new command loader instance. @@ -36,14 +36,14 @@ public function __construct(ContainerInterface $container) } /** - * Determine if the class is accepted by the command loader. + * Determine if the command is accepted by the command loader. * - * @param string $class + * @param string $name * @return bool */ - public function accepts(string $class): bool + public function accepts(string $name): bool { - return class_exists($class); + return class_exists($name) && ! is_null($name::getDefaultName()); } /** @@ -60,7 +60,7 @@ public function add(string $name) throw new InvalidArgumentException(sprintf('Command "%s" was not accepted by the command loader.', $name)); } - $this->classes[] = $name; + $this->commandMap[$name::getDefaultName()] = $name; } /** @@ -77,7 +77,7 @@ public function get(string $name): Command throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name)); } - return $this->container->get($name); + return $this->container->get($this->commandMap[$name]); } /** @@ -88,7 +88,7 @@ public function get(string $name): Command */ public function has(string $name): bool { - return in_array($name, $this->classes); + return isset($this->commandMap[$name]); } /** @@ -98,12 +98,6 @@ public function has(string $name): bool */ public function getNames(): array { - $names = []; - - foreach ($this->classes as $class) { - $names[] = $class::getDefaultName(); - } - - return $names; + return array_keys($this->commandMap); } } diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index c9b95e6a9236..01fb7fe3f12f 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -328,7 +328,6 @@ protected function getArtisan() { if (is_null($this->artisan)) { $this->artisan = (new Artisan($this->app, $this->events, $this->app->version())) - ->setContainerCommandLoader() ->resolveCommands($this->commands); } From 258224aa8ed2d6d0aecff44dadb17d83ba599cd0 Mon Sep 17 00:00:00 2001 From: Gary Green Date: Tue, 21 Sep 2021 16:12:37 +0100 Subject: [PATCH 5/6] Add command cache for faster command loading and reduced memory --- src/Illuminate/Console/Application.php | 23 ++++++++++++ .../Console/ContainerCommandLoader.php | 31 +++++++++++++++- .../Console/CommandCacheCommand.php | 35 +++++++++++++++++++ src/Illuminate/Foundation/Console/Kernel.php | 35 +++++++++++++++++++ .../Foundation/Console/OptimizeCommand.php | 1 + .../Providers/ArtisanServiceProvider.php | 14 ++++++++ 6 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/Illuminate/Foundation/Console/CommandCacheCommand.php diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index a755a27ea60b..72366cea2e40 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -294,6 +294,19 @@ public function resolveCommands($commands) return $this; } + /** + * Add deferred commands list. + * + * @param array $commands + * @return $this + */ + public function addDeferredCommands(array $commands) + { + $this->commandLoader->merge($commands); + + return $this; + } + /** * Set the container command loader for lazy resolution. * @@ -311,6 +324,16 @@ public function setContainerCommandLoader(CommandLoaderInterface $loader = null) return $this; } + /** + * Get the deferred console commander loader. + * + * @return \Symfony\Component\Console\CommandLoader\CommandLoaderInterface + */ + public function getCommandLoader() + { + return $this->commandLoader; + } + /** * Get the default input definition for the application. * diff --git a/src/Illuminate/Console/ContainerCommandLoader.php b/src/Illuminate/Console/ContainerCommandLoader.php index c91ba5ff46b0..281e6d1ed660 100644 --- a/src/Illuminate/Console/ContainerCommandLoader.php +++ b/src/Illuminate/Console/ContainerCommandLoader.php @@ -36,13 +36,28 @@ public function __construct(ContainerInterface $container) } /** - * Determine if the command is accepted by the command loader. + * Merge the command map list + * + * @param array $commandMap + * @return void + */ + public function merge(array $commandMap) + { + $this->commandMap = array_merge($this->commandMap, $commandMap); + } + + /** + * Determine if the command is accepted by the cmand loader. * * @param string $name * @return bool */ public function accepts(string $name): bool { + if (in_array($name, $this->commandMap)) { + return true; + } + return class_exists($name) && ! is_null($name::getDefaultName()); } @@ -56,6 +71,10 @@ public function accepts(string $name): bool */ public function add(string $name) { + if (in_array($name, $this->commandMap)) { + return; + } + if (! $this->accepts($name)) { throw new InvalidArgumentException(sprintf('Command "%s" was not accepted by the command loader.', $name)); } @@ -100,4 +119,14 @@ public function getNames(): array { return array_keys($this->commandMap); } + + /** + * Get the full list of commands. + * + * @return array + */ + public function all(): array + { + return $this->commandMap; + } } diff --git a/src/Illuminate/Foundation/Console/CommandCacheCommand.php b/src/Illuminate/Foundation/Console/CommandCacheCommand.php new file mode 100644 index 000000000000..64f44d9ffc8a --- /dev/null +++ b/src/Illuminate/Foundation/Console/CommandCacheCommand.php @@ -0,0 +1,35 @@ +info('Commands cached successfully!'); + } +} diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index 01fb7fe3f12f..a22aa69c7d21 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -199,6 +199,40 @@ public function command($signature, Closure $callback) return $command; } + /** + * Cache the list of registered deferred/lazily loaded commands. + * + * @return void + */ + public function cacheCommands() + { + $commands = $this->getArtisan()->getCommandLoader()->all(); + + file_put_contents($this->getCommandCachePath(), 'getCommandCachePath(); + + return file_exists($path) ? require $this->getCommandCachePath() : []; + } + + /** + * Get full path to the command cache file. + * + * @return string + */ + protected function getCommandCachePath(): string + { + return base_path('bootstrap/cache/commands.php'); + } + /** * Register all of the commands in the given directory. * @@ -328,6 +362,7 @@ protected function getArtisan() { if (is_null($this->artisan)) { $this->artisan = (new Artisan($this->app, $this->events, $this->app->version())) + ->addDeferredCommands($this->getCachedCommands()) ->resolveCommands($this->commands); } diff --git a/src/Illuminate/Foundation/Console/OptimizeCommand.php b/src/Illuminate/Foundation/Console/OptimizeCommand.php index 30ed992f00ba..4cfe89327cc3 100644 --- a/src/Illuminate/Foundation/Console/OptimizeCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeCommand.php @@ -38,6 +38,7 @@ public function handle() { $this->call('config:cache'); $this->call('route:cache'); + $this->call('command:cache'); $this->info('Files cached successfully!'); } diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 58bc54e3e57f..4d12c950f2ac 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -22,6 +22,7 @@ use Illuminate\Foundation\Console\CastMakeCommand; use Illuminate\Foundation\Console\ChannelMakeCommand; use Illuminate\Foundation\Console\ClearCompiledCommand; +use Illuminate\Foundation\Console\CommandCacheCommand; use Illuminate\Foundation\Console\ComponentMakeCommand; use Illuminate\Foundation\Console\ConfigCacheCommand; use Illuminate\Foundation\Console\ConfigClearCommand; @@ -95,6 +96,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ClearCompiled' => ClearCompiledCommand::class, 'ClearResets' => ClearResetsCommand::class, 'ConfigCache' => ConfigCacheCommand::class, + 'CommandCache' => CommandCacheCommand::class, 'ConfigClear' => ConfigClearCommand::class, 'Db' => DbCommand::class, 'DbPrune' => PruneCommand::class, @@ -320,6 +322,18 @@ protected function registerConfigClearCommand() }); } + /** + * Register the command. + * + * @return void + */ + protected function registerCommandCacheCommand() + { + $this->app->singleton(CommandCacheCommand::class, function ($app) { + return new CommandCacheCommand; + }); + } + /** * Register the command. * From 11286b6f4f1dbbbdadae0e7118d5c2e34141ccc2 Mon Sep 17 00:00:00 2001 From: Gary Green Date: Tue, 21 Sep 2021 18:39:10 +0100 Subject: [PATCH 6/6] Defering of console commands --- src/Illuminate/Console/Application.php | 23 +++++++++++++---- src/Illuminate/Foundation/Console/Kernel.php | 26 +++++++++++++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/Illuminate/Console/Application.php b/src/Illuminate/Console/Application.php index 72366cea2e40..f78f816a6814 100755 --- a/src/Illuminate/Console/Application.php +++ b/src/Illuminate/Console/Application.php @@ -54,6 +54,13 @@ class Application extends SymfonyApplication implements ApplicationContract */ protected static $bootstrappers = []; + /** + * Indicates whether the console application has booted. + * + * @var bool + */ + protected $booted = false; + /** * The lazy command loader. * @@ -81,8 +88,6 @@ public function __construct(Container $laravel, Dispatcher $events, $version) $this->events->dispatch(new ArtisanStarting($this)); $this->setContainerCommandLoader(); - - $this->bootstrap(); } /** @@ -154,13 +159,21 @@ public static function starting(Closure $callback) /** * Bootstrap the console application. * - * @return void + * @return $this */ - protected function bootstrap() + public function bootstrap() { + if ($this->booted) { + return $this; + } + foreach (static::$bootstrappers as $bootstrapper) { $bootstrapper($this); } + + $this->booted = true; + + return $this; } /** @@ -297,7 +310,7 @@ public function resolveCommands($commands) /** * Add deferred commands list. * - * @param array $commands + * @param array $commands Must be keyed by command name e.g. ['migrate' => MigrateCommand::class] * @return $this */ public function addDeferredCommands(array $commands) diff --git a/src/Illuminate/Foundation/Console/Kernel.php b/src/Illuminate/Foundation/Console/Kernel.php index a22aa69c7d21..503d18a61f85 100644 --- a/src/Illuminate/Foundation/Console/Kernel.php +++ b/src/Illuminate/Foundation/Console/Kernel.php @@ -47,6 +47,13 @@ class Kernel implements KernelContract */ protected $commands = []; + /** + * The deferred command list. + * + * @var array|null + */ + protected $deferredCommands; + /** * Indicates if the Closure commands have been loaded. * @@ -232,6 +239,16 @@ protected function getCommandCachePath(): string { return base_path('bootstrap/cache/commands.php'); } + + /** + * Load the cached commands, used for deferring/lazy resolving of commands. + * + * @return void + */ + protected function loadCachedCommands() + { + $this->deferredCommands = $this->getCachedCommands(); + } /** * Register all of the commands in the given directory. @@ -347,7 +364,9 @@ public function bootstrap() $this->app->loadDeferredProviders(); if (! $this->commandsLoaded) { - $this->commands(); + if (! $this->loadCachedCommands()) { + $this->commands(); + } $this->commandsLoaded = true; } @@ -362,8 +381,9 @@ protected function getArtisan() { if (is_null($this->artisan)) { $this->artisan = (new Artisan($this->app, $this->events, $this->app->version())) - ->addDeferredCommands($this->getCachedCommands()) - ->resolveCommands($this->commands); + ->addDeferredCommands($this->deferredCommands ?? []) + ->resolveCommands($this->commands) + ->bootstrap(); } return $this->artisan;