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.get
mix 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
で十分なケースが増えた気もする。。。