作りたいものがありすぎる

40歳を過ぎてプログラミングを始めた人の顛末とこれからなど

【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 8章 コンソールアプリケーション 前半資料

以下の記事は2019年9月12日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読&勉強会 コンソールアプリケーションの輪読会資料の一部となります。
今回は 4章前半 8-1 『Commandの基礎』, 8-2『Commandの実装』部分の記事をアップします。

また、元になっている書籍は以下となります。

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

また書籍のコードが記載されているGitHubレポジトリは以下となります。とても助かります!
GitHub - laravel-socym/chapter08

  • 8-1 Commandの基礎

    • 8-1-1 クロージャによるCommandの基礎
    • 8-1-2 クラスによるCommandの作成
    • 8-1-3 Commandへの入力
    • 8-1-4 Commandからの入力
    • 8-1-5 Commandの実行
  • 8-2 Commandの実装

    • 8-2-1 サンプル実装の仕様
    • 8-1-2 Commandの生成
    • 8-1-3 ユースケースクラスとサービスクラスの分離
    • 8-2-4 ユースケースクラスのひな形を作成する
    • 8-2-5 サービスクラスの実装
    • 8-2-6 ユースケースクラスの実装
    • 8-2-7 Commandクラスの仕上げ


8-1 Commandの基礎

LaravelにはCommandというコンポーネントがあり、これを使えばコンソールアプリケーションに必要な機能を簡単に実装できる
Commandの作り方には2種類ある

  • クロージャ
    • 簡単な処理を書くのに向いている
  • クラス
    • 複雑な処理にも対応可能


8-1-1 クロージャによるCommandの基礎

こんな風にオリジナルのコマンドを作る事ができる ここでは単純な文字列を表示するCommandをクロージャで作る

実行してみる

php artisan hello:closure
Hello Closure command!

routes/console.phpクロージャで書いて定義する

<?php

Artisan::command('hello:closure', function () {
    $this->comment('Hallo Closure Command!');  // Command本体 文字列出力
})->describe('サンプルコマンド(クロージャ実装)'); //コマンドの説明

routes/console.php にコマンドを書くとlistに自動登録される、以下のコマンドで登録が確認できる

実行してみる

vagrant@homestead:~/study_command$ php artisan list
Laravel Framework 6.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
      --env[=ENV]       The environment the command should run under
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Available commands:
  clear-compiled       Remove the compiled class file
  down                 Put the application into maintenance mode
  env                  Display the current framework environment

  ### 省略 ###

 flare
  flare:test           Send a test notification to Flare
 hello
  hello:closure        サンプルコマンド(クロージャ実装)
 key
  key:generate         Set the application key

  ### 省略 ###


8-1-2 クラスによるCommandの作成

以下の様なコマンドでClassのひな形を作れる

php artisan make:command HelloCommand
Console command created successfully.

ちなみに、どうも同時にどこかに自動で登録をしているようで ファイル名を間違えて自分で手直しすると、 list に出てこなかったり、実行できなかったり登録する?みたいなコマンドが出たりしたが、正体不明であった)

作成されたファイル app/Console/Commands/HelloCommand.php の中身

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

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

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

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

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        //
    }
}

上記を以下の様に書き換えると、クロージャ同様のコマンドが作れる

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class HelloCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    // コマンドを指定する
    protected $signature = 'hello:class';

    /**
     * The console command description.
     *
     * @var string
     */
    // コマンドの説明を指定
    protected $description = 'サンプルコマンド(クラスで実装)';

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

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    // 実行されるコマンドの定義
    public function handle()
    {
        $this->comment('Hello class command!');
    }
}

実行コマンドと結果

実行してみる

php artisan hello:class 
Hello class command!

リストにも登録されているのが確認できる

vagrant@homestead:~/study_command$ php artisan list

### 省略 ###

flare
  flare:test           Send a test notification to Flare
 hello
  hello:class          サンプルコマンド(クラスで実装)
  hello:closure        サンプルコマンド(クロージャ実装)
 key
  key:generate         Set the application key

### 省略 ###


8-1-3 Commandへの入力

vagrant@homestead:~/study_command$ php artisan hello:option --param
Hello Option param  => 有
vagrant@homestead:~/study_command$ php artisan hello:option
Hello Option param  => 無

コマンドには引数を入れて処理をさせることが出来る
実際にこんなコマンドを叩いた経験があるかもしれないが、

