Rakeを徹底解剖 - その3 "タスクの定義"
前回はタスクの読み込みについて調べたが、今回は読み込んだrakefileをどう解釈し、タスクとして定義しているのかについて調べていく。
前回よりタスクの読み込みは、Rake.load_rakefile
によって行われていることがわかりました。
## rake/rake_module.rb def load_rakefile(path) load(path) end
ここで、読み込む際にタスクとして定義する dsl は全てRake::DSL
に定義されたものになっています。
今回はその中でも、頻繁に使う task
の宣言のみに焦点を当てて読み解いていきます。
なお、まだその1, その2を読んでない方はこちらから。
タスクの定義
タスクの定義は以下のように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
の定義自体に焦点をあてればそこまで難しくはなさそうであった。
気になっていた豊富なタスクの指定方法とその、解釈の仕方だがどうやら、かなり力技で対応しているようだった。
次回は、ついにタスクの実行時の動作について。dependencies
やargs
の処理など、正直ここがキモだ!
じっくり、解剖していこう。
Rakeを徹底解剖 - その2 "タスクの読みこみ"
前回に引き続き、rakeの仕組みを知るべく、ソースリーディングをして処理を解読していく。
前回では、init
の中身を調べて行ったが今回は、タスクの読み込みおよびタスクの定義に焦点を当てて、読み込んでいく。
## rake/application.rb def run standard_exception_handling do init # 初期化 load_rakefile # taskの読み込み <= ココ top_level # コマンドの実行 end end
まだ、その1を読んでいない方はこちらから。
タスクの読み込み
まずは、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
をなぜmain
にextend
しているのだろうというところだが、
その理由はload
によってタスクの読み込みを行っているためであることが判明した。
さて、読み込みとしては最後となるload_imports
。
rakelib
ディレクトリや、引数として指定されたディレクトリの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実行および読み込み
最初はまず、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
に関しては、ブロック内で発生したException
をcatch
してる模様。
今回は、初期化にフォーカスを当てるため、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