Generator の歩き方

趣旨

script/generate はいわずと知れた Rails のコードジェネレータである。

% cd $RAILSAPP
% ruby script/generate scaffold MyModel

などとして使う。このコードでは MyModel に関するコードの雛形が生成されるわけだ。
今日は、この script/generate がどのように動作するか、しつこく追いかけてみる。

(コードジェネレータの内部構造については Generatorプラグインの作り方 に簡潔に書かれている。とてもおすすめ。瀧口さん、いつもお世話になります(ペコリ))

コマンドが実行されるまで

まずは、$RAILSAPP/script/generate の中身をのぞいてみよう。*1

#!/usr/bin/env ruby
require File.dirname(__FILE__) + '/../config/boot'  # (A)
require 'commands/generate'                         # (B)
#($RAILSAPP/script/generate)

(A) は Rails フレームワークの読み込みである。ここでロードパスが設定される。そのため (B) では、$GEMSHOME/rails-1.2.3/lib/commands/generate が読み込まれる。*2
では次にその中身を見てみよう。

require "#{RAILS_ROOT}/config/environment"
require 'rails_generator'
require 'rails_generator/scripts/generate'

ARGV.shift if ['--help', '-h'].include?(ARGV[0])
Rails::Generator::Scripts::Generate.new.run(ARGV)
#($GEMSHOME/rails-1.2.3/lib/commands/generate.rb)

さて Rails::Generator::Scripts::Generate というクラスはどこで定義されているのか。これはクラス名からも察しが付くように、rails_generator/scripts/generate.rb にあった。

require File.dirname(__FILE__) + '/../scripts'

module Rails::Generator::Scripts
  class Generate < Base
    mandatory_options :command => :create # (X) => 後述
  end
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/scripts/generate.rb)

たったこれだけある。Rails::Generator::Scripts::Generate クラスの実体はスーパークラスRails::Generator::Scripts::Base にある。
Base#run を見てみようか。

def run(args = [], runtime_options = {})
  # (オプション解析)
  Rails::Generator::Base.instance(options[:generator], args, options).command(options[:command]).invoke!
  # (例外処理)
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/scripts.rb)

と大胆にはしょってしまおう。実質上の1行だけである。

Rails::Generator::Base.instance() は lookup.rb に定義がある。見ての通り、options[:generator] という名前のジェネレータのインスタンスを探している。そして、command() で options[:command] という名前のコマンドを見つけ出し、invoke! で実行。options[:command] はデフォルトで :create になっており(上記(X)参照) create.rb にある Rails::Generator::Commands::Create が生成される。つまりデフォルトだと何らかのコードが生成される。(って当たり前か)

何が実行されるのか?

では上で (Create クラスインスタンス).invoke! で何が実行されるのか? 答えはスーパークラスRails::Generator::Commands::Base#invoke! にある。

def invoke!
  manifest.replay(self)
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/commands.rb)

単純だ。manifest() して replay() しているだけだ。 この二つをそれぞれ見ていく。

まずは manifest()

manifest() の方だが、Rails::Generator::Commands::Base の delegate(たらいまわし先)である Rails::Generator::Base で定義されている。(Rails::Generator::Commands::Base の定義を見ると class Base < DelegateClass(Rails::Generator::Base) と書いてある。DelegateClass() については、文末の補足説明を参照のこと)

Rails::Generator::Base#manifest() の定義は空であり、サブクラスで定義をするようになっている。

  def manifest
    raise NotImplementedError, "No manifest for '#{spec.name}' generator."
  end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/base.rb)

実際には、Rails::Generator::Base から派生した Rails::Generator::NamedBase のサブクラスのなかで manifest() を定義するようだ。たとえば次のように。

class ControllerGenerator < Rails::Generator::NamedBase
  def manifest
    record do |m|
      # Check for class naming collisions.
      m.class_collisions class_path, "#{class_name}Controller", "#{class_name}ControllerTest", "#{class_name}Helper"

      # Controller, helper, views, and test directories.
      m.directory File.join('app/controllers', class_path)
      m.directory File.join('app/helpers', class_path)
      m.directory File.join('app/views', class_path, file_name)
      m.directory File.join('test/functional', class_path)

      # Controller class, functional test, and helper class.
      m.template 'controller.rb',
                  File.join('app/controllers',
                            class_path,
                            "#{file_name}_controller.rb")

      m.template 'functional_test.rb',
                  File.join('test/functional',
                            class_path,
                            "#{file_name}_controller_test.rb")

      m.template 'helper.rb',
                  File.join('app/helpers',
                            class_path,
                            "#{file_name}_helper.rb")

      # View template for each action.
      actions.each do |action|
        path = File.join('app/views', class_path, file_name, "#{action}.rhtml")
        m.template 'view.rhtml', path,
          :assigns => { :action => action, :path => path }
      end
    end
  end
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/controller/controller_generator.rb)

これは % script/generate controller MyController ... とかして使うコントローラのジェネレータ本体である。record() は Rails::Generator::Base で定義されている。

def record
  Rails::Generator::Manifest.new(self) { |m| yield m }
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/base.rb)

ここで真打 Rails::Generator::Manifest の登場となる。このクラスの定義で、関係する部分を掲載する。

class Manifest
  attr_reader :target

# Take a default action target.  Yield self if block given.
def initialize(target = nil)
  @target, @actions = target, []
  yield self if block_given?
end

# Record an action.
def method_missing(action, *args, &block)
  @actions << [action, args, block]
