Silly

PHPでCLIアプリケーションを作成するためのマイクロフレームワーク。Symfony Consoleをベースに、よりシンプルなAPIを提供。

phpclicommand-linemicro-framework

フレームワーク

Silly

概要

SillyはPHPでCLIアプリケーションを作成するためのマイクロフレームワークです。Symfony Consoleをベースに、よりシンプルなAPIを提供します。シンプルなCLIツールを素早く作成したい開発者に選ばれており、最小限の設定で始められる軽量なフレームワークです。

詳細

SillyはSymfony Consoleコンポーネントのパワーを活用しながら、より直感的で簡潔なAPIを提供します。複雑な設定なしに、関数型のアプローチでコマンドを定義できるため、小さなCLIツールの開発に最適です。

主な特徴

  • シンプルなAPI: 最小限のコードでコマンドを定義
  • 関数型アプローチ: クロージャーや関数でコマンドロジックを記述
  • Symfony Console基盤: 安定したSymfony Consoleの機能を活用
  • 軽量: 最小限の依存関係とオーバーヘッド
  • 柔軟性: 必要に応じてSymfony Consoleの機能に直接アクセス可能
  • PSR準拠: PSR-11コンテナとの統合
  • CLI表示: プログレスバー、テーブルなどの豊富な表示機能

メリット・デメリット

メリット

  • 学習コストが低い: 非常にシンプルで理解しやすいAPI
  • 高速開発: 最小限のコードで素早くCLIツールを構築
  • 軽量: 少ない依存関係とメモリ使用量
  • Symfony互換: Symfony Consoleの豊富な機能を利用可能
  • 柔軟性: 関数型とオブジェクト指向の両方のアプローチをサポート

デメリット

  • 機能制限: 大規模で複雑なCLIアプリケーションには不向き
  • コミュニティ: 他の主要フレームワークと比べて小さなコミュニティ
  • 拡張性: 高度なカスタマイズには限界がある
  • ドキュメント: 相対的に少ないドキュメントとリソース

主要リンク

書き方の例

インストールと基本設定

composer require silly/silly

基本的な使用例

#!/usr/bin/env php
<?php
// cli-app.php

require_once 'vendor/autoload.php';

use Silly\Application;

$app = new Application();

// シンプルなコマンド定義
$app->command('hello [name]', function ($name, $output) {
    $name = $name ?: 'World';
    $output->writeln("Hello $name!");
});

// オプション付きコマンド
$app->command('greet name [--yell]', function ($name, $yell, $output) {
    $message = "Hello $name";
    
    if ($yell) {
        $message = strtoupper($message);
    }
    
    $output->writeln($message);
});

// 複数引数のコマンド
$app->command('sum numbers*', function (array $numbers, $output) {
    $sum = array_sum($numbers);
    $output->writeln("Sum: $sum");
});

$app->run();

より高度な例

#!/usr/bin/env php
<?php
// advanced-cli.php

require_once 'vendor/autoload.php';

use Silly\Application;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

$app = new Application('File Manager', '1.0.0');

// ファイル処理コマンド
$app->command('process:files input [--format=] [--output=]', function (
    $input, 
    $format, 
    $output, 
    InputInterface $inputInterface, 
    OutputInterface $outputInterface
) {
    $format = $format ?: 'txt';
    
    $outputInterface->writeln("<info>Processing files from: $input</info>");
    $outputInterface->writeln("<comment>Format: $format</comment>");
    
    // ファイル一覧を取得
    $files = glob("$input/*.$format");
    
    if (empty($files)) {
        $outputInterface->writeln("<error>No files found with format: $format</error>");
        return 1;
    }
    
    // プログレスバーを表示
    $progressBar = new ProgressBar($outputInterface, count($files));
    $progressBar->start();
    
    $results = [];
    foreach ($files as $file) {
        $results[] = [
            'file' => basename($file),
            'size' => filesize($file),
            'modified' => date('Y-m-d H:i:s', filemtime($file))
        ];
        
        $progressBar->advance();
        usleep(100000); // 処理をシミュレート
    }
    
    $progressBar->finish();
    $outputInterface->writeln('');
    
    // 結果をテーブルで表示
    $table = new Table($outputInterface);
    $table->setHeaders(['File', 'Size (bytes)', 'Modified']);
    $table->setRows($results);
    $table->render();
    
    // ファイル出力
    if ($output) {
        $csv = "File,Size,Modified\n";
        foreach ($results as $row) {
            $csv .= implode(',', $row) . "\n";
        }
        file_put_contents($output, $csv);
        $outputInterface->writeln("<info>Results saved to: $output</info>");
    }
    
    return 0;
});

