/dev/null

脳みそのL1キャッシュ

symfony/consoleを使ってPHPでCLIを書いてみる

はじめに

前回は素のPHPCLIツールを書いてみたのですが、如何せんしょぼい…なので、今回はsymfony/consoleというライブラリを使って、リッチなCLIツールを書いてみたいと思います。

今回の成果物はここにあります。

symfony/consoleとは

symfony/consoleSymfonyというWeb Application Frameworkの一部で、Symfonyのコンソールコマンドで使用されています。Symfonyの一部と言っても、 symfony/consoleは独立して使えるようです。実際、Laravelのlaravel/installerartisanはこのsymfony/consoleを使っています。

今回はsymfony/consoleを使ってCLIツールを作ってみたいと思います。

CLIツールの作り方

1. プロジェクトを作る

まず、composer initを実行してプロジェクトを作ります。リッチなCLIツールを作りますので、名前はrichにでもしておきましょう。

途中で依存パッケージを聞かれるので、symfony/consoleを入れてやりましょう。

$ mkdir rich; cd rich
$ composer init
...
Would you like to define your dependencies (require) interactively [yes]?
Search for a package: symfony/console
...
Would you like to install dependencies now [yes]?
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 5 installs, 0 updates, 0 removals
  - Installing psr/container (1.0.0): Loading from cache
  - Installing symfony/service-contracts (v2.0.1): Loading from cache
  - Installing symfony/polyfill-php73 (v1.16.0): Downloading (100%)
  - Installing symfony/polyfill-mbstring (v1.16.0): Downloading (100%)
  - Installing symfony/console (v5.0.8): Loading from cache
symfony/service-contracts suggests installing symfony/service-implementation
symfony/console suggests installing symfony/event-dispatcher
symfony/console suggests installing symfony/lock
symfony/console suggests installing symfony/process
symfony/console suggests installing psr/log (For using the console logger)
...

ディレクトリはこうなります。今後はここをワーキングディレクトリとして作業していきます。

$ ls
composer.json composer.lock vendor

2. エントリポイントを作る

まず、bin/richファイルを作ります。ファイルの内容は以下の通りです。

#!/usr/bin/env php
<?php

require __DIR__.'/../vendor/autoload.php';

use Symfony\Component\Console\Application;

$app = new Application('Rich command', '0.0.1');

$app->run();

Applicationクラスにコマンド名とバージョンを渡せます。

次に、autoload.phpを作ります。これで、vendor/autoload.phpが作られます。

$ composer dump-autoload

最後に、php bin/richを実行してみましょう。

$ php bin/rich
Rich command 0.0.1

Usage:
  command [options] [arguments]

Options:
  -h, --help            Display this help message
  -q, --quiet           Do not output any message
  -V, --version         Display this application version
      --ansi            Force ANSI output
      --no-ansi         Disable ANSI output
  -n, --no-interaction  Do not ask any interactive question
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Available commands:
  help  Displays help for a command
  list  Lists commands

なかなかいい感じですね。

3. コマンドを作る

とりあえず、前回同様catコマンドを作ってみます。まず、catコマンド用にsrc/CatCommand.phpを作ります。

<?php

namespace Rich\Console;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class CatCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('cat')
            ->setDescription('Concatenate and print files');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln('This is cat command!');
        return 0;
    }
}

composer.jsonを編集し、autoloadを追加します。これで、use Rich\Console;と書くとと、src/以下のRich\Console名前空間に入っているものが使えるようになります。

{
    "name": "d2verb/rich",
    "description": "A rich cli tool",
    "require": {
        "symfony/console": "^5.0"
    },
    "autoload": {
        "psr-4": {
            "Rich\\Console\\": "src/"
        }
    }
}

ここまでできたら、composer dump-autoloadを実行してautoload.phpを作成しましょう。

次に、作ったコマンドを登録します。bin/richに以下の行を$app->run()の前に追加します。

$app->add(new CatCommand());

php bin/richを実行してみます。

