kei-p3’s blog

kei-pによる技術共有と思考整理

Rakeを徹底解剖 - その3 "タスクの定義"

前回はタスクの読み込みについて調べたが、今回は読み込んだrakefileをどう解釈し、タスクとして定義しているのかについて調べていく。

前回よりタスクの読み込みは、Rake.load_rakefile によって行われていることがわかりました。

## rake/rake_module.rb

def load_rakefile(path)
  load(path)
end

ここで、読み込む際にタスクとして定義する dsl は全てRake::DSLに定義されたものになっています。

今回はその中でも、頻繁に使う task の宣言のみに焦点を当てて読み解いていきます。

なお、まだその1, その2を読んでない方はこちらから。

kei-p3.hatenablog.com

kei-p3.hatenablog.com

タスクの定義

タスクの定義は以下のようにDSLとして用意されています。

## rake/dsl_definition.rb

def task(*args, &block) # :doc:
  Rake::Task.define_task(*args, &block)
end
## rake/task.rb

def define_task(*args, &block)
  Rake.application.define_task(self, *args, &block)
end

Rake::Task.define_taskをコールしてはいるが実態は、Rake.application.define_taskのようだ。

## rake/task_manager.rb

def define_task(task_class, *args, &block) # :nodoc:
  task_name, arg_names, deps = resolve_args(args)

  original_scope = @scope
  if String === task_name and
     not task_class.ancestors.include? Rake::FileTask then
    task_name, *definition_scope = *(task_name.split(":").reverse)
    @scope = Scope.make(*(definition_scope + @scope.to_a))
  end

  task_name = task_class.scope_name(@scope, task_name)
  deps = [deps] unless deps.respond_to?(:to_ary)
  deps = deps.map { |d| Rake.from_pathname(d).to_s }
  task = intern(task_class, task_name)
  task.set_arg_names(arg_names) unless arg_names.empty?
  if Rake::TaskManager.record_task_metadata
    add_location(task)
    task.add_description(get_description(task))
  end
  task.enhance(deps, &block)
ensure
  @scope = original_scope
end

まず、一行目を見てみる。

task_name, arg_names, deps = resolve_args(args)

これはどうやら、taskに与えられた引数を処理しているらしい。 rakeのタスクは、たくさんの定義の仕方が存在していて、以下のような書き方がある。

task task_name
task task_name: dependencies
task task_name, arguments => dependencies

これらをargsとして取得して、それぞれ task_name, arg_names, deps としてパースするのがここの役目。

与えられる引数の構造が結構違って大変だが、それを全てこのresolve_argsで対応してます。

ちなみに処理の内容は以下の感じ。

## rake/task_manager.rb

def resolve_args(args)
  if args.last.is_a?(Hash)
    deps = args.pop
    resolve_args_with_dependencies(args, deps)
  else
    resolve_args_without_dependencies(args)
  end
end

def resolve_args_without_dependencies(args)
  task_name = args.shift
  if args.size == 1 && args.first.respond_to?(:to_ary)
    arg_names = args.first.to_ary
  else
    arg_names = args
  end
  [task_name, arg_names, []]
end

def resolve_args_with_dependencies(args, hash) # :nodoc:
  fail "Task Argument Error" if hash.size != 1
  key, value = hash.map { |k, v| [k, v] }.first
  if args.empty?
    task_name = key
    arg_names = []
    deps = value || []
  else
    task_name = args.shift
    arg_names = key
    deps = value
  end
  deps = [deps] unless deps.respond_to?(:to_ary)
  [task_name, arg_names, deps]
end

わぉ、かなりの力技。。。
これ、抜け漏れないか確認するのかなり大変だな。。。

さて、次はこちら。

original_scope = @scope
if String === task_name and
   not task_class.ancestors.include? Rake::FileTask then
  task_name, *definition_scope = *(task_name.split(":").reverse)
  @scope = Scope.make(*(definition_scope + @scope.to_a))
end

これは、とりあえず今回は、Rake::Taskを中心にみたいので、task_class.ancestors.include? Rake::FileTaskについては割愛。

if文の中の処理としては、task 'namespace_name:task_name'という形式で書かれたタスクを、以下のように書いたもののと同様に扱うためのものと見られる。

namespace namespace_name do
    task :task_name do
    end
end

@scopeについては、詳しくは調べてはいないが、おそらくこのタスクを定義する際のネストを意味しているのであろう。

それでは、あとは残りの部分を読み解いていく。

