今回はthorというRubyでタスクランナーを作るためのライブラリの仕組みについて、ソースコードを読んで調べてみたので紹介します。
この記事で引用するthorのコードは全てv1.1.0
のものを参照しています。
thorとは
thorはrakeのようにrubyで何らかのタスクを行うコマンドを作ることができるライブラリで、Railsのgeneratorなどでも使われています。 基本的な使い方は以下のような感じです。
require "thor" class MyShell < Thor desc "echo", "Print input string." def echo(text = "") puts text end desc "ls", "List files" option :long, aliases: "-l", type: :boolean, desc: "List in long format" def ls puts options[:test] if options[:test] args = ["*"] args.push(File::FNM_DOTMATCH) if options[:long] puts Dir.glob(*args) end end MyShell.start(ARGV)
これで、lsコマンドやechoコマンドのようなコマンドが作ることができます。
$ ruby thor_sample.rb echo hogehoge hogehoge $ ruby thor_sample.rb ls thor_sample.rb $ ruby thor_sample.rb ls -l . .. thor_sample.rb .git # help コマンドは自動で実装され、desc で定義したテストが表示される $ ruby thor_sample.rb help Commands: thor_sample.rb echo # Print input string. thor_sample.rb help [COMMAND] # Describe available commands or one specific command thor_sample.rb ls # List files
基本的にはクラスの中でメソッドを定義すると、コマンド
として認識されて、その上にdesc
で説明を書いたり、options
で-l
や--force
のようなオプションをを定義できることになっています。
optionにはメソッド内でoptions[:オプション名]
でアクセスできます。
また、オプション以外に引数を設定でき、引数はecho
メソッドのtext引数のようにrubyメソッドの引数として実装します。とても簡潔でわかりやすいですね。
thorの仕組み
コマンドに説明やオプションを登録するDSLの仕組み
ところで、descメソッドやoptionsメソッドでは、対象のコマンド(メソッド)を引数で指定しているわけではありません。
単にdef 文の直前に書いているだけなのに、どうしてdescやoptionsはちゃんと意図したコマンドに適応されるのでしょうか。
desc "ls", "List files" option :long, aliases: "-l", type: :boolean, desc: "List in long format" def ls puts options[:test] if options[:test] args = ["*"] args.push(File::FNM_DOTMATCH) if options[:long] puts Dir.glob(*args) end
最初読んだ時は「ひょっとしてRubyにもPythonやJavaScriptのデコレータのような書き方があるのか...!?」 と思いましたがそういうことでは無いようです。
結論から言うと、インスタンス変数を使って、descやoptionで指定した値をメモし、対象のメソッドが登録されたらリセットする、と言う動作をしています。
まずdescメソッドの中身から詳しくみていきます。
# Defines the usage and the description of the next command. # # ==== Parameters # usage<String> # description<String> # options<String> # def desc(usage, description, options = {}) if options[:for] command = find_and_refresh_command(options[:for]) command.usage = usage if usage command.description = description if description else @usage = usage @desc = description @hide = options[:hide] || false end end
from: thor/thor.rb at b60e9eba629f2b0be4da9f2ab6208798f3945692 · erikhuda/thor · GitHub
for: "ls"
のようにコマンドが指定されていれば対応するコマンドを見つけてきて、usage
とdescription
を更新します。
for
でコマンドを指定しない場合(今回はこちら)、引数のusageやdescriptionをインスタンス変数(厳密にはThorを継承したクラスのクラスインスタンス変数)に保存しているだけですね。
次はoption
メソッドを見てみます。
# Adds an option to the set of method options. If :for is given as option, # it allows you to change the options from a previous defined command. # # def previous_command # # magic # end # # method_option :foo => :bar, :for => :previous_command # # def next_command # # magic # end # # ==== Parameters # name<Symbol>:: The name of the argument. # options<Hash>:: Described below. # # ==== Options # :desc - Description for the argument. # :required - If the argument is required or not. # :default - Default value for this argument. It cannot be required and have default values. # :aliases - Aliases for this option. # :type - The type of the argument, can be :string, :hash, :array, :numeric or :boolean. # :banner - String to show on usage notes. # :hide - If you want to hide this option from the help. # def method_option(name, options = {}) scope = if options[:for] find_and_refresh_command(options[:for]).options else method_options end build_option(name, options, scope) end alias_method :option, :method_option
thor/thor.rb at b60e9eba629f2b0be4da9f2ab6208798f3945692 · erikhuda/thor · GitHub
こちらもfor
が指定されていない方を読むと、method_options
をscope変数としてbuild_option
を呼んでいます。
build_option
はscope(どうやらHashらしい)にThor::Optionオブジェクトを生成して設定しているようです。
# Build an option and adds it to the given scope. # # ==== Parameters # name<Symbol>:: The name of the argument. # options<Hash>:: Described in both class_option and method_option. # scope<Hash>:: Options hash that is being built up def build_option(name, options, scope) #:nodoc: scope[name] = Thor::Option.new(name, {:check_default_type => check_default_type}.merge!(options)) end
from: thor/base.rb at b60e9eba629f2b0be4da9f2ab6208798f3945692 · erikhuda/thor · GitHub
改めて、今回のscopeの中身であるmethod_options
メソッドの返り値を確認します。
# Declares the options for the next command to be declared. # # ==== Parameters # Hash[Symbol => Object]:: The hash key is the name of the option and the value # is the type of the option. Can be :string, :array, :hash, :boolean, :numeric # or :required (string). If you give a value, the type of the value is used. # def method_options(options = nil) @method_options ||= {} build_options(options, @method_options) if options @method_options end
form: thor/thor.rb at b60e9eba629f2b0be4da9f2ab6208798f3945692 · erikhuda/thor · GitHub
これは若干複雑ですが、このメソッドには2つの使い方があり、1つは引数ナシでメソッド名と同名のインスタンス変数を返すrubyでよくある使い方。
もう1つは引数アリで、コマンドのオプションを一度に複数登録する使い方でした。
build_options
は名前の通りbuild_option
メソッドの複数バージョンです。
整理すると、オプションを定義するoption
メソッドでは、method_options
というオプションを集めたHashに指定された名前で新しいオプションに登録するメソッドということがわかりました。
ここまでで、desc, optionsメソッドを確認しましたが、それぞれインスタンス変数にオブジェクトを格納しているだけで、コマンドとの紐付けは行われていません。
実際にコマンドにオプションや説明が登録されるのは次のステップです。
鍵はメソッドが追加された時に呼び出されるメソッドmethod_added
でした。
method_added
はRubyに組み込まれているメソッドで、なんらかのメソッドが定義された時点でインタプリタがメソッド名(シンボル)を引数として呼び出します。
つまりmethod_added
の実装はクラスやメソッドの定義が読み込まれる途中で実行されることになります。
# Fire this callback whenever a method is added. Added methods are # tracked as commands by invoking the create_command method. def method_added(meth) super(meth) meth = meth.to_s if meth == "initialize" initialize_added return end # Return if it's not a public instance method return unless public_method_defined?(meth.to_sym) return if no_commands? || !create_command(meth) is_thor_reserved_word?(meth, :command) Thor::Base.register_klass_file(self) end
from: thor/base.rb at b60e9eba629f2b0be4da9f2ab6208798f3945692 · erikhuda/thor · GitHub
メソッドの中身は複雑ですが、要約すると、以下の条件に合致する場合に、create_command
というメソッドを実行しています。
- メソッド名が
"initialize"
でない - メソッドがパブリックメソッドである
no_command?
でない(こちらは後ほど説明します)
create_commandの中身を確認します。
def create_command(meth) #:nodoc: @usage ||= nil @desc ||= nil @long_desc ||= nil @hide ||= nil if @usage && @desc base_class = @hide ? Thor::HiddenCommand : Thor::Command commands[meth] = base_class.new(meth, @desc, @long_desc, @usage, method_options) @usage, @desc, @long_desc, @method_options, @hide = nil true elsif all_commands[meth] || meth == "method_missing" true else puts "[WARNING] Attempted to create command #{meth.inspect} without usage or description. " \ "Call desc if you want this method to be available as command or declare it inside a " \ "no_commands{} block. Invoked from #{caller[1].inspect}." false end end alias_method :create_task, :create_command
ここでようやく、これまで定義されていたインスタンス変数が使われているところが確認できました。
@usage
と@desc
が定義されていたら、それらの変数とmethod_options
を使ってコマンドオブジェクトを生成してます。
さらにcommandを作ったら、関係するインスタンス変数にnil
を代入してリセットしています。
if @usage && @desc base_class = @hide ? Thor::HiddenCommand : Thor::Command commands[meth] = base_class.new(meth, @desc, @long_desc, @usage, method_options) @usage, @desc, @long_desc, @method_options, @hide = nil true elsif
ということで、descやoptionsでコマンドを指定しなくても良い理由がわかりました。
コマンド定義前のクラスメソッドdescやoptionの値は一旦インスタンス変数にメモし、メソッドが定義されたら method_added
フックでメモしておいたインスタンス変数を使ってコマンドを作り、その後メモをリセットするという流れになっていました。
desc "ls", "List files" option :long, aliases: "-l", type: :boolean, desc: "List in long format" def ls # implement end
インスタンス変数をメモのように使うのはたまに見かけますが、今回のようにRubyのクラスコンテキストでメソッドが定義される前後でどんどんクラスのインスタンス変数に値が入ったりリセットされるのは珍しい気がしたので個人的には新鮮でした。
ところで、create_command
コマンドではパブリックメソッドとして定義したコマンドをthorのコマンドとして登録してしまいますが、attr_accessor
などで登録したメソッドはどうやって弾いているのでしょうか。
ここで登場するのが先ほど説明を飛ばしたno_commands?
です。
コマンドとして登録したくないパブリックメソッドはno_commands
ブロックで囲むようにします。
def attr_accessor(*) #:nodoc: no_commands { super } end
from: thor/base.rb at b60e9eba629f2b0be4da9f2ab6208798f3945692 · erikhuda/thor · GitHub
no_commands
メソッドの実装はNestedContext#enter
です。
def no_commands(&block) no_commands_context.enter(&block) end alias_method :no_tasks, :no_commands def no_commands_context @no_commands_context ||= NestedContext.new end def no_commands? no_commands_context.entered? end
from: thor/base.rb at b60e9eba629f2b0be4da9f2ab6208798f3945692 · erikhuda/thor · GitHub
NestedContextはかなりシンプルなオブジェクトで、enter
メソッドのブロックの内側に入ると@depth
が1つづつ増えていき、ブロックから抜けると減っていきます。
@depth
が1以上であればentered?
がtrueを返します。
class Thor class NestedContext def initialize @depth = 0 end def enter push yield ensure pop end def entered? @depth > 0 end private def push @depth += 1 end def pop @depth -= 1 end end end
from: thor/nested_context.rb at v1.1.0 · erikhuda/thor · GitHub
これでno_commands?
もブロックの内側ではtrueを返すので、もしmethod_added
がno_commands
ブロックの内側に定義されたメソッドに反応してしまってもthorのコマンドとして登録されないということになります。
以上、thorのDSL風の書き方の仕組みを紹介しました。
個人的にはmethod_added
を使うとこういう書き方ができるんだな〜というところが学びでした。
丸山 礼 (記事一覧)
サービス開発課でCloud Automatorを開発しています。