ElixirでCLIを書く
[翻訳]Elixirでコマンドラインアプリケーションを書くで紹介されている通り、Erlang/Elixirにはescriptという便利なツールがあり、CLIを簡単に作れる。
今回自分でも作ってみたので、いくつか気づいたポイントを紹介。
基礎知識
-
mix.exsで以下のように定義しておく
def project do
[
...
escript: [
main_module: Your.CLI,
name: "yourcli",
path: Path.expand(Path.join(["~", ".mix", "escripts", "yourcli"])), # 後述
],
...
]
end
-
Your.CLI.main/1の中身を実装する。その名の通りCLIのメインエントリポイントで、コマンドライン引数が配列として渡る- 最初は
IO.puts("Hello world!")とかで
- 最初は
mix deps.getmix escript.build- (追記)Elixir 1.3以降では
:pathは不要。後段の追記参照
バイナリを置く場所
いきなりこの記事のポイントに入る。
mix escript.buildはデフォルト(前節で:pathキーを設定しない状態)だと、カレントディレクトリにyourcliという名前の実行可能バイナリをポンと設置してくれる。従って、
$ mix escript.build
(snip)
$ chmod +x yourcli
$ ./yourcli
Hello world!
こうなるわけだ。簡単でよろしい。パッと作れそうだ。
さて、近々来る予定のElixir 1.3では~/.mix/escriptsというパスがデフォルトのescriptバイナリ置き場として制定され、新設のmix escript.installコマンドがビルドしたバイナリはここに置かれる。1
つまり今から~/.mix/escriptsにPATHを通しておき、バイナリをそこに吐き出すようにしておけばスムースに移行できる。前節の設定はそういう意味。
ちなみにElixir 1.3ではmix escript.install <URL>一発でリモートのプロジェクトをフェッチしてインストールできる。「サッと共有」ができるというのはそういうわけ。2
ただし、escriptはコンパイル済みバイナリによる提供であればErlangさえあれば動くが、mixでインストールするにはElixirが必要になる。というかmixはElixirについてくる。
(追記)というわけで、Elixir 1.3が出てしばらく立ち、もう1.6になった今現在は、:pathは指定せずにおいて、mix escript.install <url>などとするのがよい。まだリリースはしておらず、ローカルディレクトリからインストールしたい場合もmix escript.installで~/.mix/escriptsに配置されるし、重複チェックやescript.uninstallなどもある。
また、1.3ローンチ直後はできなかったが、最近は選択肢が増えて、
mix escript.install hex <package>mix escript.install github <user>/<repo>
などとできるようになった。今はこちらをinstall instructionとすべき。
OptionParser超便利
冒頭でリンクした記事でも紹介されているOptionParserはElixirのビルトインモジュールで超便利である。
コマンドライン引数は配列としてmain/1に渡ってくるわけだが、OptionParserはその配列をよろしく解釈してくれる。
OptionParser.parse(~W(-v --hoge --foo Foo Bar Baz))
# {[hoge: true, foo: "Foo"], ["Bar", "Baz"], [{"-v", nil}]}
返り値は{opts, args, errors}で、--つきのオプションであれば自動でoptsとして解釈される。true, false以外の文字列や数値の値がなければ真偽値を、あればその値をオプションに紐付ける。
-つきのオプションは短縮形扱いなので、基本形が定義されていないとerrorsに入ってしまうが、その設定もaliasesを指定するだけでいい。
OptionParser.parse(~W(-v --hoge --foo Foo Bar Baz),
aliases: [v: :verbose])
# {[verbose: true, hoge: true, foo: "Foo"], ["Bar", "Baz"], []}
switchesやstrictといったその他のオプションもある。switchesはオプションの取る値のTypeを指定できて、
OptionParser.parse(~W(-v --hoge --foo Foo Bar Baz),
aliases: [v: :verbose],
switches: [foo: :integer])
# {[verbose: true, hoge: true], ["Bar", "Baz"], [{"--foo", "Foo"}]}
このようにvalidateできる。strictは読んで字のごとく定義したモノ以外受け付けなくする。
OptionParser.parse(~W(-v --hoge --foo Foo Bar Baz),
aliases: [v: :verbose],
strict: [verbose: :boolean])
# {[verbose: true], ["Foo", "Bar", "Baz"], [{"--hoge", nil}, {"--foo", nil}]}
システムコマンドの使用
System.cmd/3を使うと別のコマンドをElixirコードから呼び出して結果を取得できる。世の中にあるElixirモジュールだけではやりづらい処理を補うことができる。
ただし、System.cmd/3は単一のコマンドに引数を渡して実行させることしかできない。例えば以下のようなことをしたい場合に困る。
$ echo "some string" | pbcopy
Macだとよくやるクリップボードコピーだ。他にもjqにパイプしてJSONをPrettyPrintしたいなんてこともあるかもしれない。3
上記Docにも下の方にちょろっと書いてあるが、こういったパイプやリダイレクトを利用したい場合、Erlangの:os.cmd/1が使える。
contents = "some string"
:os.cmd('echo #{contents} | pbcopy')
渡すのはchar listになる点が要注意。Interpolationは使えるので、Elixirコード内で生成された文字列等をクリップボードに渡す処理を書くことができる。Windowsの場合もclipで同じことができる。
コマンドの終了ステータス
何かメッセージを出してコマンドを異常終了させたい場合があると思う。
単にraise(message)してもいいのだが、例外になってしまうので標準出力にスタックトレースが吐き出されてカッコ悪い。
エラーハンドリングした上で終了状態を明示して止めたいのであれば、こう書ける。
IO.puts(:stderr, message)
exit({:shutdown, 1})
Kernel.exit/1のdocの下の方、"CLI exits"の節に説明が書いてある。1を別のステータスコードにしてもいい。リンクされた全てのOTPプロセスには全てpolitely-shutdownするよう通知される。
ちなみにraise等の正常でないケースでは、OSプロセスとしては全てステータス1で終了するとのこと。
ghpr
他にもある気がするけど、とりあえず思いついたのはここまで。
以上の知見を活かしつつ、ghprというCLIを作ったので使ってみてください。Elixirで書いてるよという主張のためにレポジトリはymtszw/ex_ghprです
内容としてはGitHub Pull Requestをコマンド一発でオープンできるシロモノです。
github/hubもあるのですが、複数アカウントの取り扱いが面倒なのを解消しつつ、自分のチームのワークフローをCLIで補助しています。
オプションでいろいろとできるようになっている+今後も拡張する予定なので、よろしくお願いします。
(追記)筆者は今も変わらず日常的に使ってますが、現状機能に満足して2年位放置していたら、hubのほうがちょこちょこ機能追加してて、普通にhubで十分なケースが増えた気もする。。。