# task名をscopeを考慮した形へ変換
task_name = task_class.scope_name(@scope, task_name)

# depsの整形
deps = [deps] unless deps.respond_to?(:to_ary)
deps = deps.map { |d| Rake.from_pathname(d).to_s } # Pathname を string へ変換してるらしい

# タスククラスの登録および取得
task = intern(task_class, task_name)

# タスクへ各種変数を代入
task.set_arg_names(arg_names) unless arg_names.empty?
if Rake::TaskManager.record_task_metadata # "rake -T"のようにタスク情報を表示する必要があるときのみ
  add_location(task)
  task.add_description(get_description(task))
end
task.enhance(deps, &block)

基本的にはタスクインスタンスを生成し、各種タスクの仕様を設定している感じのようだ。

まとめ

今回は、dslによるタスクの読み込みについて調べた。
見てみた感想としては、taskの定義自体に焦点をあてればそこまで難しくはなさそうであった。

気になっていた豊富なタスクの指定方法とその、解釈の仕方だがどうやら、かなり力技で対応しているようだった。

次回は、ついにタスクの実行時の動作について。dependenciesargsの処理など、正直ここがキモだ! じっくり、解剖していこう。

Rakeを徹底解剖 - その2 "タスクの読みこみ"

前回に引き続き、rakeの仕組みを知るべく、ソースリーディングをして処理を解読していく。

前回では、initの中身を調べて行ったが今回は、タスクの読み込みおよびタスクの定義に焦点を当てて、読み込んでいく。

## rake/application.rb

def run
  standard_exception_handling do
    init               # 初期化
    load_rakefile      # taskの読み込み <= ココ
    top_level          # コマンドの実行
  end
end

まだ、その1を読んでいない方はこちらから。

kei-p3.hatenablog.com

タスクの読み込み

まずは、load_rakefileを見てみる。

## rake/application.rb

def load_rakefile
  standard_exception_handling do
    raw_load_rakefile
  end
end

前回もでてきたExceptionをcatchするブロックが登場。
読み込み処理の肝心な部分は、raw_load_rakefileのようだ。

## rake/application.rb

def raw_load_rakefile # :nodoc:
  rakefile, location = find_rakefile_location
  if (! options.ignore_system) &&
      (options.load_system || rakefile.nil?) &&
      system_dir && File.directory?(system_dir)
    print_rakefile_directory(location)
    glob("#{system_dir}/*.rake") do |name|
      add_import name
    end
  else
    fail "No Rakefile found (looking for: #{@rakefiles.join(', ')})" if
      rakefile.nil?
    @rakefile = rakefile
    Dir.chdir(location)
    print_rakefile_directory(location)
    Rake.load_rakefile(File.expand_path(@rakefile)) if
      @rakefile && @rakefile != ''
    options.rakelib.each do |rlib|
      glob("#{rlib}/*.rake") do |name|
        add_import name
      end
    end
  end
  load_imports
end

まずは、この中のfind_rakefile_locationから。

def find_rakefile_location # :nodoc:
  here = Dir.pwd
  until (fn = have_rakefile)
    Dir.chdir("..")
    return nil if Dir.pwd == here || options.nosearch
    here = Dir.pwd
  end
  [fn, here]
ensure
  Dir.chdir(Rake.original_dir)
end

この中では、Rakefileを探してる模様。
実行したときのカレントディレクトリから始まり、見つかるまでは親のディレクトリをひたすら登っていくようになっている。
そのため、rakeはそのプロジェクト内のディレクトリであれば、どこからでも呼べるようになっているようだ。

さて、再びraw_load_rakefileへ戻る。

if (! options.ignore_system) &&
    (options.load_system || rakefile.nil?) &&
    system_dir && File.directory?(system_dir)
  print_rakefile_directory(location)
  glob("#{system_dir}/*.rake") do |name|
    add_import name
  end
else

options.ignore_systemに関しては、--no-systemを設定したときの動作である。今回は通常のrakeの挙動を追っていきたいの、ここはパスする。
よって、elseの部分だけを切り出して深掘っていく。

rakefile, location = find_rakefile_location

fail "No Rakefile found (looking for: #{@rakefiles.join(', ')})" if rakefile.nil?
@rakefile = rakefile

# カレントディレクトリの移動
Dir.chdir(location)

# 現在のディレクトリの位置を表示 (Rakefileが実行時のカレントディクトリにない場合のみ)
print_rakefile_directory(location)