php artisan make:migration [マイグレーションファイル名] --table=[テーブル名]

後ろのファイル名(必須)と テーブル名(オプション) が引数となっている


コマンド引数 (必須)

$signature に引数を { } で指定できる

<?php

protected $signature = 'hello:class {name}';

handleメソッド内で argument() を使って呼べる

<?php

    public function handle()
    {
        $param = $this->argument('name');
        $this->comment('Hello class command! Param => ' . ($param));
    }

コマンド実行結果

やってみる際にコードのコメントを変更

vagrant@homestead:~/study_command$ php artisan hello:class taro
Hello class command! Param => taro

その他引数の指定方法

コマンド引数 内容
{name} 引数を文字列として取得 省略するとエラー
{name?} 引数を文字列として取得 省略可能
{name=default} 引数を文字列として取得 デフォルト値指定
{name*} 引数を配列として取得 省略するとエラー
{name : description} : コロン以降に説明を記述可能 : 前後にスペース必須

オプション引数 (任意)

あっても無くても良いオプションの引数を指定できる
以下は引数を論理値として使える例で、引数の文字列は指定したものを決め打ちしないと駄目

<?php
protected $signature = 'hello:option {--param}';

option()メソッドで利用できる

<?php

    public function handle()
    {
        $param = $this->option('param');
        $this->comment('Hello Option param  => ' . ($param ? '有' : '無'));
    }

やってみる際にコードのコメントを変更

引数ありで実行 指定すると true となる

vagrant@homestead:~/study_command$ php artisan hello:option --param
Hello Option param  => 有

引数無しで実行 指定なしは false となる

vagrant@homestead:~/study_command$ php artisan hello:option
Hello Option param  => 無

オプション引数の指定方法には以下のようなものがある

コマンド引数 内容
{--switch} 引数を論地値として取得 省略するとエラー
{--switch=} 引数を文字列として取得 省略可能
{--switch=default} 引数を文字列として取得 デフォルト値指定
{--switch=*} 引数を配列として取得 省略するとエラー
{--switch : description} :コロン以降に説明を記述可能 : 前後にスペース必須


8-1-4 Commandからの入力

出力の際の文字色や表示レベルをカスタマイズできる

<?php

    public function handle()
    {
        // 文字色,背景色の設定
        $this->line('line');
        $this->info('info');
        $this->comment('comment');
        $this->question('question');
        $this->error('error');
        $this->warn('warn');
        $this->table(['h1', 'h2'], [[1, 2]]);

        // 出力レベルの設定
        $this->info('quiet', OutputInterface::VERBOSITY_QUIET);
        $this->info('normal', OutputInterface::VERBOSITY_NORMAL);
        $this->info('verbose', OutputInterface::VERBOSITY_VERBOSE);
        $this->info('very_verbose', OutputInterface::VERBOSITY_VERY_VERBOSE);
        $this->info('debug', OutputInterface::VERBOSITY_DEBUG);

    }

出力レベルはLaravelのLog機能同様に通常で出力、詳細まで出す、デバッグで出す、みたいなレベル付けで出力ができる

実行例

それぞれ出力されるものが異なってくる

php artisan output:test --quiet
php artisan output:test
php artisan output:test -v
php artisan output:test -vv
php artisan output:test -vvv


8-1-5 Commandの実行

コンソールのコマンドで実行させるだけでなく、プログラムを直接書いてCommandの実行も可能
ここでは例としてroutes/web.php に書いて Log::debug(); で出力してみる

<?php

Route::get('/no_args', function () {
    Artisan::call('output:test');
});

// php artisan hello:option --param=1 --param=2

Route::get('/with_args', function () {
    Artisan::call('hello:option', [
        'arg' => 'value',
        '--switch' => 'false',
    ]);
});
Artisan::call('コマンド', ['引数指定']);

Artisanというファサードで call メソッドを呼ぶ、
第一引数は呼びたいコマンドを指定
第二引数にコールバック関数を入れる 引数を指定可能、上の例では第二引数を配列にして2つの引数を送っている

ブラウザで設定したurlにアクセスすると

http://study_command.test/no_args

<?php

    public function handle()
    {
        $param = $this->option('param');
        $this->comment('Hello Option param  => ' . ($param ? '有' : '無'));

        log::debug(print_r('command HelloOptionParamCommand run!!',1));
    }