// ユーティリティコマンド
$app->command('utils:hash file [--algorithm=]', function ($file, $algorithm, $output) {
    $algorithm = $algorithm ?: 'md5';
    
    if (!file_exists($file)) {
        $output->writeln("<error>File not found: $file</error>");
        return 1;
    }
    
    $supportedAlgorithms = ['md5', 'sha1', 'sha256'];
    if (!in_array($algorithm, $supportedAlgorithms)) {
        $output->writeln("<error>Unsupported algorithm. Use: " . implode(', ', $supportedAlgorithms) . "</error>");
        return 1;
    }
    
    $hash = hash_file($algorithm, $file);
    
    $output->writeln("<info>File:</info> $file");
    $output->writeln("<info>Algorithm:</info> $algorithm");
    $output->writeln("<info>Hash:</info> $hash");
    
    return 0;
});

// インタラクティブコマンド
$app->command('interactive:setup', function (InputInterface $input, OutputInterface $output) {
    $helper = $app->getHelperSet()->get('question');
    
    $output->writeln('<info>Interactive Setup</info>');
    $output->writeln('');
    
    // 質問の定義
    $questions = [
        new \Symfony\Component\Console\Question\Question('Project name: ', 'my-project'),
        new \Symfony\Component\Console\Question\Question('Version: ', '1.0.0'),
        new \Symfony\Component\Console\Question\ChoiceQuestion(
            'Environment: ',
            ['development', 'production', 'testing'],
            'development'
        ),
        new \Symfony\Component\Console\Question\ConfirmationQuestion('Enable debug mode? [y/N] ', false),
    ];
    
    $answers = [];
    foreach ($questions as $question) {
        $answers[] = $helper->ask($input, $output, $question);
    }
    
    // 設定ファイル生成
    $config = [
        'name' => $answers[0],
        'version' => $answers[1], 
        'environment' => $answers[2],
        'debug' => $answers[3],
    ];
    
    file_put_contents('config.json', json_encode($config, JSON_PRETTY_PRINT));
    
    $output->writeln('<info>Configuration saved to config.json</info>');
    
    return 0;
});

// バックアップコマンド
$app->command('backup:create source destination [--compress]', function (
    $source, 
    $destination, 
    $compress, 
    OutputInterface $output
) {
    if (!is_dir($source)) {
        $output->writeln("<error>Source directory not found: $source</error>");
        return 1;
    }
    
    $output->writeln("<info>Creating backup...</info>");
    $output->writeln("<comment>Source: $source</comment>");
    $output->writeln("<comment>Destination: $destination</comment>");
    
    // バックアップロジック(簡略化)
    $files = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS)
    );
    
    $totalFiles = iterator_count($files);
    $files->rewind();
    
    $progressBar = new ProgressBar($output, $totalFiles);
    $progressBar->start();
    
    if (!is_dir($destination)) {
        mkdir($destination, 0755, true);
    }
    
    foreach ($files as $file) {
        $relativePath = str_replace($source . DIRECTORY_SEPARATOR, '', $file->getPathname());
        $destPath = $destination . DIRECTORY_SEPARATOR . $relativePath;
        
        $destDir = dirname($destPath);
        if (!is_dir($destDir)) {
            mkdir($destDir, 0755, true);
        }
        
        copy($file->getPathname(), $destPath);
        $progressBar->advance();
    }
    
    $progressBar->finish();
    $output->writeln('');
    
    if ($compress) {
        $output->writeln('<info>Compressing backup...</info>');
        // 圧縮処理(実装省略)
        $output->writeln('<info>Backup compressed</info>');
    }
    
    $output->writeln('<info>Backup completed successfully!</info>');
    
    return 0;
});

