Container(PSR-11)とsymfony/consoleで簡単なコマンドラインアプリケーションを作ってみよう

ytake です。

PHPでコマンドラインアプリケーションを開発する時、
いつも使っているフレームワークをそのまま使うには巨大すぎる・・。
という方も多いのではないでしょうか?

今回は巨大なフレームワークなどを導入せずに、
アプリケーションに合わせて、自分好みのライブラリを組み合わせたり、
簡単なボイラープレートを作成したり、
テストしやすいコマンドラインアプリケーション作りを行うために、
symfony/consolePSR-11 準拠のDIコンテナを組み合わせたコマンドラインアプリケーションの実装例を紹介します。

本エントリに対応したリポジトリは こちら

簡単なコンソールアプリケーションを作る

まずは symfony/console をインストールします。
composerを使って下記のコマンドを入力します。

$ composer require symfony/console

次にコンソールアプリケーションを起動するファイルを作成します。
例としてファイル名は console とします。

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

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

use Symfony\Component\Console\Application;

$application = new Application('console-with-container', '1.0.0');
$application->run();

symfony/console自体はこれだけで実行することができます。
次のコマンドを実行してターミナルに出力が正常に行われることを確認しましょう。

$ php console 

実行するとターミナルに以下のように出力されます。

console-with-container 1.0.0

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

コマンドラインアプリケーションが起動できるのことを確認したら、
簡単なコマンドを作成します。

sample:process コマンド

このコマンドはターミナル上にユーザー情報のような簡単なデータ出力を行います。
ユーザー情報のようなものを表現するクラスとして以下のものを作成します。

<?php
declare(strict_types=1);

namespace ConsoleApp;

/**
 * Class User
 */
final class User
{
    /** @var string */
    private $name;

    /** @var string */
    private $url;

    /**
     * User constructor.
     *
     * @param string $name
     * @param string $url
     */
    public function __construct(string $name, string $url)
    {
        $this->name = $name;
        $this->url = $url;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getUrl(): string
    {
        return $this->url;
    }
}

次にコマンドクラスを作成します。
ConsoleApp\Commands\SampleExecuteCommand クラスとします。

<?php
declare(strict_types=1);

namespace ConsoleApp\Commands;

use ConsoleApp\User;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Class SampleExecuteCommand
 */
class SampleExecuteCommand extends Command
{
    /** @var string  command name */
    protected $command = 'sample:process';

    /** @var string  command description */
    protected $description = 'try di containers!';

    /** @var User */
    protected $user;

    /**
     * SampleExecuteCommand constructor.
     *
     * @param User $user
     */
    public function __construct(User $user)
    {
        $this->user = $user;
        parent::__construct(null);
    }

    /**
     * @param InputInterface  $input
     * @param OutputInterface $output
     */
    protected function execute(InputInterface $input, OutputInterface $output): void
    {
        $rows = 10;
        $progressBar = new ProgressBar($output, $rows);
        $progressBar->setBarCharacter('<fg=magenta>=</>');
        $progressBar->setProgressCharacter("\xF0\x9F\x8D\xBA");
        for ($i = 0; $i < $rows; $i++) {
            usleep(300000);
            $progressBar->advance();
        }
        $progressBar->finish();
        $output->writeln(' <bg=yellow;options=bold>' . get_class($this->user) . '</>');
        $output->writeln(
            sprintf('name: %s / url: %s', $this->user->getName(), $this->user->getUrl())
        );
    }