handle() に書いたlogが出力されているのが確認できる

laravel.log

[2019-09-08 11:43:58] local.DEBUG: command output:test run!!  

ちなみに handle() の中にcallメソッドで別のコマンドを呼ぶ処理を書けばCommandから別のCommandを実行することができる

<?php
    public function handle()
    {
        $this->call('hello:param');
    }


8-2 Commandの実装

ここでは実際のコマンドラインアプリケーションの実装方法の解説をExportOrdersCommandで説明する

8-2-1 サンプル実装の仕様

ここではコマンド実行でDBの値をTSV形式で出力できる機能を実装する

TSVとは何? Weblio辞書

フルスペル:Tab Separated Values 読み方:ティーエスブイ 別名:タブ区切り TSVとは、文字や文字列の間にタブ記号を挿入して区切りを設けること、あるいは、そのようにして各データを区切って管理するファイル形式のことである。

書籍にはDB定義のカラム仕様や出力されるデータの形式が詳細に記載されているがここでは割愛する。

<?php
        // ひとまずseederファイルの一部でDB構造はなんとなく把握してください。
        Schema::create('orders', function (Blueprint $table) {
            $table->increments('id');
            $table->string('order_code', 32);
            $table->dateTime('order_date');
            $table->string('customer_name', 100);
            $table->string('customer_email', 255);
            $table->string('destination_name', 100);
            $table->string('destination_zip', 10);
            $table->string('destination_prefecture', 10);
            $table->string('destination_address', 100);
            $table->string('destination_tel', 20);
            $table->integer('total_quantity');
            $table->integer('total_price');
            $table->timestamps();

            $table->unique('order_code');
            $table->index('order_date');
        });

        Schema::create('order_details', function (Blueprint $table) {
            $table->string('order_code', 32);
            $table->integer('detail_no');
            $table->string('item_name', 100);
            $table->integer('item_price');
            $table->integer('quantity');
            $table->integer('subtotal_price');

            $table->primary(['order_code', 'detail_no']);

            $table->index('order_code');
            /** @noinspection PhpUndefinedMethodInspection */
            $table->foreign('order_code')->references('order_code')->on('orders');
        });

登録されるコマンドは以下の様なものとなる

vagrant@homestead:~/study_command$ php artisan list
Laravel Framework 6.0.1
  ### 省略 ###

app
  app:export-orders    購入情報を出力する

  ### 省略 ###

出力される内容は以下の様なタブ区切りでカラム名とレコードを出力する

 php artisan app:export-orders
購入コード      購入日時        明細番号        商品名  商品価格        購入点数小計金額        合計点数        合計金額        購入者氏名      購入者メールアドレス    送付先氏名      送付先郵便番号  送付先都道府県  送付先住所      送付先電話番号
1111-1111-1111-1111     2019-09-08 00:00:00     1       商品1   1000    1      1000     大阪 太郎       osaka@example.com       送付先 太郎     1234567 大阪府 送付先住所1     06-0000-0000


8-2-2 Commandの生成

8-1章でのコマンドに習いファイルを作成し、ひな形として動くコマンドファイルを作成する

php arrisan make:command ExportOrdersCommand

app\Console\Commands\ExportOrdersCommand.php

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class ExportOrdersCommand extends Command
{
    // コメントは省略
    protected $signature = 'app:export-orders';

    protected $description = '購入情報を出力する';

    public function __construct()
    {
        parent::__construct();
    }

    public function handle()
    {
        $this->info('hello');
    }
}

実行してみてhelloを確認

vagrant@homestead:~/study_command$ php artisan app:export-orders
hello


8-2-3 ユースケースクラスとサービスクラスの分離

ここでは設計と実装について説明しているがこのUML図とコメントでおおよそを把握してください。

f:id:sakamata:20190911082822p:plain

処理毎にクラスを分離して役割を明確にして、定められた役割のみを担うように実装をする。
これにより処理の再利用性が高まる。 8-3 では実際に ExportOrdersServiceクラスを再利用する。

また、テストを容易にでくるメリットもあり、図の真ん中の ExportOrderUseCaseも単体でテスト可能となる。


8-2-4 ユースケースクラスのひな形を作成する

まずごく簡単な処理の流れがつかめる実装を行う Commandクラス(1階層目)に ExportOrdersUseCase を注入して run メソッドに引数を入れて走らせる処理を書く