end

# Replay recorded actions.
def replay(target = nil)
  send_actions(target || @target, @actions)
end

#(中略)

private
  def send_actions(target, actions)
    actions.each do |method, args, block|
      target.send(method, *args, &block)
    end
  end
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/manifest.rb)

Rails::Generator::Manifest#initialize を見ればわかるが、要は、record() は Manifest インスタンスを生成して、ブロックに渡しているだけ。そのブロックの中で、Manifest インスタンス

m.directory File.join('app/controllers', class_path)
m.template 'controller.rb',
                  File.join('app/controllers',
                            class_path,
                            "#{file_name}_controller.rb")

等々実行させたいアクションを登録していくわけだ。アクションを登録するやり方が面白い。method_missing() を使って一行で処理している。Ruby の面目躍如だ。
ここまでで manifest() の解説終わり。(ながっ)

次に replay()

ここまでくれば簡単だ。上の Manifest#replay() をみて欲しい。 send_actions というワーカーメソッドを呼び出しているがこいつはあらかじめ登録されたアクションを順に実行していくだけである。target.send(method,...) としている。実際には target = Rails::Generator::Commands::Base のサブクラスのインスタンスなので、この target で定義された directory() やら template() やらを実行していくことになる。

directory() や template() は何を実行しているの?

Rails::Generator::Commands::Create を見てみよう。たとえば template の定義は次のとおりだ。

def template(relative_source, relative_destination, template_options = {})
  file(relative_source, relative_destination, template_options) do |file|
    # Evaluate any assignments in a temporary, throwaway binding.
    vars = template_options[:assigns] || {}
    b = binding
    vars.each { |k,v| eval "#{k} = vars[:#{k}] || vars['#{k}']", b }

    # Render the source file with the temporary binding.
    ERB.new(file.read, nil, '-').result(b)
  end
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/commands.rb)

基本的に file() というメソッドを呼び出している。おそらくはブロックを評価した結果を使ってファイルを生成するメソッドであろう。(確認したら実際そうだった。各自ソースコードを参照されたい)ここではブロックの中でテンプレートファイルを .rhtml ファイルのように埋め込まれた Ruby コードを評価した結果を返している。

directory() に関しても同様に実務的な処理を行っている。まさにここがジェネレータの核心であるといえる。

class_path て何よ?

上のコントローラ・ジェネレータのマニフェストにもあったけれど、class_path というのをよく見かける。これは Rails::Generator::NamedBase のアトリビュートとして定義されている。だが、いったい何を意味するのか?答えは、Rails::Generator::Base#extract_modules にあった。ここで引数 name には script/generate controller XXX の XXX の部分が入る。

def extract_modules(name)
  modules = name.include?('/') ? name.split('/') : name.split('::')
  name    = modules.pop
  path    = modules.map { |m| m.underscore }
  file_path = (path + [name.underscore]).join('/')
  nesting = modules.map { |m| m.camelize }.join('::')
  [name, path, file_path, nesting, modules.size]
end
#($GEMSHOME/rails-1.2.3/lib/rails_generator/base.rb)

呼び出し元では上のメソッドの中の path にあたる部分を Rails::Generator::NamedBase の class_path 属性に代入している。name = MyController::MySubController などと指定すると、path = ["my_controller"] となる。 (MyController/MySubController と "/" 区切りでもよい) name = A::B::C の場合は path = ["a", "b"] になる。結局、class_path は対象とするクラスにおけるモジュールの包含関係を表したものである、といえる。

結語

非常に長くなってしまった。ここまで読んでいるあなたは、かなり偉い。Rails の generator を書いたのは、Jeremy Kemper というお人らしいが、彼はかなりデザインパタンにはこだわりがあるようで、さまざまなパタンを駆使して美しいクラス構成を取っている。そのため汎用性・拡張性に優れている。反面、処理が方々のクラスに分散しているので、コードを追っかけるのが一苦労だった。基本的には非常に整然としたコードなので勉強のため読んでみるのもいいかもしれない。

補足1: DelegateClass() について

DelegateClass() というメソッドは次のようなクラスを自動的に作ってくれるものだと考えればいい。

class MyArray
  def initialize(delegate_array)
    @delegate_array = delegate_array
  end
  
  def push(x)
    @delegate_array.push(x)
  end
  
  def size
    @delegate_array.size
  end
  
  ....
end

ma = MyArray.new([])
ma.push(10)
ma.push(20)
ma.size # => 2

MyArray はコンストラクタで配列のインスタンスを取り、メソッド呼び出しを配列のインスタンスに転送するクラスである。普通に delegate をしようとすると、上のようにいちいち転送先のメソッドを転送元で定義してあげなければならないが、DelegateClass() を使うと次のように簡潔に書ける。

require 'delegate'
class MyArray < DelegateClass(Array)
end

補足2: デバック Tips

Generator 系のクラスでは、例外が途中で補足されてしまう。

raise my_interesting_variable

などとして状態を見ようとしても何も出力されない。

そういうときは、

% ruby script/generate foo_generator abc --backtrace

と --backtrace オプションをつけることで、バックトレースが表示されるようになる。

*1:$RAILSAPP は % rails myapp としたときに生成されるディレクトリへのパス。

*2:$GEMSHOME は gem でインストールされたアプリが格納されるディレクトリ。たとえば、/usr/local/lib/ruby/gems/1.8/gems/rails-1.2.3/lib など。