【Ruby】thorの仕組みを調べてみた DSL編

記事タイトルとURLをコピーする

今回は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"のようにコマンドが指定されていれば対応するコマンドを見つけてきて、usagedescriptionを更新します。
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_addedno_commandsブロックの内側に定義されたメソッドに反応してしまってもthorのコマンドとして登録されないということになります。

以上、thorのDSL風の書き方の仕組みを紹介しました。 個人的にはmethod_addedを使うとこういう書き方ができるんだな〜というところが学びでした。

丸山 礼 (記事一覧)

サービス開発課でCloud Automatorを開発しています。