<?php

namespace App\Console\Commands;

use App\UseCases\ExportOrdersUseCase;
use Carbon\Carbon;
use Illuminate\Console\Command;

class ExportOrdersCommand extends Command
{
    protected $signature = 'app:export-orders';

    /** @var TemplateExportOrdersUseCase */
    private $useCase;

    protected $description = '購入情報を出力する';

    public function __construct(ExportOrdersUseCase $useCase)
    {
        parent::__construct();
        $this->useCase = $useCase;
    }

    public function handle()
    {
        $tsv = $this->useCase->run(Carbon::today());
        echo $tsv;
    }
}

ExportOrdersUseCase クラス(2階層目)には runメソッドを書いて渡されたCarbonオブジェクトで Y-m-d形式の表示を行う仮実装を行う。

<?php

declare(strict_types=1);

namespace App\UseCases;

use App\Services\ExportOrdersService;
use Carbon\Carbon;

final class TemplateExportOrdersUseCase
{
    /**
     * @param Carbon $targetDate
     * @return string
     */
    public function run(Carbon $targetDate): string
    {
        return $targetDate->format('Y-m-d') . 'の購入情報';
    }
}

コマンド実行で 本日 + 文字列 が出力されるようになる。

php artisan app:export-orders
2019-09-09の購入情報


8-2-5 サービスクラスの実装

次に3階層目(Service)の処理を書く

<?php

declare(strict_types=1);

namespace App\Services;

use Carbon\Carbon;
use Generator;
use Illuminate\Database\Connection;

final class ExportOrdersService
{
    /** @var Connection */
    private $connection;

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
    }

    /**
     * 対象日の購入情報を取得
     *
     * @param Carbon $date
     * @return Generator
     */
    public function findOrders(Carbon $date): Generator
    {
        return $this->connection
            ->table('orders')
            ->join('order_details', 'orders.order_code', '=', 'order_details.order_code')
            ->select([
                'orders.order_code',
                'orders.order_date',
                'orders.total_price',
                'orders.total_quantity',
                'orders.customer_name',
                'orders.customer_email',
                'orders.destination_name',
                'orders.destination_zip',
                'orders.destination_prefecture',
                'orders.destination_address',
                'orders.destination_tel',
                'order_details.*',
            ])
            ->where('order_date', '>=', $date->toDateString())
            ->where('order_date', '<', $date->copy()->addDay()->toDateString())
            ->orderBy('order_date')
            ->orderBy('orders.id')
            ->orderBy('order_details.detail_no')
            ->cursor();  // ここがポイント! cursor は呼び出し時点でレコードの読み込みはしない

            // https://localdisk.hatenablog.com/entry/2016/07/20/173208
            // cursor メソッドを使っています。これは 5.2.33 から追加されました*1。
            // 内部でジェネレータを使っているのでメモリ不足で死ななくて最高です。
    }
}

クエリビルダで単純に必要なデータを取得しているのみ。 引数にCoerbonの日付を入れてクエリ条件にしている。

ポイントは最後の cursor() メソッドでこれは呼び出し時点でクエリを実行しないので、メモリ的に厳しい処理にも対応できるらしい。

コードの詳細な説明は書籍を参照のこと。


8-2-6 ユースケースクラスの実装

次に2階層目(UseCase)クラスを実装する。

<?php

declare(strict_types=1);

namespace App\UseCases;

use App\Services\ExportOrdersService;
use Carbon\Carbon;

final class ExportOrdersUseCase
{
    /** @var ExportOrdersService */
    private $service;

    public function __construct(ExportOrdersService $service)
    {
        $this->service = $service;
    }

    /**
     * @param Carbon $targetDate
     * @return string
     */
    public function run(Carbon $targetDate): string
    {
        // (1) データベースから購入情報を取得
        $orders = $this->service->findOrders($targetDate);

        // (2) TSV ファイル用コレクションを生成
        $tsv = collect();
        // (3) タイトル行を追加
        $tsv->push($this->title());

        // (4) 購入情報を追加
        foreach ($orders as $order) {
            $tsv->push([
                $order->order_code,
                $order->order_date,
                $order->detail_no,
                $order->item_name,
                $order->item_price,
                $order->quantity,
                $order->subtotal_price,
                $order->customer_name,
                $order->customer_email,
                $order->destination_name,
                $order->destination_zip,
                $order->destination_prefecture,
                $order->destination_address,
                $order->destination_tel,
            ]);
        }

        // (5) 各要素を TSV 形式に変換
        return $tsv->map(function (array $values) {
                return implode("\t", $values);
            })->implode("\n") . "\n";
    }