# Rakefileをロード
Rake.load_rakefile(File.expand_path(@rakefile)) if @rakefile && @rakefile != ''

# rakelibに指定されたディレクトリがrake読み込み対象になる
# のちの load_imports で 読み込まれる
options.rakelib.each do |rlib|
  glob("#{rlib}/*.rake") do |name|
    add_import name
  end
end

load_imports

ここで気になったのは、load_rakefile
ただ、中を探してみるととくに特別なことはしておらず、ただ単にloadを呼び出している。

## rake/rake_module.rb

def load_rakefile(path)
  load(path)
end

タスクの追加はRake::Application自身が行っていると思ったが、そうではないようだ。
このときにRakefileを読み込んでいるのは、mainである。

気になっていたDSLの解釈のmoduleであるRake::DSLをなぜmainextendしているのだろうというところだが、
その理由はloadによってタスクの読み込みを行っているためであることが判明した。

さて、読み込みとしては最後となるload_importsrakelibディレクトリや、引数として指定されたディレクトリのrakeタスクを読み込むようだ。

ここで使っているlookupだが、まだ詳しく処理の内容はわからない。 返り値としては、nilになるため、ここの処理の内容が詳しくはわからない。

どうやら、すでにタスクが定義されているとタスクが返却されるようだ。
そしてそれを実行しているようだ。

現時点でこの役割はわからないが、あまり一般的な利用法でもないと思うので、とりあえずここも深堀しないことにする。(のちにわかるかもしれない)

def load_imports # :nodoc:
  while fn = @pending_imports.shift
    next if @imported.member?(fn)
    fn_task = lookup(fn) and fn_task.invoke
    ext = File.extname(fn)
    loader = @loaders[ext] || @default_loader
    loader.load(fn)
    if fn_task = lookup(fn) and fn_task.needed?
      fn_task.reenable
      fn_task.invoke
      loader.load(fn)
    end
    @imported << fn
  end
end

まとめ

今回は、タスクの読み込みについて調べてみた。
前回の初期化に比べると、なかなか複雑な感じがでてきた。 細かなオプションによって細かな挙動が変わるようで、いままで知らない使い方が発見できそうだ。

Rakeを徹底解剖 - その1 "Rakeの実行から初期化まで"

はじめに

先日とあるハッカソンに参加した際に、rake の task を拡張したメソッドを追加する必要がありました。
単なるメソッド追加だろうと思っていたら、それが思いの外大変。

なかなかうまくいかず、どうやったら拡張できるのかを探るために rake の仕組みを調べてみることに。
ただ、この仕組みがなかなか複雑ですぐに理解できるものでもなく、その時は設計やコードの綺麗さを捨て、動くものを作る感じで乗り切りました。

後日、その時書いたコードに納得がいかず、"もっといい書き方はないのか?"と悶々と気になりだし、「そもそも rake がどういう仕組みを調べてみよう」と思い、ソースリーディングをしてみました。

内容

内容としては、

  • rakeの仕組み
    • タスクの読み込み
    • タスクの定義
    • タスクの実行
  • 読んでいて気になった ruby テクニック
  • 余裕があったら、簡易版 rake の作成 -> 車輪の再発明 

あたりを取り上げていくつもりです。

なお、まとめ方としては、全体的な内容をまとめてからちゃんと構成立てるのがベストだと思う。
がしかし、個人的にモチベーション続かなそうなので、その日調べて気づいたことをその時の分だけまとめていく感じで書いていくつもりです。
そのため、後々響いてくるような複雑な箇所は若干読み飛ばし気味ながらまとめていくつもりです。

Rake実行および読み込み

最初はまず、bin/rakeなりbundle execなどで実行した時に下記のようなコードが読まれる。

## ext/rake
require 'rake'

Rake.application.run

ここで注意するのが、require 'rake'内でのrequire 'rake/dsl_definition'という箇所。 このrequire 'rake/dsl_definition'では、module Rake::DSLを定義するついでに、なんと

self.extend Rake::DSL

と、selfを拡張していること。

ここでの、self = mainを拡張してる。
正直かなり危なっかしいやり方ではあるが、なにか理由があってこの方法を取っているのであろう。
そしてまさに、これこそが今回のrakeの仕組みを調べようと思ったきっかけである。

Rakeの初期化

実行は、以下のように呼び出されている。

Rake.application.run

ここで、Rake.applicationは、Rake::Applicationインスタンス生成(シングルトン)をしている。

