長らく――というほどではないが、そこそこの期間をコンソールアプリの自作に費やしてきたので、コツのようなものを晒してみる。
対話型を避け、他のツールとの連携を重視する
今、この時代にコンソールアプリを新たに作成する場合、ユースケースの大半は「自分用」ないし「自分を含む開発者向け」だと考えたほうがよいだろう。すなわち「ユーザ≒それなりに腕に覚えのある人」である。
もしも利用者層をもう少し広げたいのならば、コンソールアプリは避けて、GUIアプリにするべきだろう。
利用者層を限定できるとなると、コンソールアプリを設計する際の方針が明確になる。
- 対話型を避ける。
- 他のツールとの連携を重視する。
対話型ツールは、(そのツールの目的・機能次第だが)それはそれで有用ではあるものの、作業の自動化が難しく、他のツールとの連携もとりにくい。つまり、スケールし難い。
対話型を諦めると、ツールの作り次第ではあるものの、作業自動化や、他のツールとの連携への道が開けてくる。この分野は「Unixのコマンド文化」という先達がいるので、その知見を取り入れればよい。
他のツールとの連携については、「作成したツールの拡張性」ということ以外に重要な点がある。それは「他のツールで可能なことは他のツールに任せて、他のツールでは実現が難しい部分だけ開発する」ということだ。これによって、開発するツールがコンパクトになり、開発に要する期間の短縮や、潜在的に入れ込んでしまうだろうバグ件数の低減に結びつく。特に、楽をするための自分用ツールでは、開発や不具合対応に時間を割きたくないので、こういった視点は重要だ。
念のため追記:当然ながら「スケールし易い対話型ツール」は作成可能だ*1。しかし、その作成コストは「『Unixのコマンド文化』を踏襲したコンソールアプリ」よりも大きくなる傾向にあると思う。
既存のツールの組み合わせで解決できないか考えてみる
いざ、コンソールアプリ開発へ――と一歩踏み出すにあたり、コードを書き出す前に、まず既存のツールを組み合わせることで解決できないか、考えてみるべきだろう。
bashなどのシェル上でワンライナーで実現できる内容なら、コードの規模は大きくないので、シェルスクリプト化してコードを整理するのに苦労することはないはずだ。
仮にシェルスクリプトの暗黒面にうんざりする事態に直面した場合は、シェルスクリプトの身の丈に合わない大きさの複雑なツールを作ろうとしていないか、またはシェルスクリプトには不向きなことを実現しようとしていないか、振り返ってみるべきだろう。もしかしたら、他の言語を使うべきかもしれない。
足りない部分だけ開発する
既存のツールの組み合わせでは解決できないので、新たにコンソールアプリを書くことに決めたとする。
ツールの内容次第だが、既存のツールでは解決できない、不足している部分のみツール化して実装することを検討するべきだ。限られた部分のみ実装することにより、開発の手間が減ることになる。
また、ツールを実装する際も、処理系付属の標準ライブラリや、サードパーティのライブラリを積極的に活用して、本当に足りない部分だけを実装できないか、検討した方が良いだろう。
より高水準なプログラミング言語を採用する
新たにコンソールアプリを書くにあたり、より高水準で便利な言語を使えるのなら、それらを用いるべきだ。
Unix環境でいうなら、いきなりC言語やC++で実装するのではなく、Perl・Python・Rubyなどの有名どころのスクリプト言語の採用を検討したほうがよい。実際、この3言語はテキストフィルタの実装で用いられることも多い。Unix環境では、依存関係の都合でPerl 5やPython 2 or 3の処理系がインストール済みでも不思議ではない。最近ではGo言語も良い選択肢となるだろう。
Windowsでは、PowerShellが最有力候補だろうか。WSH(とそのデフォルトの言語であるVBScript/JScript)は、今となっては少々古めかしいので、あまりお勧めできない。
解くべき課題によっては、より特化した専用言語を選択してもよいだろう。例えばUnix環境では鉄板のsedやAWK、対話型操作を自動化するExpect、グラフ表現用のデータ記述言語DOTなどは、それ単体でアプリを実装するのは厳しいかもしれないが、「他のツールと組み合わせる」という前提では、驚くほど効果を発揮することがある。Prologなども有用だろう。
もちろん、スクリプト言語を採用する場合は、開発したコンソールアプリの利用者の環境を考慮する必要がある。コンパイル型言語であっても、ランタイムライブラリの有無を考慮する必要があるかもしれない。継続的に使われるツールなら、メンテナの育成(自分以外にその言語で開発できる人がいるか?)も考えておかなくてはならない。
そういう配慮は必要なものの、対応を間違わなければ、より高水準なプログラミング言語を採用することは、非常に魅力的だといえる。
複数の引数に対応できるようにする。
作成するコンソールアプリが引数としてファイルを指定する類のものだった場合は、可能なら一度に複数のファイルを指定できる仕様にするのが望ましいだろう。
作成したツールをコマンドラインから使用する時、もし複数のファイルを処理したいなら、ファイル名としてワイルドカードを用いたくなるかもしれない。Unix環境では、ワイルドカードを展開するのはシェルの役割だが、展開された結果を引数で受け取り処理するのはアプリ側の責務だ。一度に複数の引数を処理するように実装しなくてはならない。
複数の引数に対応できるならば、xargsやfind(1)のオプション-exec
と組み合わせた際に、当該ツールの呼び出し回数が最小化される。プロセス生成回数が減るので、その分だけ処理の高速化が期待できるだろう。
# ファイル1つ ./mycmd foo.txt # ファイル2つ以上 ./mycmd foo.txt bar.txt ./mycmd foo.txt bar.txt baz.txt # ワイルドカードで複数指定 ./mycmd *.txt # find(1)との組み合わせ find . -type f -regex '.*\.txt$' -exec ./mycmd {} +
実行結果(終了状態)を返す。最低でも成功/失敗を返す。
アプリの実行結果を終了状態(Unixシェルなら$?
、Windowsのコマンドプロンプトなら%ERRORLEVEL%
で確認できる値)でも返すようにしておくべきだろう。これによって、シェルスクリプトやバッチファイルにて実行結果と制御構文を組み合わせることが可能となる。
通常は、成功したら0を、失敗や異常終了では1を返すようにしておけば済むことが多い。時にはgrep(1)の「見つかったら0、見つからなかったら1、失敗したら2」のような凝った方法が望ましいこともあるが、そういうケースは稀である。
# 成功ならOKと表示される ./mycmd foo.txt && echo OK # 失敗ならNGと表示される ./mycmd bar.txt || echo NG
※実のところ「成功したら0、失敗したら1」という流儀は、あくまでも「多くのシステムで通用するお約束」でしかない。つまり厳密に言えば移植性に欠けている。可搬性求めるのなら、CやC++では0や1などの直値ではなくEXIT_SUCCESS
とEXIT_FAILURE
を使えばよいだろう。ところで他の言語では、これらのマクロに該当する定義は用意されているのだろうか? Perl 5なら、POSIXモジュールにEXIT_SUCCESS
やEXIT_FAILURE
が用意されているようだが。
フィルタの既定の入力元は標準入力にする。オプションで複数ファイルからの入力に対応する。
作成するコンソールアプリがフィルタの類ならば、既定の入力元は標準入力にするべきだろう。でなければ、パイプライン経由で他のアプリの出力を受け取ることができない。
同時に、引数経由で複数ファイルからの入力にも対応すべきだろう(「複数の引数に対応できるようにする」を参照」)。
# 引数指定されたファイルを処理 ./myfilter foo.txt # 標準入力経由でも受け付ける ./myfilter <foo.txt # 標準入力経由で受け付ける => パイプで結合できる cat foo.txt | ./myfilter ./mycmd foo.txt | ./myfilter # ファイル2つ以上にも対応 ./myfilter foo.txt bar.txt baz.txt
フィルタ以外では、引数の個数が0個だった時の振る舞いを考える。
フィルタの場合、引数の個数が0個の場合には、標準入力からの入力を読もうとするだろう。この振る舞いは一般的なものだ。
では、例えばchmod(1)のように「フィルタではなく(標準入力を読むことはなく)、引数にファイルが指定される」という使い方のコマンドでは、引数の個数が0個の場合にどう振る舞うべきだろうか?
この問いの答えは、なかなか難しい。例えば、実行結果を成功と失敗のどちらにするべきだろうか? 今のところ「そのコマンドのユースケース次第」という玉虫色の回答しかできない。コマンドの出力についても、Unix文化的には「何も出力しない」が正しい気がする一方で、ツールの性格次第では「ヘルプメッセージを出力しても良いかも」と思うこともある。
少なくとも、1つだけ確実に言えることは……引数の個数が0個だった時の振る舞いを考える必要がある、ということだ。
時に異常な入力にたいして「エラー処理して特別扱い」ではなく「異常値を事前に取り除く」などを検討する
1回限りの使い捨てワンライナーならともかく、何度も繰り返し利用されるスクリプトならば、入力にたいしてある程度ロバストに作っておくのは悪くない習慣である。
問題は「ある程度」の基準をどこに置くかだろう。
例えば、フィルタをパイプで数珠つなぎしてテキストレコードを処理する、Arrayをメソッドチェーンで処理する、コレクションをLINQで処理する――という風に、複数のデータをストリームで処理するコードを書いた時、その中に「異常値を見つけたらユーザに問い合わせる」などの特別扱いの機能を挿入するのは難しい。どうしてもエラー処理の部分にて一旦ストリームが途切れてしまう。
この場合は「黙って異常値を取り除く」や「異常値を正常範囲に丸める」といった対応の方が、ストリームを途切れさせずに間に挿入しやすいことが多い。
だから、ケースバイケースではあるが、時には異常値の扱いとして「事前に取り除く」や「正常範囲に丸める」などを検討しても良いだろう。
フィルタの既定の出力先は標準出力にする。オプションで単一ファイルへの出力に対応する。
作成するコンソールアプリがフィルタの類ならば、既定の出力先は標準出力にするべきだろう。でなければ、パイプライン経由で他のアプリに流し込むことができない。
同時に、オプション引数で出力先ファイルを指定できるようにもするべきだろう。
# 出力先未指定の場合は、標準出力に垂れ流す ./myfilter foo.txt # 標準出力に垂れ流す => リダイレクトできる ./myfilter foo.txt >foo_mod.txt # 標準出力に垂れ流す => パイプで結合できる ./myfilter foo.txt | ./mycmd >foo_mod_mod.txt # 出力先を指定できる ./myfilter bar.txt >bar_mod_1.txt ./myfilter -o bar_mod_2.txt bar.txt cmp bar_mod_1.txt bar_mod_2.txt && echo IDENTICAL
フィルタ以外では、時に複数のファイルへの出力に対応するか否か検討する。
フィルタなどで頻出するオプション「-o
」で指定できる出力先は大抵1ヶ所だけである。この仕様でも大抵は問題ないが、しかし「常に問題ない」という訳ではない。ユースケース次第では、複数の入力ファイルにたいして複数の出力先ファイルを指定可能とするべきだろう。
例えば、以前extpcmというツールを作ったことがある。このツールはRIFF WAVEファイルからPCMデータ部分のみを抽出するPythonスクリプトだ。
このツールを作った理由は「大量のRIFF WAVEファイルからPCMデータを取り出す」という作業を自動化するためだった。
ところで、取り出したPCMデータは元ファイルごとに個別に出力する必要があった。extpcmは出力先をオプション-o
で1つしか指定できなかった。そのため、コンソールやスクリプト上でfor文を使って1ファイルごとにextpcmを呼び出さなくてはならなかった。これでは1ファイルごとにPythonの処理系の起動とスクリプトの読み込みが発生してしまい、大量のファイルを扱う際に処理完了まで時間がかかってしまう。
そこで、別ツールとして、複数の出力先ファイルを指定できるextpcmsを作った。extpcmsは入力ファイル数と同じ数の出力先ファイルを要求する仕様だ。extpcmsのおかげで、大量のファイルを扱う場合でもPythonの起動とスクリプトの読み込みが1回だけで済むようになり、処理時間が短縮された。
以上の例のように、ユースケースとして「複数のファイルへの出力」が重要である場合には、教条的にオプション-o
の一般的挙動に拘るのではなく、使い勝手の観点で「複数の出力先ファイルを指定する」という仕様を検討しても良いだろう。
エラー出力等は標準エラーに出力する。
特に、既定の出力先が標準出力であるフィルタの類では、エラーメッセージなどは標準エラー出力に垂れ流すべきだろう。でなければ、フィルタで加工された出力とエラー出力がごちゃ混ぜになってしまう。
出力の機械可読性を考慮する。
特にフィルタにおいて顕著だが、自分が作成したツールの出力をパイプ経由で他のツールに流し込むケースを考慮するなら、時に「ユーザが見やすい出力」よりも「他のツールにて手軽に分解・加工しやすい出力」を選択するべきだろう。
フィルタを自作してみれば分かるが、ヒューマン・リーダブルで例外則が含まれる入力を解析するのは面倒だ。コードを書く側としては、定型的で例外則の少ない入力の方が扱いやすい。
だから、ユースケース次第ではあるが、作ろうとしているツールの出力を他のツールで利用することが多いのならば、機械可読性を考慮した出力とする方が好ましいだろう。
ヘルプメッセージ出力用のオプションを付けておく。
慣習に従って、オプション「-h
」や「/?
」などでヘルプメッセージを出力する機能を追加しておくことは、良い習慣だと言えるだろう。なぜなら、自作ツールの数が増えるに従って、よほど頻繁に使用するコマンドでもない限り、当該コマンドを開発した自分自身ですら使い方を忘れてしまうからだ。
./myfilter -h # => 'usage: myfilter [-h] [-o file] [-v] [file [file ...]]' ./myfilter -v # => 'myfilter 1.0.0'
オプション引数解析用の機能を用いる。
多くのモダンな言語では、オプション引数を解析する標準的な仕組みが用意されているものだ。そのような環境では、オプション引数の解析で困ることはない。
しかし、言語によっては、今でもオプション引数解析の標準的な仕組みが無くて困ることがある。
- Tcl:多分tcllibのcmdlineを使うことになる。準公式のライブラリだから、メンテナンスとか大丈夫だと思いたい。
- C/C++:Unix環境(MinGW含む)限定ならgetopt(3)を使えばよい。Windows向けないしクロスプラットフォームのツールは――まずC/C++以外の言語を検討すべきだと思う。
- Java、Kotlin:サードパーティのライブラリを使うことに抵抗があるなら、シェルスクリプトなどでラッピングすること。処理の中核はJava/Kotlinで実装するのだが、その際に固定の順番・個数の引数を取るようにしておく。その上で、オプション解析などの前処理をラッパスクリプト側で実施してから、Java/Kotlinで実装されたコア部分に固定の順番・個数で引数を渡せばよい(例としてXSLT-PFのJava実装を参照)。