    private function title(): array
    {
        return [
            '購入コード',
            '購入日時',
            '明細番号',
            '商品名',
            '商品価格',
            '購入点数',
            '小計金額',
            '合計点数',
            '合計金額',
            '購入者氏名',
            '購入者メールアドレス',
            '送付先氏名',
            '送付先郵便番号',
            '送付先都道府県',
            '送付先住所',
            '送付先電話番号',
        ];
    }
}

書籍にはないが、そろそろSeederを追加してダミーデータを入れておく ファイルを書くより書籍用のGithubリポジトリのコードを流用するのが良い。

php artisan migrate
php artisan db:seed

seeder に購入日を本日に指定したレコードを挿入しておく( Carbon::now()でよろしくやる )

作ったコマンド実行をすると、見出しとレコードがタブスペース繋がりで出力される

php artisan app:export-orders

購入コード      購入日時        明細番号        商品名  商品価格        購入点数小計金額        合計点数        合計金額        購入者氏名      購入者メールアドレス    送付先氏名      送付先郵便番号  送付先都道府県  送付先住所      送付先電話番号
1111-1111-1111-1111     2019-09-08 00:00:00     1       商品1   1000    1      1000     大阪 太郎       osaka@example.com       送付先 太郎     1234567 大阪府 送付先住所1     06-0000-0000

データが無い場合は見出しのみ出力される


8-2-7 Commandクラスの仕上げ

日付を引数で指定して渡してやるとその日に購入されたレコードが表示されるようになる

<?php

### 省略 ###

    protected $signature = 'app:export-orders {date}';

  ### 省略 ###

    public function handle()
    {
        // 日付のパラメーターを取得してUseCaseに処理を渡す
        $date = $this->argument('date');
        $tagetDate = Carbon::createFromFormat('Ymd', $date);
        $tsv = $this->useCase->run($tagetDate);

        echo $tsv;
    }

### 省略 ###

出力結果

vagrant@homestead:~/study_command$ php artisan app:export-orders 20180629
購入コード      購入日時        明細番号        商品名  商品価格        購入点数        小計金額        合計点数        合計金額        購入者氏名      購入者メールアドレス    送付先氏名      送付先郵便番号  送付先都道府県  送付先住所  送付先電話番号
1111-1111-1111-1112     2018-06-29 23:59:59     1       商品1   1000    2       2000    神戸 花子       kobe@example.com        送付先 太郎     1234567 兵庫県  送付先住所2     078-0000-0000
1111-1111-1111-1112     2018-06-29 23:59:59     2       商品2   500     1       500     神戸 花子       kobe@example.com        送付先 太郎     1234567 兵庫県  送付先住所2     078-0000-0000
vagrant@homestead:~/study_command$ php artisan app:export-orders 20190908
購入コード      購入日時        明細番号        商品名  商品価格        購入点数        小計金額        合計点数        合計金額        購入者氏名      購入者メールアドレス    送付先氏名      送付先郵便番号  送付先都道府県  送付先住所  送付先電話番号
1111-1111-1111-1111     2019-09-08 00:00:00     1       商品1   1000    1       1000    大阪 太郎       osaka@example.com       送付先 太郎     1234567 大阪府  送付先住所1     06-0000-0000

さらに必須引数に日時 オプションに出力先を指定すれば、その場所にファイルが出力される

<?php
    // オプションの引数を追加
    protected $signature = 'app:export-orders {date} {--output=}';

    public function handle()
    {
        $date = $this->argument('date');
        $tagetDate = Carbon::createFromFormat('Ymd', $date);
        $tsv = $this->useCase->run($tagetDate);

        // オプションの引数を取得できれば指定されたpathに出力
        $outputFilePath = $this->option('output');
        if (is_null($outputFilePath)) {
            echo $tsv;
            return;
        }

        file_put_contents($outputFilePath, $tsv);
    }

出力pathとファイル名をオプションで指定すればファイルとして出力できる。

php artisan app:export-orders 20190908 --output tmp/orders.tsv