dorivenの日記

気がついたら社会人。気になる技術的なことについて少しずつ書いていけたらと思っております。

直感的に使えるPHPコマンドラインオプションパーサーを作った

(この記事はQiitaに同様のものを同じ人が投稿しています)

https://packagist.org/packages/d0riven/php-flags#0.1

TL;DR;

自分が欲しいPHPコマンドラインオプションパーサーがなかったので作った。 このライブラリはドキュメントを見なくても使えるくらいシンプルかつ型チェックが行えるコマンドラインオプションパーサーである。 PHPライブラリを作ったことで色々と周辺技術や最新のCIなどを学べて良かった。

作ろうと思った背景

AuraCliをプロジェクトで採用しており、個人的にそれに違和感はなかった。 しかし、PHPに慣れていないメンバーからわかり辛いという声が上がった。

理由は shortやlongの表現, :, ::, \*, などの記号が何を意味しているかがドキュメントみないと分からない という物だった。 当初は「いや、それくらいドキュメント見るでしょ…」と思ったのだがgoなどのオプションパーサー(e.g. flag, kingpin, go-flags)に比べるとたしかに以下の使い辛さを感じる。

  • 宣言とデータ取得のそれぞれでオプション名を記述する必要があり手間
  • 型が保証されていないので自分で検証する必要がある
  • short記法やlong記法、必須オプションかどうかなどがドキュメントを見ないと良くわからない

つまりこういうPHPのオプションパーサーが存在あればそちらを使えばいいと考えた。

  • オプションの宣言と同時にオブジェクトが返ってきてパース後にはそこに値が格納される
    • これにより、宣言とデータ取得でオプション名を重複して記述する必要がなくなる
  • オプションの宣言で型が定義可能でパース時に違反していたら例外で落としてくれる
  • メソッドの補完でどんなオプションが定義可能か、どんなオプションが設定されているかがひと目で分かる
    • goのkingpinのFlagが自分が期待しているもの
    • ドキュメントを読まなくてもUsageを見れば使い方がぱっと分かる程度のもの
  • シンプルにオプションをパースしてくれればよくサブコマンドの定義などは不要
    • ドキュメントが大量に書かれているものは理解に

ということで、 https://qiita.com/tanakahisateru/items/785a56fb6950d8a52006https://github.com/ziadoz/awesome-php#command-line あたりを参考にして既存のPHPコマンドラインオプションパーサーに自分の求めるものがあるか見てみた。

上のものが近しかったりするが、二重定義が必要だったり、型が保証されていなかったり、shortやlongの定義がgetoptっぽかったりと微妙に手が届かない。

結果、作ったほうが早いなと思い作った。

php-flags

go-flagsから持ってきたものの定義の仕方はkingpinという… あとは大体README.mdに書いてあることを日本語になおして書く。

特徴

  • フラグや引数の定義を関数によって定義する
    • コード補完の関数一覧を見れば何ができるのかが大体分かる
    • 結果的にドキュメントを見る必要がない
  • option名の二重記述が必要ない
    • オプションの宣言と同時にオブジェクトが返ってきてパース後にはそこに値が格納される
  • オプションと引数のみしか扱わずシンプル
    • ハードなCLIコマンドをPHPで今どき書かないでしょ、と思っているので
  • ヘルプの自動生成

コード補完の関数一覧を見れば何ができるのかが大体分かる

以下のようにコード補完でユーザ定義可能なものの一覧が出てくる。

image.png

image.png

image.png

image.png

image.png

使い方

例えばpingコマンドラインの定義をこのCLIで行った場合はこんな感じになる。 これ以上このライブラリの説明はしない。 これで分からないようならつまり自分が作ったライブラリはわかり辛かったという話なため。

もし使いたいと思ったらgithubのレポジトリにもう少し詳細な使い方が書かれているので見てほしい。

<?php
use PhpFlags\Parser;
use PhpFlags\Spec\ApplicationSpec;

// example ping
$spec = ApplicationSpec::create();
$spec->version('1.0.0')->clearShort();
$count = $spec->flag('count')->short('c')->default(-1)
    ->desc('Number of times to send an ICMP request. The default of -1 sends an unlimited number of requests.')
    ->validRule(function($count) {
        return $count >= -1;
    })
    ->int('request count');
