Commit 0360ef3b authored by Benjamin Franzke's avatar Benjamin Franzke Committed by Benni Mack
Browse files

[FEATURE] Add dependency injection support for console commands

Transform CommandRegistry into a symfony CommandLoader which
allows console commands to be created on demand.
That means commands are lazy loaded in order to avoid creating
all commands with all their dependencies in every console
invocation. Command will now be loaded when they are either
executed or when command metadata is required (e.g. for
the command listing)

The `site:list` command is adapted to make use of dependency injection.

Releases: master
Resolves: #89139
Change-Id: I64256bf2dc21f0f3fe434aa5dff6176f0fe22233
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61630


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Achim Fritz's avatarAchim Fritz <af@achimfritz.de>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Achim Fritz's avatarAchim Fritz <af@achimfritz.de>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent c46e56e7
<?php
/**
* Commands to be executed by typo3, where the key of the array
* is the name of the command (to be called as the first argument after typo3).
* Required parameter is the "class" of the command which needs to be a subclass
* of Symfony/Console/Command.
*
* example: bin/typo3 backend:lock
*/
return [
'backend:lock' => [
'class' => \TYPO3\CMS\Backend\Command\LockBackendCommand::class
],
'backend:unlock' => [
'class' => \TYPO3\CMS\Backend\Command\UnlockBackendCommand::class
],
'referenceindex:update' => [
'class' => \TYPO3\CMS\Backend\Command\ReferenceIndexUpdateCommand::class
]
];
...@@ -7,6 +7,18 @@ services: ...@@ -7,6 +7,18 @@ services:
TYPO3\CMS\Backend\: TYPO3\CMS\Backend\:
resource: '../Classes/*' resource: '../Classes/*'
TYPO3\CMS\Backend\Command\LockBackendCommand:
tags:
- { name: 'console.command', command: 'backend:lock' }
TYPO3\CMS\Backend\Command\UnlockBackendCommand:
tags:
- { name: 'console.command', command: 'backend:unlock' }
TYPO3\CMS\Backend\Command\ReferenceIndexUpdateCommand:
tags:
- { name: 'console.command', command: 'referenceindex:update' }
# Temporary workaround until testing framework loads EXT:fluid in functional tests # Temporary workaround until testing framework loads EXT:fluid in functional tests
# @todo: Fix typo3/testing-framework and remove this # @todo: Fix typo3/testing-framework and remove this
TYPO3\CMS\Backend\View\BackendTemplateView: TYPO3\CMS\Backend\View\BackendTemplateView:
......
...@@ -28,6 +28,17 @@ use TYPO3\CMS\Core\Site\SiteFinder; ...@@ -28,6 +28,17 @@ use TYPO3\CMS\Core\Site\SiteFinder;
*/ */
class SiteListCommand extends Command class SiteListCommand extends Command
{ {
/**
* @var SiteFinder
*/
protected $siteFinder;
public function __construct(SiteFinder $siteFinder)
{
$this->siteFinder = $siteFinder;
parent::__construct();
}
/** /**
* Defines the allowed options for this command * Defines the allowed options for this command
*/ */
...@@ -44,8 +55,7 @@ class SiteListCommand extends Command ...@@ -44,8 +55,7 @@ class SiteListCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$siteFinder = new SiteFinder(); $sites = $this->siteFinder->getAllSites();
$sites = $siteFinder->getAllSites();
if (empty($sites)) { if (empty($sites)) {
$io->title('No sites configured'); $io->title('No sites configured');
......
...@@ -29,7 +29,6 @@ use TYPO3\CMS\Core\Core\Bootstrap; ...@@ -29,7 +29,6 @@ use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Localization\LanguageService; use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
* Entry point for the TYPO3 Command Line for Commands * Entry point for the TYPO3 Command Line for Commands
...@@ -42,15 +41,21 @@ class CommandApplication implements ApplicationInterface ...@@ -42,15 +41,21 @@ class CommandApplication implements ApplicationInterface
*/ */
protected $context; protected $context;
/**
* @var CommandRegistry
*/
protected $commandRegistry;
/** /**
* Instance of the symfony application * Instance of the symfony application
* @var Application * @var Application
*/ */
protected $application; protected $application;
public function __construct(Context $context) public function __construct(Context $context, CommandRegistry $commandRegistry)
{ {
$this->context = $context; $this->context = $context;
$this->commandRegistry = $commandRegistry;
$this->checkEnvironmentOrDie(); $this->checkEnvironmentOrDie();
$this->application = new Application('TYPO3 CMS', sprintf( $this->application = new Application('TYPO3 CMS', sprintf(
'%s (Application Context: <comment>%s</comment>)', '%s (Application Context: <comment>%s</comment>)',
...@@ -58,6 +63,7 @@ class CommandApplication implements ApplicationInterface ...@@ -58,6 +63,7 @@ class CommandApplication implements ApplicationInterface
Environment::getContext() Environment::getContext()
)); ));
$this->application->setAutoExit(false); $this->application->setAutoExit(false);
$this->application->setCommandLoader($commandRegistry);
} }
/** /**
...@@ -113,11 +119,12 @@ class CommandApplication implements ApplicationInterface ...@@ -113,11 +119,12 @@ class CommandApplication implements ApplicationInterface
/** /**
* Put all available commands inside the application * Put all available commands inside the application
*
* Note: This method will be removed in TYPO3 v11 when support for Configuration/Commands.php is dropped.
*/ */
protected function populateAvailableCommands(): void protected function populateAvailableCommands(): void
{ {
$commands = GeneralUtility::makeInstance(CommandRegistry::class); foreach ($this->commandRegistry->getLegacyCommands() as $commandName => $command) {
foreach ($commands as $commandName => $command) {
/** @var Command $command */ /** @var Command $command */
$this->application->add($command); $this->application->add($command);
} }
......
...@@ -15,7 +15,10 @@ namespace TYPO3\CMS\Core\Console; ...@@ -15,7 +15,10 @@ namespace TYPO3\CMS\Core\Console;
* The TYPO3 project - inspiring people to share! * The TYPO3 project - inspiring people to share!
*/ */
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use TYPO3\CMS\Core\Package\PackageManager; use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\SingletonInterface; use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
...@@ -23,17 +26,22 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; ...@@ -23,17 +26,22 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
* Registry for Symfony commands, populated from extensions * Registry for Symfony commands, populated from extensions
*/ */
class CommandRegistry implements \IteratorAggregate, SingletonInterface class CommandRegistry implements CommandLoaderInterface, \IteratorAggregate, SingletonInterface
{ {
/** /**
* @var PackageManager * @var PackageManager
*/ */
protected $packageManager; protected $packageManager;
/**
* @var ContainerInterface
*/
protected $container;
/** /**
* Map of commands * Map of commands
* *
* @var Command[] * @var array
*/ */
protected $commands = []; protected $commands = [];
...@@ -44,21 +52,68 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface ...@@ -44,21 +52,68 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
*/ */
protected $commandConfigurations = []; protected $commandConfigurations = [];
/**
* Map of lazy (DI-managed) command configurations with the command name as key
*
* @var array
*/
protected $lazyCommandConfigurations = [];
/** /**
* @param PackageManager $packageManager * @param PackageManager $packageManager
* @param ContainerInterface $container
*/
public function __construct(PackageManager $packageManager, ContainerInterface $container)
{
$this->packageManager = $packageManager;
$this->container = $container;
}
/**
* {@inheritdoc}
*/
public function has($name)
{
$this->populateCommandsFromPackages();
return array_key_exists($name, $this->commands);
}
/**
* {@inheritdoc}
*/ */
public function __construct(PackageManager $packageManager = null) public function get($name)
{ {
$this->packageManager = $packageManager ?: GeneralUtility::makeInstance(PackageManager::class); try {
return $this->getCommandByIdentifier($name);
} catch (UnknownCommandException $e) {
throw new CommandNotFoundException($e->getMessage(), [], 1567969355, $e);
}
}
/**
* {@inheritdoc}
*/
public function getNames()
{
$this->populateCommandsFromPackages();
return array_keys($this->commands);
} }
/** /**
* @return \Generator * @return \Generator
* @deprecated will be removed in TYPO3 v11.0 when support for Configuration/Commands.php is dropped.
*/ */
public function getIterator(): \Generator public function getIterator(): \Generator
{ {
trigger_error('Using ' . self::class . ' as iterable has been deprecated and will stop working in TYPO3 11.0.', E_USER_DEPRECATED);
$this->populateCommandsFromPackages(); $this->populateCommandsFromPackages();
foreach ($this->commands as $commandName => $command) { foreach ($this->commands as $commandName => $command) {
if (is_string($command)) {
$command = $this->getInstance($command, $commandName);
}
yield $commandName => $command; yield $commandName => $command;
} }
} }
...@@ -73,11 +128,30 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface ...@@ -73,11 +128,30 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
$this->populateCommandsFromPackages(); $this->populateCommandsFromPackages();
foreach ($this->commands as $commandName => $command) { foreach ($this->commands as $commandName => $command) {
if ($this->commandConfigurations[$commandName]['schedulable'] ?? true) { if ($this->commandConfigurations[$commandName]['schedulable'] ?? true) {
if (is_string($command)) {
$command = $this->getInstance($command, $commandName);
}
yield $commandName => $command; yield $commandName => $command;
} }
} }
} }
/**
* @return \Generator
* @internal This method will be removed in TYPO3 v11 when support for Configuration/Commands.php is dropped.
*/
public function getLegacyCommands(): \Generator
{
$this->populateCommandsFromPackages();
foreach ($this->commands as $commandName => $command) {
// Type string indicates lazy loading
if (is_string($command)) {
continue;
}
yield $commandName => $command;
}
}
/** /**
* @param string $identifier * @param string $identifier
* @throws CommandNameAlreadyInUseException * @throws CommandNameAlreadyInUseException
...@@ -95,7 +169,12 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface ...@@ -95,7 +169,12 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
); );
} }
return $this->commands[$identifier] ?? null; $command = $this->commands[$identifier] ?? null;
if (is_string($command)) {
$command = $this->getInstance($command, $identifier);
}
return $command;
} }
/** /**
...@@ -118,6 +197,13 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface ...@@ -118,6 +197,13 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
if ($this->commands) { if ($this->commands) {
return; return;
} }
foreach ($this->lazyCommandConfigurations as $commandName => $commandConfig) {
// Lazy commands shall be loaded from the Container on demand, store the command as string to indicate lazy loading
$this->commands[$commandName] = $commandConfig['class'];
$this->commandConfigurations[$commandName] = $commandConfig;
}
foreach ($this->packageManager->getActivePackages() as $package) { foreach ($this->packageManager->getActivePackages() as $package) {
$commandsOfExtension = $package->getPackagePath() . 'Configuration/Commands.php'; $commandsOfExtension = $package->getPackagePath() . 'Configuration/Commands.php';
if (@is_file($commandsOfExtension)) { if (@is_file($commandsOfExtension)) {
...@@ -129,6 +215,14 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface ...@@ -129,6 +215,14 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
$commands = require $commandsOfExtension; $commands = require $commandsOfExtension;
if (is_array($commands)) { if (is_array($commands)) {
foreach ($commands as $commandName => $commandConfig) { foreach ($commands as $commandName => $commandConfig) {
if (array_key_exists($commandName, $this->lazyCommandConfigurations)) {
// Lazy (DI managed) commands override classic commands from Configuration/Commands.php
// Skip this case to allow extensions to provide commands via DI config and to allow
// TYPO3 v9 backwards compatibile confguration via Configuration/Commands.php.
// Note: Also the deprecation error is skipped on-demand as the extension has been
// adapted and the configuration will be ignored as of TYPO3 v11.
continue;
}
if (array_key_exists($commandName, $this->commands)) { if (array_key_exists($commandName, $this->commands)) {
throw new CommandNameAlreadyInUseException( throw new CommandNameAlreadyInUseException(
'Command "' . $commandName . '" registered by "' . $package->getPackageKey() . '" is already in use', 'Command "' . $commandName . '" registered by "' . $package->getPackageKey() . '" is already in use',
...@@ -137,9 +231,38 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface ...@@ -137,9 +231,38 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
} }
$this->commands[$commandName] = GeneralUtility::makeInstance($commandConfig['class'], $commandName); $this->commands[$commandName] = GeneralUtility::makeInstance($commandConfig['class'], $commandName);
$this->commandConfigurations[$commandName] = $commandConfig; $this->commandConfigurations[$commandName] = $commandConfig;
trigger_error(
'Registering console commands in Configuration/Commands.php has been deprecated and will stop working in TYPO3 v11.0.',
E_USER_DEPRECATED
);
} }
} }
} }
} }
} }
protected function getInstance(string $class, string $commandName): Command
{
$command = $this->container->get($class);
if ($command instanceof Command) {
$command->setName($commandName);
return $command;
}
throw new \InvalidArgumentException('Registered console command class ' . get_class($command) . ' does not inherit from ' . Command::class, 1567966448);
}
/**
* @internal
*/
public function addLazyCommand(string $commandName, string $serviceName, bool $alias = false, bool $schedulable = true): void
{
$this->lazyCommandConfigurations[$commandName] = [
'class' => $serviceName,
'alias' => $alias,
'schedulable' => $schedulable,
];
}
} }
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DependencyInjection;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use TYPO3\CMS\Core\Console\CommandRegistry;
/**
* @internal
*/
final class ConsoleCommandPass implements CompilerPassInterface
{
/**
* @var string
*/
private $tagName;
/**
* @param string $tagName
*/
public function __construct(string $tagName)
{
$this->tagName = $tagName;
}
/**
* @param ContainerBuilder $container
*/
public function process(ContainerBuilder $container)
{
$commandRegistryDefinition = $container->findDefinition(CommandRegistry::class);
if (!$commandRegistryDefinition) {
return;
}
$unorderedEventListeners = [];
foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) {
$container->findDefinition($serviceName)->setPublic(true);
foreach ($tags as $attributes) {
if (!isset($attributes['command'])) {
continue;
}
$commandRegistryDefinition->addMethodCall('addLazyCommand', [
$attributes['command'],
$serviceName,
(bool)($attributes['alias'] ?? false),
(bool)($attributes['schedulable'] ?? true)
]);
}
}
}
}
...@@ -35,6 +35,7 @@ class ServiceProvider extends AbstractServiceProvider ...@@ -35,6 +35,7 @@ class ServiceProvider extends AbstractServiceProvider
return [ return [
Cache\CacheManager::class => [ static::class, 'getCacheManager' ], Cache\CacheManager::class => [ static::class, 'getCacheManager' ],
Console\CommandApplication::class => [ static::class, 'getConsoleCommandApplication' ], Console\CommandApplication::class => [ static::class, 'getConsoleCommandApplication' ],
Console\CommandRegistry::class => [ static::class, 'getConsoleCommandRegistry' ],
Context\Context::class => [ static::class, 'getContext' ], Context\Context::class => [ static::class, 'getContext' ],
EventDispatcher\EventDispatcher::class => [ static::class, 'getEventDispatcher' ], EventDispatcher\EventDispatcher::class => [ static::class, 'getEventDispatcher' ],
EventDispatcher\ListenerProvider::class => [ static::class, 'getEventListenerProvider' ], EventDispatcher\ListenerProvider::class => [ static::class, 'getEventListenerProvider' ],
...@@ -77,7 +78,15 @@ class ServiceProvider extends AbstractServiceProvider ...@@ -77,7 +78,15 @@ class ServiceProvider extends AbstractServiceProvider
public static function getConsoleCommandApplication(ContainerInterface $container): Console\CommandApplication public static function getConsoleCommandApplication(ContainerInterface $container): Console\CommandApplication
{ {
return new Console\CommandApplication($container->get(Context\Context::class)); return new Console\CommandApplication(
$container->get(Context\Context::class),
$container->get(Console\CommandRegistry::class)
);
}
public static function getConsoleCommandRegistry(ContainerInterface $container): Console\CommandRegistry
{
return new Console\CommandRegistry($container->get(Package\PackageManager::class), $container);
} }
public static function getEventDispatcher(ContainerInterface $container): EventDispatcher\EventDispatcher public static function getEventDispatcher(ContainerInterface $container): EventDispatcher\EventDispatcher
......
<?php
return [
'dumpautoload' => [
'class' => \TYPO3\CMS\Core\Command\DumpAutoloadCommand::class,
'schedulable' => false,
],
'mailer:spool:send' => [
'class' => \TYPO3\CMS\Core\Command\SendEmailCommand::class,
],
'extension:list' => [
'class' => \TYPO3\CMS\Core\Command\ExtensionListCommand::class,
'schedulable' => false
],
'site:list' => [
'class' => \TYPO3\CMS\Core\Command\SiteListCommand::class,
'schedulable' => false
],
'site:show' => [
'class' => \TYPO3\CMS\Core\Command\SiteShowCommand::class,
'schedulable' => false
]
];
...@@ -21,5 +21,6 @@ return function (ContainerConfigurator $container, ContainerBuilder $containerBu ...@@ -21,5 +21,6 @@ return function (ContainerConfigurator $container, ContainerBuilder $containerBu
$containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener')); $containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener'));
$containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware')); $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware'));
$containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler')); $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler'));
$containerBuilder->addCompilerPass(new DependencyInjection\ConsoleCommandPass('console.command'));
$containerBuilder->addCompilerPass(new DependencyInjection\AutowireInjectMethodsPass()); $containerBuilder->addCompilerPass(new DependencyInjection\AutowireInjectMethodsPass());
}; };
...@@ -10,6 +10,48 @@ services: ...@@ -10,6 +10,48 @@ services:
TYPO3\CMS\Core\DependencyInjection\EnvVarProcessor: TYPO3\CMS\Core\DependencyInjection\EnvVarProcessor:
tags: ['container.env_var_processor'] tags: ['container.env_var_processor']
TYPO3\CMS\Core\Command\DumpAutoloadCommand:
tags:
- name: 'console.command'