generatorの仕組みを解読する

Rails3.0.0betaが出ていて仕事でFW作ることになったのでRailsのgeneratorの仕組みを参考にしたい。

環境

bash-3.2$ rvm use

Now using ruby 1.9.1 p378

bash-3.2$ rails -v
Rails 3.0.0.beta

流れを追うコマンド

bash-3.2$ rails pochi -d mysql
      create
      create  README
      create  .gitignore
      create  Rakefile
      create  config.ru
      create  Gemfile
      create  app
      create  app/controllers/application_controller.rb
      create  app/helpers/application_helper.rb
      create  app/models
      create  app/views/layouts
      create  config
      create  config/routes.rb
      create  config/application.rb
      create  config/environment.rb
        ...
      create  test/integration
      create  test/unit
      create  tmp
      create  tmp/sessions
      create  tmp/sockets
      create  tmp/cache
      create  tmp/pids
      create  vendor/plugins
      create  vendor/plugins/.gitkeep

railsコマンドの中身

railsのバージョンだけ指定して後はGem.bin_pathを呼んでいることが分かる。

require 'rubygems'

version = ">= 0"

if ARGV.first =~ /^_(.*)_$/ and Gem::Version.correct? $1 then
  version = $1
  ARGV.shift
end

gem 'rails', version
load Gem.bin_path('railties', 'rails', version)

Gem#bin_path

Gem.bin_path(name, exec_name, version)
今日はRailsのgeneratorに着目するのであまり気にしない。
gemから実行時のフルパスを取得する。

さっきのrailsのコマンドをたたいた場合、結局以下が呼ばれてることになる(はず)

load "#{railties_home}/bin/rails"

Railitiesのrailsコマンド

if File.exists?(Dir.getwd + '/script/rails')
  exec(Dir.getwd + '/script/rails', *ARGV)
else
  railties_path = File.expand_path('../../lib', __FILE__)
  $:.unshift(railties_path) if File.directory?(railties_path) && !$:.include?(railties_path)

  require 'rails/ruby_version_check'
  Signal.trap("INT") { puts; exit }

  require 'rails/commands/application'
end

簡単な流れは以下。

1. 実行ディレクトリ以下に"/script/rails"があればそれを実行
2. それがない場合(要はRailsアプリを新規作成する場合)
2-1. railtiesパスがLOAD_PATHになければ追加
2-2. rubyのバージョンをチェック
2-3. rails/commands/applicationを呼ぶ


ruby_version_check.rbは以下。(1.8.7以上を有効にしてる)

min_release  = "1.8.7"
ruby_release = "#{RUBY_VERSION} (#{RUBY_RELEASE_DATE})"
if ruby_release < min_release
  abort <<-end_message

    Rails requires Ruby version #{min_release} or later.
    You're running #{ruby_release}; please upgrade to continue.

  end_message
end

#{railties_path}/lib/rails/commands/application

require 'rails/version'
if %w(--version -v).include? ARGV.first
  puts "Rails #{Rails::VERSION::STRING}"
  exit(0)
end

ARGV << "--help"   if ARGV.empty?
require 'rubygems' if ARGV.include?("--dev")

require 'rails/generators'
require 'generators/rails/app/app_generator'

Rails::Generators::AppGenerator.start

以下のことしてます。

1. バージョンオプションはいってる場合はversion出しておしまい
2. 引数がなかった場合は"--help"オプションを付け足す
3. "--dev"があった場合はrubygems読み込み(ライブラリ単体で開発テストしたいときは必要なのかも)
4. 必要はライブラリ読み込んでRails::Gnerators::AppGeneratot.start

ここまでは、なんとなく予想範囲な実装コードだ。

Rails::Generators::AppGenerator.start

このstartメソッドはThorというgemから呼ばれている。
Thorはrakeとはまた違ったRubyのDSLらしい。

クラス階層はこんな感じ。

                    • -
Thor::Base
                    • -
Thor::Group
                    • -
Rails::Generators::Base
                    • -
Rails::Generators::AppGenerator
                    • -

ちょっとややこしいけどstartメソッドはThor::Baseメソッドが最初に呼ばれている。
中身は以下。

      def start(given_args=ARGV, config={})
        self.debugging = given_args.include?("--debug")
        config[:shell] ||= Thor::Base.shell.new
        yield(given_args.dup)
      rescue Thor::Error => e
        debugging ? (raise e) : config[:shell].error(e.message)
        exit(1) if exit_on_failure?
      end

コード読むと以下のようなことしてるはず。

1. debbugモードなのかどうかをオプションから判断
2. confi[:shell]にThor::Base.shellオブジェクトを入れる
3. yield(Thor::Group.startに処理を渡す)


