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:
TYPO3\CMS\Backend\:
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
# @todo: Fix typo3/testing-framework and remove this
TYPO3\CMS\Backend\View\BackendTemplateView:
......
......@@ -28,6 +28,17 @@ use TYPO3\CMS\Core\Site\SiteFinder;
*/
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
*/
......@@ -44,8 +55,7 @@ class SiteListCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$siteFinder = new SiteFinder();
$sites = $siteFinder->getAllSites();
$sites = $this->siteFinder->getAllSites();
if (empty($sites)) {
$io->title('No sites configured');
......
......@@ -29,7 +29,6 @@ use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Entry point for the TYPO3 Command Line for Commands
......@@ -42,15 +41,21 @@ class CommandApplication implements ApplicationInterface
*/
protected $context;
/**
* @var CommandRegistry
*/
protected $commandRegistry;
/**
* Instance of the symfony application
* @var Application
*/
protected $application;
public function __construct(Context $context)
public function __construct(Context $context, CommandRegistry $commandRegistry)
{
$this->context = $context;
$this->commandRegistry = $commandRegistry;
$this->checkEnvironmentOrDie();
$this->application = new Application('TYPO3 CMS', sprintf(
'%s (Application Context: <comment>%s</comment>)',
......@@ -58,6 +63,7 @@ class CommandApplication implements ApplicationInterface
Environment::getContext()
));
$this->application->setAutoExit(false);
$this->application->setCommandLoader($commandRegistry);
}
/**
......@@ -113,11 +119,12 @@ class CommandApplication implements ApplicationInterface
/**
* 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
{
$commands = GeneralUtility::makeInstance(CommandRegistry::class);
foreach ($commands as $commandName => $command) {
foreach ($this->commandRegistry->getLegacyCommands() as $commandName => $command) {
/** @var Command $command */
$this->application->add($command);
}
......
......@@ -15,7 +15,10 @@ namespace TYPO3\CMS\Core\Console;
* The TYPO3 project - inspiring people to share!
*/
use Psr\Container\ContainerInterface;
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\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -23,17 +26,22 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Registry for Symfony commands, populated from extensions
*/
class CommandRegistry implements \IteratorAggregate, SingletonInterface
class CommandRegistry implements CommandLoaderInterface, \IteratorAggregate, SingletonInterface
{
/**
* @var PackageManager
*/
protected $packageManager;
/**
* @var ContainerInterface
*/
protected $container;
/**
* Map of commands
*
* @var Command[]
* @var array
*/
protected $commands = [];
......@@ -44,21 +52,68 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
*/
protected $commandConfigurations = [];
/**
* Map of lazy (DI-managed) command configurations with the command name as key
*
* @var array
*/
protected $lazyCommandConfigurations = [];
/**
* @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
* @deprecated will be removed in TYPO3 v11.0 when support for Configuration/Commands.php is dropped.
*/
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();
foreach ($this->commands as $commandName => $command) {
if (is_string($command)) {
$command = $this->getInstance($command, $commandName);
}
yield $commandName => $command;
}
}
......@@ -73,11 +128,30 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
$this->populateCommandsFromPackages();
foreach ($this->commands as $commandName => $command) {
if ($this->commandConfigurations[$commandName]['schedulable'] ?? true) {
if (is_string($command)) {
$command = $this->getInstance($command, $commandName);
}
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
* @throws CommandNameAlreadyInUseException
......@@ -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
if ($this->commands) {
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) {
$commandsOfExtension = $package->getPackagePath() . 'Configuration/Commands.php';
if (@is_file($commandsOfExtension)) {
......@@ -129,6 +215,14 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
$commands = require $commandsOfExtension;
if (is_array($commands)) {
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)) {
throw new CommandNameAlreadyInUseException(
'Command "' . $commandName . '" registered by "' . $package->getPackageKey() . '" is already in use',
......@@ -137,9 +231,38 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface
}
$this->commands[$commandName] = GeneralUtility::makeInstance($commandConfig['class'], $commandName);
$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
return [
Cache\CacheManager::class => [ static::class, 'getCacheManager' ],
Console\CommandApplication::class => [ static::class, 'getConsoleCommandApplication' ],
Console\CommandRegistry::class => [ static::class, 'getConsoleCommandRegistry' ],
Context\Context::class => [ static::class, 'getContext' ],
EventDispatcher\EventDispatcher::class => [ static::class, 'getEventDispatcher' ],
EventDispatcher\ListenerProvider::class => [ static::class, 'getEventListenerProvider' ],
......@@ -77,7 +78,15 @@ class ServiceProvider extends AbstractServiceProvider
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
......
<?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
$containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener'));
$containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware'));
$containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler'));
$containerBuilder->addCompilerPass(new DependencyInjection\ConsoleCommandPass('console.command'));
$containerBuilder->addCompilerPass(new DependencyInjection\AutowireInjectMethodsPass());
};
......@@ -10,6 +10,48 @@ services:
TYPO3\CMS\Core\DependencyInjection\EnvVarProcessor:
tags: ['container.env_var_processor']
TYPO3\CMS\Core\Command\DumpAutoloadCommand:
tags:
- name: 'console.command'
command: 'dumpautoload'
schedulable: false
- name: 'console.command'
command: 'extensionmanager:extension:dumpclassloadinginformation'
alias: true
schedulable: false
- name: 'console.command'
command: 'extension:dumpclassloadinginformation'
alias: true
schedulable: false
TYPO3\CMS\Core\Command\ExtensionListCommand:
tags:
- name: 'console.command'
command: 'extension:list'
schedulable: false
TYPO3\CMS\Core\Command\SendEmailCommand:
tags:
- name: 'console.command'
command: 'mailer:spool:send'
- name: 'console.command'
command: 'swiftmailer:spool:send'
alias: true
schedulable: false
TYPO3\CMS\Core\Command\SiteListCommand:
tags:
- name: 'console.command'
command: 'site:list'
schedulable: false
TYPO3\CMS\Core\Command\SiteShowCommand:
tags:
- name: 'console.command'
command: 'site:show'
schedulable: false
TYPO3\CMS\Core\Configuration\SiteConfiguration:
arguments:
$configPath: "%env(TYPO3:configPath)%/sites"
......
.. include:: ../../Includes.txt
=====================================================================================
Deprecation: #89139 - Console Commands configuration migrated to Symfony service tags
=====================================================================================
See :issue:`89139`
Description
===========
The console command configuration file format :php:`Configuration/Commands.php`
has been deprecated in favor of the dependency injection service tag
`console.command`. The tag allows to configure dependency injection and
command registration in one single location.
Impact
======
Providing a command configuration in :php:`Configuration/Commands.php` will
trigger a deprecation warning when the respective commands have not already
been defined via dependency injection service tags.
Extensions that provide both, the deprecated configuration file and service
tags, will not trigger a deprecation message in order to allow extensions to
support multiple TYPO3 major versions.
Affected Installations
======================
TYPO3 installations with custom extensions that configure symfony console commands
via :php:`Configuration/Commands.php` and have not been migrated to add symfony
service tags.
Migration
=========
Add the `console.command` tag to command classes. Use the tag attribute `command`
to specify the command name. The optional tag attribute `schedulable` may be set
to false to exclude the command from the TYPO3 scheduler.
.. code-block:: yaml
services:
_defaults:
autowire: true
autoconfigure: true
public: false
MyVendor\MyExt\Commands\FooCommand
tags:
- name: 'console.command',
command: 'my:command'
schedulable: false
Command aliases are to be configured as separate tags.
The optonal tag attribute `alias` should be set to true for alias commands.
.. code-block:: yaml
MyVendor\MyExt\Commands\BarCommand
tags:
- name: 'console.command'
command: 'my:bar' }
- name: 'console.command'
command: 'my:old-bar-command'
alias: true
schedulable: false
.. index:: CLI, PHP-API, PartiallyScanned, ext:core