    /**
     * command interface configure
     */
    protected function configure(): void
    {
        $this->setName($this->command);
        $this->setDescription($this->description);
    }
}

ただ文字が出力されるだけでは面白みがないため、
プログレスバーにビールを組み合わせています。

このクラスの commandプロパティに記述された文字列がコマンドとなります。
このコマンドをsymfony/consoleに登録し、実行できるようにするには以下の通りに記述します。

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

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

use Symfony\Component\Console\Application;

$application = new Application('console-with-container', '1.0.0');
$application->addCommands([
    new \ConsoleApp\Commands\SampleExecuteCommand(
        new User(
            'ytake',
            'https://github.com/ytake'
        )
    ),
]);

$application->run();

ターミナルで $ php console php console sample:process と入力するとコマンドが実行されます。

DIコンテナを導入する

簡単なアプリケーションであればこれまでに紹介した実装方法で問題はありませんが、
実際のアプリケーションではデータベースやロガーといったコンポーネントを組み合わせたり、
複雑なアプリケーション仕様を実現するために様々なクラスを組み合わせることとなります。

変更に強く、テストしやすいアプリケーション開発にはDIコンテナや、
外部からの依存クラス解決の仕組みが必要不可欠となります。

PHPにも様々なDIコンテナライブラリがあり、
最近のPHPフレームワークでは標準でサポートされていることも多いです。
ここではDIコンテナライブラリを組み込んでコマンドクラスを変更することなく、
出力される情報などを変更する方法を紹介してます。

PSR-11

PSR-11はDIコンテナライブラリの共通インターフェースの標準化を目標としたものです。
has メソッドと get メソッドをもつインターフェースで、

指定した識別子(名前)がコンテナに存在するかどうか(has)
指定した識別子(名前)をコンテナから取得する(get)

というシンプルなインターフェースです。

DIコンテナへの登録方法については決められていないため、
ライブラリによって登録方法はことなります。

このインターフェースを実装したDIコンテナライブラリを使うと、
symfony/consoleへの登録は次のようになります。

<?php

$application->addCommands([
    $container->get(\ConsoleApp\Commands\SampleExecuteCommand::class),
]);

PSR-11に準拠したいくつかのDIコンテナライブラリを使って、登録方法の違いをみてみましょう。
それぞれのDIコンテナライブラリの詳細な利用方法は、ライブラリのドキュメントなどを参照してください。

*サンプルコードでは ConsoleApp\ContainerFactory\Factory クラスでDIコンテナライブラリが変更されるようになっています。

zend-servicemanager

zend-servicemanager

Zend Framework や、 Zend Expressive で利用されている、
DIコンテナ(サービスロケータ)ライブラリで、
クラスに対応したFactoryクラスをマッピングさせることで、
柔軟なインスタンス生成方法を指定できるライブラリです。
弊社でもフレームワークを使わなくてもいいぐらいの小さいアプリケーションで利用することも多いライブラリです。

ConsoleApp\Commands\SampleExecuteCommand クラスを例にすると、
インスタンス生成を担当するのは次のようなクラスになります。

<?php
declare(strict_types=1);

namespace ConsoleApp\Commands;

use ConsoleApp\User;
use Psr\Container\ContainerInterface;

/**
 * Class SampleExecuteCommandFactory
 *
 * for zend servicemanager factory
 */
class SampleExecuteCommandFactory
{
    /**
     * {@inheritdoc}
     */
    public function __invoke(ContainerInterface $container): SampleExecuteCommand
    {
        return new SampleExecuteCommand($container->get(User::class));
    }
}

このクラスをzend-servicemanagerに登録することで、
getメソッドで ConsoleApp\Commands\SampleExecuteCommand クラスを取得するときに、
ConsoleApp\Commands\SampleExecuteCommandFactory クラスの __invoke メソッドが実行されて、
ConsoleApp\Commands\SampleExecuteCommand インスタンスが取得できます。

__invokeメソッドを実装されたクラス、またはクロージャで記述できます。
様々な利用方法がありますが、シンプルに使う場合は以下のようになります。

<?php
use Zend\ServiceManager\ServiceManager;

$container = new ServiceManager([
    'factories' => [
        SampleExecuteCommand::class => SampleExecuteCommandFactory::class,
        User::class => function (ContainerInterface $container) {
            return new User(
                'zendframework/zend-servicemanager',
                'https://github.com/zendframework/zend-servicemanager'
            );
        },
    ],
]);

PSR-11のインターフェースを実装しているため、symfony/consoleのaddCommandsメソッドで利用すると、
ターミナルにはzend-servicemangerのurl情報がユーザー情報として表示されます。
ConsoleApp\Commands\SampleExecuteCommand クラスを変更することなく出力情報が変更されました。

これ以降に紹介するDIコンテナライブラリもこの ConsoleApp\Commands\SampleExecuteCommandFactory クラスを利用する実装で紹介します。

aura/di

aura/di

コンストラクタインジェクション、セッターインジェクションをサポートしたライブラリで、
実装のクリーンさと実行速度が速く、使い勝手のいいDIコンテナライブラリです。

Zend Expressiveでもデフォルトで利用できるDIコンテナの一つとして取り上げられています。
このライブラリでは、ConsoleApp\Commands\SampleExecuteCommand クラス取得時に、
ConsoleApp\Commands\SampleExecuteCommandFactory クラスの __invoke メソッドを実行する、として記述します。

<?php
use Aura\Di\ContainerBuilder;
use ConsoleApp\User;
use Psr\Container\ContainerInterface;
use ConsoleApp\Commands\SampleExecuteCommand;
use ConsoleApp\Commands\SampleExecuteCommandFactory;

$builder = new ContainerBuilder();
$container = $builder->newInstance();
$container->set(
    SampleExecuteCommandFactory::class,
    $container->lazyNew(SampleExecuteCommandFactory::class)
);
$container->params[User::class]['name'] = 'aura/di';
$container->params[User::class]['url'] = 'https://github.com/auraphp/Aura.Di';
$container->set(User::class, $container->lazyNew(User::class));
$container->set(
    SampleExecuteCommand::class,
    $container->lazyGetCall(
        SampleExecuteCommandFactory::class,
        '__invoke',
        $container
    )
);

zend-servicemanagerと違い、インスタンス生成方法を詳細に記述します。
Userクラス生成時に利用するパラメータもDIコンテナに登録でき、様々なニーズに対応できます。

league/container

league/container

aura/diと同じくコンストラクタインジェクション、セッターインジェクションをサポートしたライブラリで、
インスタンス生成方法を記述せずに自動でインスタンス生成を行うAutowiringをサポートしています。

<?php

use ConsoleApp\Commands\SampleExecuteCommand;
use ConsoleApp\Commands\SampleExecuteCommandFactory;
use ConsoleApp\User;
use League\Container\Container as LeagueContainer;
use Psr\Container\ContainerInterface;

$container = new LeagueContainer();
$container->add(ContainerInterface::class, $container);
$container->add(SampleExecuteCommandFactory::class, SampleExecuteCommandFactory::class);
$container->add(User::class, function () {
    return new User(
        'league/container',
        'https://github.com/thephpleague/container'
    );
});
$container->add(SampleExecuteCommand::class, function () use ($container) {
    return $container->call(
        $container->get(SampleExecuteCommandFactory::class),
        [$container]
    );
});

上記はAutowiringを使わずに、各クラスのインスタンス生成方法を記述します。
コンテナに登録されているインスタンスを使うために、PSR-11インターフェースにコンテナ自身を割り当て、
Userクラスのインスタンス生成方法はクロージャで記述します。

ConsoleApp\Commands\SampleExecuteCommandFactory クラスの __invoke メソッドは callクラスで実行されます。

illuminate/container

illuminate/container

最後に実装例として紹介するのはLaravelのDIコンテナライブラリです。
単体でも利用できるライブラリで記述方法は先に紹介した league/container に似ています。
デフォルトでAutowiringサポートとなっており、意図した通りにインスタンス生成が行われない場合は、
インスタンス生成方法を詳細に記述しなければなりません。

<?php

use ConsoleApp\Commands\SampleExecuteCommand;
use ConsoleApp\Commands\SampleExecuteCommandFactory;
use ConsoleApp\User;
use Illuminate\Container\Container as IlluminateContainer;
use Psr\Container\ContainerInterface;

$container = new IlluminateContainer();
$container->instance(ContainerInterface::class, $container);
$container->bind(User::class, function () {
    return new User(
        'illuminate/container',
        'https://github.com/illuminate/container'
    );
});
$container->bind(SampleExecuteCommand::class, function (IlluminateContainer $container) {
    return $container->call([
        $container->make(SampleExecuteCommandFactory::class),
        '__invoke',
    ], [$container]);
});

league/container同様にPSR-11インターフェースにコンテナ自身を割り当て、
Userクラスのインスタンス生成方法も同様にクロージャで記述します。
ConsoleApp\Commands\SampleExecuteCommandFactory クラスの __invoke メソッドは、
Laravelでメソッドインジェクションとして提供されている機能(callメソッド)を使ってインスタンス生成を行います。

さいごに

今回紹介したライブラリは全てPSR-11に準拠していますが、
インスタンス生成方法はそれぞれ大きく異なります。

PSR-11に準拠したライブラリを選択することで、開発者の好みに合わせてライブラリを選択することが可能で、
フレームワークを使わずとも簡単にアプリケーションに導入することができます。
いろんなライブラリの実装方法や使い方などを学んで、
コンソールアプリケーションにDIコンテナを導入してみましょう!

アイスタイルCTO 開発で好んで使う言語はPHP, Hack, Go, Scala 好きな財団はApacheソフトウェア財団 github twitter