## rake/rake_module.rb

def application
  @application ||= Rake::Application.new
end

Rake::Application#initializeでは、読み込むファイルの拡張子やファイル名を定義。 あとは、読み込む際のクラスを指定してるっぽい。

## rake/application.rb

def initialize
  super
  @name = 'rake'
  @rakefiles = DEFAULT_RAKEFILES.dup
  @rakefile = nil
  @pending_imports = []
  @imported = []
  @loaders = {}
  @default_loader = Rake::DefaultLoader.new
  @original_dir = Dir.pwd
  @top_level_tasks = []
  add_loader('rb', DefaultLoader.new)
  add_loader('rf', DefaultLoader.new)
  add_loader('rake', DefaultLoader.new)
  @tty_output = STDOUT.tty?
  @terminal_columns = ENV['RAKE_COLUMNS'].to_i
end

そして、Rake::Application#runが呼ばれる。

## rake/application.rb

def run
  standard_exception_handling do
    init                    # 初期化
    load_rakefile               # taskの読み込み
    top_level               # コマンドの実行
  end
end

なお、standard_exception_handlingに関しては、ブロック内で発生したExceptioncatchしてる模様。

今回は、初期化にフォーカスを当てるため、initの中を読んでいく。

## rake/application.rb

def init(app_name='rake')
  standard_exception_handling do
    @name = app_name
    args = handle_options
    collect_command_line_tasks(args)
  end
end

ここのhandle_optionsでは、rakeで実行するタスクだけを抽出している模様。

オプションの一覧は、standard_rake_optionsに記述されている。

## rake/application.rb

def standard_rake_options # :nodoc:
  sort_options(
    [
      ['--all', '-A',
        "Show all tasks, even uncommented ones (in combination with -T or -D)",
        lambda { |value|
          options.show_all_tasks = value
        }
     ],
     ...
    ]
  )
end

オプションに指定されているものは、optionsに追加されていき、漏れたものが配列として返却されているようだ。

そして、argsは、collect_command_line_tasks(args)へと渡されて残りはのちに実行するタスクとして、登録されていく。

## rake/application.rb

def collect_command_line_tasks(args) # :nodoc:
  @top_level_tasks = []
  args.each do |arg|
    if arg =~ /^(\w+)=(.*)$/m
      ENV[$1] = $2
    else
      @top_level_tasks << arg unless arg =~ /^-/
    end
  end
  @top_level_tasks.push(default_task_name) if @top_level_tasks.empty?
end

なお、初めて知ったのだが基本的に環境変数は、引数と混ざらないようにコマンドの前に書くようにしていた。 $ HOGE=1 bin/rake task # こんな感じ

だが、どうやら rake は後ろに書く書き方でも、対応できるようだ。

if arg =~ /^(\w+)=(.*)$/m
  ENV[$1] = $2
else
  @top_level_tasks << arg unless arg =~ /^-/
end

とはいっても、コマンドによって対応していたりしていなかったりすることを考慮すると、やはり前に書くように気をつけておく方がいいと思う。

以上。ここまでがinitまでの処理の流れだ。

まとめ

今回はとりあえず、"rake実行から初期化まで"を徹底解剖してみました。
まだまだ、Rakeのコアの部分には触れてはいないものの、メソッドのまとめ方や細かなテクニックなどなかなか気になる箇所は多かったです。

次回は、load_rakefileを深ほって、Rakeのタスク読み取りの箇所を読み取っていきます!

おまけ

セーフティーなメソッド追加

なかったらメソッド追加? セーフティーなメソッド追加。

## rake/ext/core.rb

class Module
  def rake_extension(method) # :nodoc:
    if method_defined?(method)
      $stderr.puts "WARNING: Possible conflict with Rake extension: " +
        "#{self}##{method} already exists"
    else
      yield
    end
  end
end

使う時はこんな感じ。

## rake/ext/pathname.rb

class Pathname
  rake_extension("ext") do
    def ext(newext='')
      Pathname.new(Rake.from_pathname(self).ext(newext))
    end
  end
end

エラークラスの定義

なかなかユニークな書き方を発見。

## rake/application.rb

CommandLineOptionError = Class.new(StandardError)

同じワンラインで書くとするなら、自分ならこう書いてました。

class CommandLineOptionError < StandardError; end

クラスすらもインスタンス(クラスインスタンス)。まさにそれを体現した書き方。
「あ、それもありなのねw」っていう衝撃。