$timeout = $spec->flag('timeout')->short('t')->default(5)
    ->desc('Timeout seconds for ICMP requests.')
    ->validRule(function($timeout) {
        return $timeout >= 0;
    })
    ->int('request count');
$verbose = $spec->flag('verbose')->short('v')
    ->desc('verbose output.')
    ->bool();
$host = $spec->arg()
    ->desc('IP of the host for the ICMP request.')
    ->validRule(function($ip){
        return preg_match('/^(([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/', $ip);
    })
    ->string('host');
try {
    Parser::create($spec)->parse($argv);
} catch (PhpFlags\InvalidArgumentsException $e) {
    echo $e->getMessage(), PHP_EOL;
    exit(1);
} catch (PhpFlags\InvalidSpecException $e) {
    echo $e->getMessage(), PHP_EOL;
    exit(1);
}
echo "  count: ", $count->get(), PHP_EOL;
echo "timeout: ", $timeout->get(), PHP_EOL;
echo "verbose: ", $verbose->get() ? 'true' : 'false', PHP_EOL;
echo "   host: ", $host->get(), PHP_EOL;

実際にコマンドラインオプションが渡されるとこんな値が取得できる。

# オプションが定義されてなければデフォルト値を使用する
$ php ping.php 127.0.0.1
  count: -1
timeout: 5
verbose: false
   host: 127.0.0.1

# 対応するオプションがコマンドラインに記載されていたら、その値が取得される
   $ php ping.php -c 3 -t 10 -v 127.0.0.1
or $ php ping.php -c=3 -t=10 -v 127.0.0.1
  count: 3
timeout: 10
verbose: true
   host: 127.0.0.1

# validRuleに指定されたルールに違反するならInvalidArgumentExceptionが投げられる
$ php ping.php -t=foo 127.0.0.1
The values does not matched the specified type. expect_type:int, given_type:string, value:foo

$ php ping.php -t=-1 127.0.0.1
invalid by validRule. flag:--timeout, value:-1

定義されたspecによってヘルプが自動的に生成される。

# --helpか-hでヘルプが出力される
   $ php ping.php --help
or $ php ping.php -h
Usage:
  php ping.php [FLAG]... (host)

FLAG:
  -c [request count], --count[=request count]
          Number of times to send an ICMP request. The default of -1 sends an unlimited
          number of requests.

  -t [timeout second], --timeout[=timeout second]
          Timeout seconds for ICMP requests.

  -v, --verbose
          verbose output.

ARG:
  host
          IP of the host for the ICMP request.

小話

BDDなテスティングフレームワークのkahlanを採用してみた

describe-itなBDDのテストをフラグや引数定義後のパース結果のテストを表現したかった。 phpunitで表現できる方法もあったが、contextがなかったので使うのはやめた。 kahlanを使ってみることにした。

個人的には使ってみて以下の理由から微妙だと感じた。

  • 呼び出し方が特殊なのでphpstanで大量のエラーが出るのでphpstanをかけられない
  • fixtureで渡したい値が $this-> で状態を保持して渡す必要があるため、無駄に状態を持つ
  • クラス内で $this-> を呼び出しているわけではないため、型ヒントが使えず結果補完をするのに一手間いる
  • 実行時にファイルをコピーしてから実行してしまうせいなのか(ちゃんとコードは読んでない)、xdebugのremote-debugのパスの一致が取れず工夫しないとデバッグできない

今後保守を継続していくことを考えると、最初のphpunitの方が良かったとやや後悔している。 が使ってみないとこういうのは分からないので仕方ない。

traitでmixinなコードをがっつり書いた

今の環境だとtraitをフルに使う必要もなく、微妙なtraitの使い方をしているものも見ているので、ちゃんとtraitを使ってみたこと正直なかった。 今回だと共通の振る舞いを定義することが多くそれをtraitで共通化したりできたので良かった。 ただ本当にちゃんと使えているかは自信がないので、LaravelとかのFWのコードを読んでみて決めた方が良さそうだとは思っている。

最後に

久々にPHPのライブラリを作ったのとオープンソース作法がわからなかったり、最近流行りのCIのgithub actions使ってみたりで色々と学びは多かった。 このライブラリが最終的に使われなくても車輪の再発明PHPのツール周りを一通り触れたので良かった。

ちなみにコントリビュートは大歓迎なのでForkしてPR投げてもらえると嬉しい。