はじめに
この記事は Perl Advent Calendar 2021 の 1日目の記事です。
この記事では、モジュールに CLI コマンドとしての機能を兼ねさせるための コードの書き方について、いくつかの設計パターンを取り上げ、 それぞれの長所短所を私 hkoba の個人的な経験から論じます。 言語としては主に Perl を取り上げますが、他の言語にも適用可能な部分はあるはずです。
モジュール兼コマンド(Modulino - モジュリーノ)とは?
この記事で論じる「モジュール兼コマンド」とは、
-
他のプログラムから使われる モジュールとして書かれたコード であり、
-
同時に、 単体で CLI コマンドとしても実行可能 なもの
のことです。Perl の界隈では、このようなプログラムに Modulino(モジュリーノ)という呼び名が提唱されています[brian d foy(2004)]。 他の言語における呼び方はStackOverflow で質問した範囲では判明しませんでした。 この記事では以後 Modulino と表記します。
Perl で書かれた Modulino の例(※) Hello.pm
#!/usr/bin/env perl
package Hello;
sub run {
print "Hello world!\n";
}
run() unless caller;
1;
※ 私の Modulino の紹介では、モジュールに #!
と実行ビットを付与することを前提としています。brian d foy や Gabor Szabo の記事では、そうでないものが多いようです。Windows への配慮なのかもしれませんが、Unix 屋にとっては不便だと考えています。
Modulino を理解するための、2つの視点
モジュール兼コマンドとなるプログラムを考えた時、それを2つの立場・視点から 利点を捉えることが出来ます。
-
CLI コマンドが、モジュールを兼ねている
% myscript.pl 引数...
↓
% MyScript.pm 引数...
-
モジュールが、CLI コマンドを兼ねている
use MyCMS; my $cms = MyCMS->new(MyCMS->parse_options(\@ARGV)); print $cms->list_blogs(@ARGV);
↓
% ./MyCMS.pm --dbname=var/cms.db list_blogs
順番に解説します。
CLI コマンドが、モジュールを兼ねるメリット
CLI コマンドをモジュールにするメリットは、そのコマンド内部の関数を 別の文脈・プログラムから実行できる(可能性が生まれる)ことです。
例えば他人の書いたスクリプト foo.pl
があって、
その中の関数 bar
だけを試しに実行してみたい、
と思ったことは無いですか?
# foo.pl の中身
while (<>) { ...; bar($., $_); ... }
sub bar {
my ($x, $y) = @_;
...
}
...
foo.pl
を他のプログラムからそのままロードした場合、
その中のトップレベルのコード(while (<>) など
)も実行されてしまいます。
なので、どうしても bar
だけを実行したいなら、bar に関係する部分だけを
手で抜き出す必要があるでしょう。これは大抵、困難な作業です。
それに対して、このプログラムが最初からモジュール Foo.pm
として
書かれていたらどうでしょう?
# Foo.pm の中身
sub run {
while (<>) { ...; bar($., $_); ... }
}
sub bar {
my ($x, $y) = @_;
...
}
run() unless caller;
1
この場合、モジュール Foo をロードして、 正しい引数を渡すだけで bar を 実行できる可能性が出てきます(もちろん、実際にはグローバル変数に依存する可能性もありますが)。
副次的に、bar だけを perlデバッガから呼び出したり、profile することも容易になります。
モジュールが、CLI コマンドを兼ねるメリット
モジュールを CLI コマンドとしても振る舞えるように記述するメリットは、 モジュールの機能をコマンド行から簡易的に試す手段を提供出来ることです。
例えばモジュール Foo.pm のクラスメソッド bar を試しに呼ぶためには、 コマンド行でこのように書く必要があるでしょう。
% perl -I. -MFoo -e 'print Foo->bar'
# ダンプもしたいなら
% perl -I. -MFoo -MData::Dumper -e 'print Dumper(Foo->bar)'
もし、これが↓このように呼べたら、便利だと思いませんか?
% ./Foo.pm bar
次節では、モジュールを CLI から起動した時の 挙動の選択肢と、その中で私がお勧めしたい案について論じます。
モジュールの CLI として振舞いの選択肢
モジュールをコマンドとして起動した時に、 何が実行されるようにするべきかは、様々な考え方があるはずです。 この節では私が思いつく以下の選択肢について、その長所短所を考えてみます。
-
UnitTest
-
そのモジュールの最も重要な関数
-
任意の関数への Dispatcher
-
Object の new と任意のメソッドの Dispatcher
UnitTest の実行
モジュールを起動した時には、そのモジュールのテストを実行する、 という方法です。これは Self testing Perl modules using the Modulino concept やModulino::Testでも紹介されています。
例えば、初期のプロトタイピングの段階で、 テストのための環境とファイルを整える時間的・心理的余裕がない、 というケースを仮に考えましょう。その場合、モジュールファイルの中に テストも書いてしまえるなら、その分、テストを書き始める時期を前倒しに 出来る、というメリットが有るでしょう。
以下は Self testing Perl modules using the Modulino concept の例の抜粋です。 モジュールをコマンドとして実行したときには self_test() が呼ばれます。
sub add { ... }
sub self_test {
require Test::More;
import Test::More;
plan(tests => 4);
is(add(2, 5), 7);
is(add(-1, 1), 0);
is(add(23, 1), 0);
is(add(0, 10), 10);
}
self_test() unless caller();
この方法の短所
-
開発が進みテストが巨大化したら、テストを別ファイルに移動する必要が有る
-
テストしか呼べない。他の機能を呼ぶには、コーディングが必要
が挙げられます。
主要な機能の実行
モジュールの特定の関数を起動する、 という方法もありえます。これはモジュールの役割が一機能に限定されている時には 自然な選択でしょう。
例えばモジュール MyFOOParser.pm
が何らかのデータファイル形式 (仮に FOO 形式として)
のparser であるなら、ファイルを引数として起動した時に parse 結果を
何らかの形式(例えば JSON)で出力してくれると嬉しいでしょう。
% ./MyFOOParser.pm aaa.foo bbb.foo ...
{"name": "aaa", "value": 3}
{"name": "bar", "value": 8}
...
この方法の短所
UnitTest の場合と同じく
- 他の関数を試したくなった時に、(ただ呼び出すだけでは済まず) glue のコーディングが必要になる。
という短所があります。
任意の関数のDispatch
モジュールを CLI から起動した時に、渡された第一引数をサブコマンド として解釈する考えです。
package Foo;
sub hello { print "hello!\n" }
sub goodbye {print "goodbye!\n" }
unless (caller) {
my $funcName = shift
or die "function name is required!";
my $func = __PACKAGE__->can($funcName)
or die "No such function: $funcName";
print Dumper($func->(@ARGV));
};1
この方式なら、任意の関数をコマンド行から呼び出すことが可能になります。
% ./Foo.pm hello
hello!
% ./Foo.pm goodbye
goodbye!
-
関数に引数として HASH/ARRAY を渡したいケースもありえます。その場合は コマンド行引数に
{...}
や[...]
が渡されたときだけ、 JSON として decode する、というアプローチを取ることが可能でしょう。 TypeScript などではこの方法が上手くフィットしそうだと私は考えています。- その場合、 出力も JSON として encode しておけば、コマンド行を glue として関数同士を組み合わせることも可能になるでしょう。
-
この unless caller ブロック内のロジックを予め別のモジュールとして 準備しておけば、個々のモジュールにおける Modulino 化のコーディング負担は 極めて少なくなります。
-
開発の最初期から、コマンド行から繰り返し関数を実データと共に呼び出すことが可能になります。その結果、関数名の良し悪しについて考え直す機会が増えます。
-
このようにコマンド行から呼びやすく設計した関数は、テストも容易になる傾向が あります。
この方法の短所
この方法では、
- CLI から OOP のオブジェクトを一切扱えない(オブジェクトを扱う関数は、呼び出してもエラーになる)
- サブコマンドを覚えるのが面倒、という考え方も有るかもしれません。
ただ、これは shell の側で補完機能を用意出来れば、問題ではなくなるでしょう。
という弱点があります。これは現代の Perl プログラムにとっては かなり痛い制限です。
ObjectのnewとメソッドのDispatch
モジュールがコマンド行から起動された時に、 そのモジュールの定義するクラスのオブジェクトを new し、 更に第一引数に基づいてメソッドの呼び出しを行う、という方法です。
(私はこのパターンを、 OO Modulino と呼んでいます)
以下の例ではサブコマンド名 $cmd
に対して cmd_$cmd
と $cmd
の二種類の
メソッド呼び出しの規約を用意しています。こうすることにより、
標準入出力や終了コードを操作したいケースも対応できます。
unless (caller) {
my $obj = __PACKAGE__->new(__PACKAGE__->parse_options(\@ARGV));
my $cmd = shift || 'help';
if (my $sub = $obj->can("cmd_$cmd")) {
# cmd_FOO があるので、後は任せる
$sub->($obj, @ARGV);
} elsif ($sub = $obj->can($cmd)) {
# 一般の任意のメソッドを試すケース
my @res = $sub->($obj, @ARGV);
# 呼び出し側で、デフォルトのシリアライザを通して出力
print Data::Dumper->new([@res])->Terse(1)->Indent(0)->Dump, "\n";
} else {
$obj->cmd_help("No such command: $cmd");
}
}
この方法のメリットは
-
(
$self
とそこから初期化される変数に限れば)オブジェクトを扱える -
前節同様、メソッドの引数に HASH/ARRAY を渡したい場合は JSON を使う方法が使える
- parse_options でも JSON の decode を行えば、HASH/ARRAY を new に渡せる
-
出力も JSON に揃えると、組み合わせやすさが更に向上する
-
Modulino 化のためのモジュールを作っておくことも可能。 CLI から Object の API へのマッピングが統一できるので、使えば使うほど馴染む。 私の MOP4Import::Base::CLI_JSON も参考にどうぞ
-
サブコマンドと new のオプションを補完できると、更に使用感が改善する
この方法の短所
- 既存のモジュールの、引数に Object を期待するメソッドは、正しく呼び出せない
つまり、最初から SCALAR か生の HASH/ARRAY しか受け取らないように 設計されたメソッドしか、コマンド行からは試せない事を意味します。
しかし逆にこの短所は、特に何でも Web API との親和性が求められがちな 現代のソフトウェア環境では、下手に class の付いた Object を 引数・戻り値として受け渡しするより、生 HASH/ARRAY をやり取りしたほうが 安全確実であるという考え方もありそうです。 ですので、これはひどい弱点にはならないかもしれない…私はそう考えています。
また、生HASH を扱う際の構造の保証には、fields 宣言による静的検査を使うことも 出来るでしょう。
まとめ
この記事では モジュールに CLI コマンドとしての機能を兼ねさせる、 Modulino という考え方について紹介しました。 Modulino の幾つかの実装パターンとその長所短所を挙げました。 その中では(特に OOP を中心にした言語では) OO Modulino パターンが最も実用的であると、私は考えています。
ここまで読んで下さり、ありがとうございました。
例:Hello world
Hello world を題材に、モジュール兼コマンドの記述例を挙げます。
Perl
#!/usr/bin/env perl
package Hello;
sub run {
print "Hello world!\n";
}
run() unless caller;
1;
Python
#!/usr/bin/env python
def run():
print('Hello world!')
if __name__ == "__main__":
run()
Ruby
#!/usr/bin/env ruby
def run()
puts 'Hello world!'
end
run() if __FILE__ == $0
TypeScript
#!/usr/bin/env ts-node
export function run() {
console.log('Hello world!')
}
if (module.id === ".") {
run()
}