Symfony2 命令或控制器里跑另一个命令的 N 种方法

Symfony2 命令或控制器里跑另一个命令的 N 种方法

Chris Yue 4 comments
Posts

或许最容易想到的,是通过 system 或者 exec 里执行命令,只不过这么做显得太过粗线条对吧——系统调用函数系列不一定主机提供商允许运行,而且运行命令得重新初始化 Symfony2 框架运行环境,多浪费计算资源。

这两个问题,最需要解决的是第一个问题。为了安全性,很多环境 PHP 的系统调用系列函数都被 disable 掉了。不过这个问题也好解决,我们来看看 app/console 文件到底执行了什么就明白了。

// app/console

...
use SymfonyBundleFrameworkBundleConsoleApplication;
use SymfonyComponentConsoleInputArgvInput;
...
$input = new ArgvInput();
...
$kernel = new AppKernel($env, $debug);
$application = new Application($kernel);
$application->run($input);

原来就是新建了一个 Application 对象并注入了 $kernel 就行了啊……且慢,输入的参数是怎么传入命令的呢?我们再看看 Symfony\Component\Console\Input\ArgvInput 类,看能不能发现什么:

// vendor/symfony/symfony/src/Symfony/Component/Console/Input/ArgInput.php

...

class ArgvInput extends Input
{
    ...
    public function __construct(array $argv = null, InputDefinition $definition = null)
    {
        if (null === $argv) {
            $argv = $_SERVER['argv'];
        }

        // strip the application name
        array_shift($argv);

        $this->tokens = $argv;

        parent::__construct($definition);
    }

    ...
}

原来如此,ArgvInput 在构建时,如果没有输入第一个参数,那么会自动采用 $_SERVER['argv'](其实也就是 $argv 变量)作为传入的参数。

当我们在命令行调用一个 PHP 脚本的时候,$argv 是如下的样子:

$ php app/console foo:bar --foo

$argv = array(
    0 => 'app/console',
    1 => 'foo:bar',
    3 => '--foo',
)

所以才会有 array_shift($argv) 一句,把 app/console$argv 里除掉,后面的处理并不需要它。

那么,我们是不是可以通过创建一个 Application 的方式来运行 Symfony2 项目里的命令了呢?当然可以!你只用创建好一个 ArgvInput 作为 Application 的第一个参数就可以了,比如调用 cache:clear 命令:

// namespace 参见 app/console 文件

$input = new ArgvInput(['app/console', 'cache:clear']);
$kernel = new AppKernel($env, $debug);
$application = new Application($kernel);
$application->run($input); // 如果你在命令行里调用命令,你还可以把 output 作为第二参数传入

不过,在已有的命令或者控制器里,不必创建 $kernel,因为 $kernel 已经有了,通过依赖注入容器你就可以获取:

$kernel = $this->getContainer()->get('kernel');

所以,之前 $kernel = new AppKernel($env, $debug) 那一行可以直接用上面的替换了,并且因为 Kernel 里已经包含了运行环境和 debug 开关等信息,你不用担心运行环境不一致的问题。

以上的代码可以运行,但需要传一个毫无意义的 app/console,确实有点不舒服。我们再继续深入代码,看看能不能不用 ArgvInput 作为输入参数,毕竟 Symfony Console Component 里又不止它一个Input。

我们来看看 Symfony/Component/Console/Input 目录下实现 InputInterface 接口的类有啥。除了抽象类 Input 以外,还有它们:

StringInput

看名字就能大概猜出来使用方法。此类的构造函数的第一个参数为字符串,接下来我想不用我多说了吧。需要注意的是,命令里并不需要提到 app/console

// 可将$input的创建替换为以下代码:

$input = new StringInput('cache:clear');
...

看起来很符合我们的需求哦。

ArrayInput

好吧,这个是数组版本的 StringInput。没啥好说的,注意数组里除了第一个元素以外,其他的全是 key => value 形式:

// 可将 $input 的创建替换为以下代码:

$input = new ArrayInput(['cache:clear']); 
// 如果后面要接参数,必须是 k/v 形式:['doctrine:schema:update', '--force' => true];

接下来的改进

话说,为什么运行命令必须得初始化一个 Application 啊?

让我们来看看 Application 到底做了些啥:

// vendor/symfony/symfony/src/Symfony/Bundle/FrameworkBundle/Console/Application.php
...

class Application extends BaseApplication
{
    ...
    public function __construct(KernelInterface $kernel)
    {
        $this->kernel = $kernel;

        parent::__construct('Symfony', Kernel::VERSION.' - '.$kernel->getName().'/'.$kernel->getEnvironment().($kernel->isDebug() ? '/debug' : ''));

        $this->getDefinition()->addOption(new InputOption('--shell', '-s', InputOption::VALUE_NONE, 'Launch the shell.'));
        $this->getDefinition()->addOption(new InputOption('--process-isolation', null, InputOption::VALUE_NONE, 'Launch commands from shell as a separate process.'));
        $this->getDefinition()->addOption(new InputOption('--env', '-e', InputOption::VALUE_REQUIRED, 'The Environment name.', $kernel->getEnvironment()));
        $this->getDefinition()->addOption(new InputOption('--no-debug', null, InputOption::VALUE_NONE, 'Switches off debug mode.'));
    }

