CLIサポートライブラリ Thor の知見メモ
はじめに
Rails とは無関係に Thor を使った CLI ツールを長い間触ってきて得た知識、特に注意点について書き残しておく。
--no-delete オプションを指定したら削除される
class App < Thor
desc "func1", "(desc)"
method_option :delete, type: :boolean
def func1
# --no-delete を指定したので false で来たところまでは合っている
options[:delete] # => false
# デフォルトは true だがマージするので false になるだろう
params = {
delete: true,
}.merge(options)
# ところが
params[:delete] # => true
# その結果、事故る
if params[:delete]
"削除しました" # => "削除しました"
end
end
end
App.start(["func1", "--no-delete"])
options は悪しき HashWithIndifferentAccess のインスタンスになっていて、文字列とシンボルを区別しないでよくなること(これがメリットだとは思わない)よりもマージした際にキーが一致せずに事故るデメリットの方がはるかに大きい。
この問題を回避するためには options.transform_keys(&:to_sym)
としてから merge せねばならない。もし ActiveSupport が入っている場合は options.to_options
でもよい。ただし options = options.to_options
と書くと nil.to_options
を呼ぶことになり、さらにはまる。この場合は options = self.options.to_options
と書かないといけない。
このように HashWithIndifferentAccess が絡むと急激に負債が増える。
自分で定義した say メソッドの挙動がおかしい
# 全体で使えるように say メソッドを定義したのだが……
module Kernel
def say(message)
puts message
system "say '#{message}'"
end
end
class App < Thor
desc "func1", "(desc)"
def func1
say "OK"
end
end
App.start(["func1"])
Mac では say コマンドが用意されているのでコマンドラインで say OK
とするだけで OK としゃべってくれる。これを利用しているのだが、OK と表示されるだけでしゃべらない。
原因は say を上書きされていたというオチだった。Thor はありがた迷惑な次のメソッドたちをぶちまける。
mute mute? padding= indent ask say say_error say_status yes? no? print_in_columns print_table print_wrapped file_collision terminal_width error set_color
これらと名前がかぶると負ける。これに気づかず、解決までに相当な時間を要した。
引数がエラーでもステイタス0を返してしまう問題
class App < Thor
def self.exit_on_failure?
true
end
end
と、書けばいいらしいが、なんでこれがデフォルトじゃないんだ! と、みんな青筋を立てている。
しかし最初の Issue が立ってから10年放置っていう。
オプションを繰り返し指定する
class App < Thor
desc "func1", "(desc)"
method_option :foo, type: :array, repeatable: true
method_option :bar, type: :hash, repeatable: true
def func1
# 配列の場合はペアになっている (平らにするには flatten が必要)
options[:foo] # => [["1", "2"], ["3", "4"]]
# ハッシュの場合は最初からフラットになっている
options[:bar] # => {"a"=>"1", "b"=>"2", "c"=>"3", "d"=>"4"}
end
end
App.start([
"func1",
"--foo", "1", "2",
"--foo", "3", "4",
"--bar", "a:1", "b:2",
"--bar", "c:3", "d:4",
])
同じオプションを指定した場合はエラーにはならず、後から指定した方が有効になる。もし、引数値を溜めたい場合は、repeatable: true
とする。
ショートカットを無闇に設定するな
class App < Thor
map "s" => :schedule
desc "schedule", "スケジュール"
def schedule
end
end
上のようにすると s
で schedule
が呼べるようになる。ただし、設定しなかった場合にのみ s
で始まるコマンドを列挙してくれる便利機能が働くので、無闇に設定しない方がよい。
コマンド実行前にクラスオプションを受け取りたい
class App < Thor
class_option :foo, type: :boolean
def initialize(...)
super
# すべてのタスクで共有する前処理
options[:foo] # => true
end
desc "func1", "(desc)"
def func1
end
end
App.start(["func1", "--foo"])
上の例であれば func1 に入った直後で options を参照すればよい。しかし、コマンドが100個がある場合、100箇所に同じコードを差し込まなければならない。
このあたり何か用意してくれててもおかしくないがいくら調べてもわからなかった。とりあえず initialize の中で参照する方法にして問題はなかった。
options 固有のメソッドを活用するな
コマンド内で使える悪しき HashWithIndifferentAccess 型の options が、
options[:foo] # => "bar"
となっているとき次のように書ける。
options.foo? # => true
options.foo # => "bar"
options.foo?("bar") # => true
しかし、この使ってしまうと Hash との整合性が取れなくなり、逆にリファクタリングがたいへん難しくなるので、一切活用してはいけない。
long_desc を使うと改行が削除される問題
コマンドの説明文を long_desc メソッドで登録すると、--help xxx
としたときにその説明文を最後に出力してくれる機能がある。
ただこれが余計なことをやりすぎで、改行コードを削除して詰めて画面幅で折り返すように整形される。また英語前提なので日本語なんか入れると折り返し位置が変になる。
この問題には次のように「何もしない」ようにすると使いやすくなる。
class Thor::Shell::Basic
def print_wrapped(message, options = {})
puts message
end
end
Thor のサブクラスに一切のロジックを書くべからず
次のように書いていたら破綻した。
class App < Thor
desc "foo_func", "(desc)"
def foo_func
foo_func_perform
end
desc "bar_func", "(desc)"
def bar_func
bar_func_perform
end
private
def foo_func_perform
# foo 専用の補助メソッド
end
def bar_func_perform
# bar 専用の補助メソッド
end
end
最初はシンプルでわかりやすかったが、そのまま膨らませていくと、プライベートメソッドが干渉し合ってメンテ不能に陥った。二つの機能 foo と bar は互いに何も関係がないにもかかわらず、同じスコープで共存しているのが失敗だった。
これは Rake タスクなどにも言えることで、エントリーポイントには完成されたメソッドを一行書くぐらいでないといけない。
可能だからといって同じスコープにプライベートメソッドをぶちまけてはいけない。そこからじわじわと崩壊していく。
便利メソッドが使えるのはサブクラスだけというジレンマ
Thor継承クラスが肥大化しがちな理由の1つとしてそこでしか便利メソッドが使えないからというのがある。
便利メソッドを活用していたコードを別のクラスに移動させるとたちまち undefine method
の嵐になってしまうのでコードを分離できないというわけである。
これは Thor が用意した便利メソッドに依存させないのがいちばん良い。ただもう依存してしまって引き剥がせない場合は、次のようにすれば一応どこからで呼ぶことができる。
Thor::Base.shell.new.say("message")
Discussion