$app->run();

DIコンテナ統合の例

#!/usr/bin/env php
<?php
// di-example.php

require_once 'vendor/autoload.php';

use Silly\Application;
use Psr\Container\ContainerInterface;

// シンプルなDIコンテナ実装
class SimpleContainer implements ContainerInterface
{
    private array $services = [];
    
    public function set(string $id, $service): void
    {
        $this->services[$id] = $service;
    }
    
    public function get(string $id)
    {
        if (!$this->has($id)) {
            throw new \Exception("Service not found: $id");
        }
        
        $service = $this->services[$id];
        
        if (is_callable($service)) {
            return $service($this);
        }
        
        return $service;
    }
    
    public function has(string $id): bool
    {
        return isset($this->services[$id]);
    }
}

// サービス定義
class DatabaseService
{
    public function connect(): string
    {
        return "Connected to database";
    }
    
    public function query(string $sql): array
    {
        return ["Result for: $sql"];
    }
}

class LoggerService  
{
    public function log(string $message): void
    {
        echo "[" . date('Y-m-d H:i:s') . "] $message\n";
    }
}

// コンテナ設定
$container = new SimpleContainer();
$container->set('database', function () {
    return new DatabaseService();
});
$container->set('logger', function () {
    return new LoggerService();
});

// アプリケーション作成
$app = new Application('DI Example', '1.0.0');
$app->useContainer($container);

// サービスを使用するコマンド
$app->command('db:status', function (DatabaseService $database, LoggerService $logger, $output) {
    $logger->log('Checking database status...');
    $status = $database->connect();
    $output->writeln("<info>$status</info>");
    $logger->log('Database status check completed');
});

$app->command('db:query sql', function ($sql, DatabaseService $database, LoggerService $logger, $output) {
    $logger->log("Executing query: $sql");
    $results = $database->query($sql);
    
    foreach ($results as $result) {
        $output->writeln($result);
    }
    
    $logger->log('Query executed successfully');
});

$app->run();

テストの例

<?php
// tests/CliTest.php

use PHPUnit\Framework\TestCase;
use Silly\Application;
use Symfony\Component\Console\Tester\ApplicationTester;

class CliTest extends TestCase
{
    private Application $app;
    private ApplicationTester $tester;
    
    protected function setUp(): void
    {
        $this->app = new Application();
        
        // テスト用コマンドを追加
        $this->app->command('test:hello [name]', function ($name, $output) {
            $name = $name ?: 'World';
            $output->writeln("Hello $name!");
        });
        
        $this->tester = new ApplicationTester($this->app);
    }
    
    public function testHelloCommand(): void
    {
        $this->tester->run(['command' => 'test:hello']);
        
        $this->assertEquals(0, $this->tester->getStatusCode());
        $this->assertStringContains('Hello World!', $this->tester->getDisplay());
    }
    
    public function testHelloCommandWithName(): void
    {
        $this->tester->run([
            'command' => 'test:hello',
            'name' => 'PHP'
        ]);
        
        $this->assertEquals(0, $this->tester->getStatusCode());
        $this->assertStringContains('Hello PHP!', $this->tester->getDisplay());
    }
}

composer.jsonの設定例

{
    "name": "my/cli-tool",
    "description": "My CLI tool built with Silly",
    "type": "project",
    "require": {
        "php": "^8.0",
        "silly/silly": "^1.8"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "bin": ["cli-app.php"],
    "scripts": {
        "test": "phpunit tests/"
    }
}