    ...
    public function doRun(InputInterface $input, OutputInterface $output)
    {
        $this->kernel->boot();

        ...
        foreach ($this->all() as $command) {
            if ($command instanceof ContainerAwareInterface) {
                $command->setContainer($container);
            }
        }

        $this->setDispatcher($container->get('event_dispatcher'));
        ...

        return parent::doRun($input, $output);
    }
    ...
}

由此可见,从 Application 创建到执行 run 方法,做了下面这些事情:

  1. 构造时注入了 kernel 并从 kernel 里获取了环境信息作为参数,并增了 4 个 Symfony2 命令必有的命令选项
  2. 运行时尝试启动 kernel 并做了一些初始化依赖的工作

且慢,代码里执行的是 run 命令,让我们来看看父类里 run 方法以及 doRun 方法做了什么:

// vendor/symfony/symfony/src/Symfony/Component/Console/Application.php
...

class Application
{
    public function run(InputInterface $input = null, OutputInterface $output = null)
    {
        ...

        try {
            $exitCode = $this->doRun($input, $output);
        } catch (Exception $e) {
            ...
        }

        return $exitCode;
    }

    public function doRun(InputInterface $input, OutputInterface $output)
    {
        ...
        $exitCode = $this->doRunCommand($command, $input, $output);
        ...
    }

    protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output)
    {
        ...
        $event = new ConsoleCommandEvent($command, $input, $output);
        $this->dispatcher->dispatch(ConsoleEvents::COMMAND, $event);

        if ($event->commandShouldRun()) {
            try {
                $exitCode = $command->run($input, $output);
            } catch (Exception $e) {
                ...
            }
        } else {
            $exitCode = ConsoleCommandEvent::RETURN_CODE_DISABLED;
        }

        $event = new ConsoleTerminateEvent($command, $input, $output, $exitCode);
        $this->dispatcher->dispatch(ConsoleEvents::TERMINATE, $event);

        return $event->getExitCode();
    }
    ...
}

嗯,结果是一大段错误处理,以及抛出两个事件。真正执行的,还是 $command->run($input, $output) 这一句。如果大家还是想自己控制意外处理,并且不需要执行事件,其实是完全可以直接创建某个命令的实例并运行他的 run 方法。

改进之后的代码:

以执行 doctrine:database:drop --force 为例:

$command = new DropDatabaseDoctrineCommand();
$command->setContainer($container); // 如果是 ContainerAwareCommand 一定得用这句

$subInput = new InputStringInput('--force'); // 注意因为我们是直接通过命令对象执行命令,所以参数中连命令名字都不需要了

$command->run($subInput, $output);

目前为止,应该是性能最优的用代码调用 Symfony2 项目命令的方式了。

提示:是否使用最后一种方式来调用命令,也得分情况:如果你调用命令是为了批处理(按顺序执行 n 个命令),使用 Application 来运行命令更适合,毕竟可能会有处理命令运行和终止事件的监听器。如果你只需要某个命令的功能,比如清空数据库,那么你最好使用最后一种方式来调用命令帮你完成任务。不过需要注意的是,有的命令是依赖 Application 的,这种情况则需要使用 $command->setApplication($application) 或者使用 Application 来运行命令,再或者,将命令注册为服务:

app_bundle.command.my_command:
    class: AppBundleCommandMyCommand
    tags:
        - { name: console.command }

当然,最好最好的方式:去看看你想调用的命令都执行了什么代码,把它们都找出来!

最后再给个友情提示:如果不想在 console 下输出信息,可以改用 NullOutput

Symfony2 命令或控制器里跑另一个命令的 N 种方法 by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

写作累,服务器还越来越贵
求分担,祝愿好人一生平安
天使打赏人

4 Comments

yuansl

四月 8, 2015 在 4:50 下午

大师兄 ,感谢你的分享的心得!现在国内的程序员学习这个东西资料不多,很多英文的但是也不是很全!我也是刚接触这个框架,遇到很多问题,有些情况通读books和cookbook 都无法解决问题,英文水平太差了!还好有有道翻译!希望能多分享一下,我持续关注! 带众多symfony2的学习的朋友对你说声感谢!

    Chris Yue

    四月 8, 2015 在 4:55 下午

    好嘞~只要有人看我肯定会继续写的~
    books和cookbook我也在翻译(symfony.cn/docs上),不过也的确是精力有限,翻译的内容还不多。

    您可以关注Symfony.cn的微博帐号

     

maker

三月 16, 2015 在 9:11 上午

哇塞,你开始写长篇原创教程了?

    Chris Yue

    三月 16, 2015 在 10:13 下午

    别别,都是小短篇。最近在忙啥捏

     

发表评论

65 − = 58