じゃあ次に見るのはThor::Group.start

    def start(original_args=ARGV, config={})
      super do |given_args| 
        if Thor::HELP_MAPPINGS.include?(given_args.first)
          help(config[:shell])
          return
        end
       
        args, opts = Thor::Options.split(given_args)
        new(args, opts, config).invoke
      end
    end

やってるのは以下のこと。

1. Thor::HELP_MAPPINGS(%w(-h -? --help -D))が入っていればshelllからメッセージを出力
2. オプション解析
3. newしてinvokeを呼ぶ

2,3をちょっと詳しく見ていく。
2のオプション解析部分はthor/lib/thor/parser/arguments.rbにある。

    def self.split(args)
      arguments = []

      args.each do |item|
        break if item =~ /^-/
        arguments << item
      end

      return arguments, args[Range.new(arguments.size, -1)]
    end

最初のアプリケーション名をargumentsにいれてそれ以外はオプション配列にうめこんでいる。
次に3を見る。

    def initialize(args=[], options={}, config={})
      args = Thor::Arguments.parse(self.class.arguments, args)
      args.each { |key, value| send("#{key}=", value) }

      parse_options = self.class.class_options

      if options.is_a?(Array)
        task_options  = config.delete(:task_options) # hook for start
        parse_options = parse_options.merge(task_options) if task_options
        array_options, hash_options = options, {}
      else
        array_options, hash_options = [], options
      end

      opts = Thor::Options.new(parse_options, hash_options)
      self.options = opts.parse(array_options)
      opts.check_unknown! if self.class.check_unknown_options?
    end

僕のRuby力が足らなくてself.class.argumentsを読むのがやたら難しかったんだけど、
ここでいうself.classはRails::Generators:::Appgeneratorをさす。ただそこにargumentsメソッドはなく呼ばれるのはThor::Base.argumentsを呼んでいる。

      def arguments
        @arguments ||= from_superclass(:arguments, [])
      end

        def from_superclass(method, default=nil)
          if self == baseclass || !superclass.respond_to?(method, true)
            default
          else
            value = superclass.send(method)
            value.dup if value
          end
        end

from_superclassはsuperclassにメソッド呼び出しできるか聞きにいき、いた場合はそれを呼んだ結果を、それ以外は引数(true/false)を返すようにしている。
で結局self.class.argumentsを呼ぶとArray[Thor::Argument]みたいなのが帰ってくる。
[#]

でそれをもとにThor::Arguments.parse(self.class.arguments, args)を呼ぶ。

    def initialize(arguments=[])
      @assigns, @non_assigned_required = {}, []
      @switches = arguments 
        
      arguments.each do |argument|
        if argument.default
          @assigns[argument.human_name] = argument.default
        elsif argument.required?
          @non_assigned_required << argument
        end
      end
    end

    def parse(args)
      @pile = args.dup

      @switches.each do |argument|
        break unless peek
        @non_assigned_required.delete(argument)
        @assigns[argument.human_name] = send(:"parse_#{argument.type}", argument.human_name)
      end

      check_requirement!
      @assigns
    end

ここでオプションを返してくれる。({app_name => "hoge"}みたいな)
もいっかい3のnew部分をまとめておく。

1. アプリケーション名をセットする
2. その他Railsオプションを解析する
3. Thor::Optionsに2のものを詰めてparse


んー、めちゃくちゃややこしいですね。
Thorは後でしっかり読む必要があるかも。
newまでで何ができるかというと、例えば以下のコマンドをたたきます。

rails pochi -d mysql -q

そうするとnewした際は以下のオブジェクトができています。

##}], @app_path="pochi", @options={"ruby"=>"/Users/kuro/.rvm/rubies/ruby-1.9.1-p378/bin/ruby", "database"=>"mysql", "quiet"=>true}>

それではinvokeを呼んだみたいと思います。
invokeはThor::Baseのクラスメソッドであるthor/lib/thor/invocation.rbにあるものが呼ばれる。

    def invoke(name=nil, *args)
      args.unshift(nil) if Array === args.first || NilClass === args.first
      task, args, opts, config = args

      object, task    = _prepare_for_invocation(name, task)
      klass, instance = _initialize_klass_with_initializer(object, args, opts, config)

      method_args = []
      current = @_invocations[klass]

      iterator = proc do |_, task|
        unless current.include?(task.name)
          current << task.name
          task.run(instance, method_args)
        end
      end

      if task
        args ||= []
        method_args = args[Range.new(klass.arguments.size, -1)] || []
        iterator.call(nil, task)
      else
        klass.all_tasks.map(&iterator)
      end
    end
1. 事前処理
2. プロック処理を用意
3. Rails generatorオブジェクトが持つタスクを全てiteratorに処理させる 

この3のとこで実際にRailsアプリケーションのひな形ができます。
(Thor::Taskオブジェクトがいっぱいつまってる)


思ったより長かった。Thorをしっかり読まないとよくわかんないことがわかった。