$ php bin/rich list                                                                                                         master ✱ ◼
Rich command 0.0.1

Usage:
  command [options] [arguments]

Options:
  -h, --help            Display this help message
  -q, --quiet           Do not output any message
  -V, --version         Display this application version
      --ansi            Force ANSI output
      --no-ansi         Disable ANSI output
  -n, --no-interaction  Do not ask any interactive question
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Available commands:
  cat   Concatenate and print files <-- catコマンドが追加されています。
  help  Displays help for a command
  list  Lists commands
  
$ php bin/rich cat
This is cat command!

最後にCatCommandをちゃんと実装します。

<?php

namespace Rich\Console;

use SplFileObject;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class CatCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('cat')
            ->setDescription('Concatenate and print files')
            ->addArgument('filenames', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Filenames you want to concatenate.');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $filenames = $input->getArgument('filenames');
        foreach ($filenames as $filename) {
            $this->printFileContent($output, $filename);
        }
        return 0;
    }

    protected function printFileContent(OutputInterface $output, string $filename)
    {
        $file = new SplFileObject($filename);
        foreach ($file as $line) {
            $output->write($line);
        }
    }
}

実行してみましょう。以下の例では、./bin/rich./src/CatCommand.phpをcatコマンドに渡しています。

$ php bin/rich cat ./bin/rich ./src/CatCommand.php
#!/usr/bin/env php
<?php

require __DIR__.'/../vendor/autoload.php';

use Symfony\Component\Console\Application;
use Rich\Console\CatCommand;
...

ファイル名を渡さなかったら怒られます。

$ php bin/rich cat                                                                                                          master ✱ ◼

                                                
  Not enough arguments (missing: "filenames").  
                                                

cat <filenames>...

存在しないファイル名を渡すと例外が発生します。

$ php bin/rich cat NULL                                                                                                 ✘ 1 master ✱ ◼
PHP Warning:  fopen(NULL): failed to open stream: No such file or directory in /Users/a_fukumoto/Workspace/misc/study/php/cli/rich/src/CatCommand.php on line 32

Warning: fopen(NULL): failed to open stream: No such file or directory in /Users/a_fukumoto/Workspace/misc/study/php/cli/rich/src/CatCommand.php on line 32

In CatCommand.php line 34:
                                 
  Can not open the file: 'NULL'  
                                 

cat <filenames>...

想定通りの動きをしていますね。

おわりに

symfony/consoleを使えば、よりリッチなCLIツールを作れることがわかりました。CLIツール作りが捗ります。symfony/consoleの使い方は公式のドキュメントにまとまっていますので、困ったら参照しましょう。

おまけ: Laravelで新しいコマンドを作る

Laravelにはmake:commandというコマンドが存在しています。このコマンドを使うことによってユーザー定義のコマンドを作ることができます。

ここでは、例として新しいviewファイルを作るコマンドを作ってみましょう。 まず、php artisan make:command MakeViewCommandを実行します。

すると、app/Console/Commands/MakeViewCommand.phpが生成されますので、中身を以下のように変更しましょう。

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Symfony\Component\Console\Exception\RuntimeException;

class MakeViewCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'make:view {file}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Create a new view file';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $viewDir = __DIR__ . '/../../../resources/views/';

        // documents.indexのような文字列を引数に取る
        $file = $this->argument('file');

        // documents/index.blade.phpのような形に変換する
        $viewFilePath = $viewDir . str_replace('.', '/', $file) . '.blade.php';

        if (file_exists($viewFilePath)) {
            throw new RuntimeException("File already exists");
        }

        $viewFileDir = dirname($viewFilePath);

        if (!file_exists($viewFileDir)) {
            mkdir(dirname($viewFilePath), 0777, true);
        }

        touch($viewFilePath);
    }
}

MakeViewCommand.phpを上記のように変更して以下を実行するとresources/views以下にdocuments/index.blade.phpが生成されます。

$ php artisan make:view documents.index

参考文献

github.com

qiita.com

symfony.com

symfony.com