scaffold_generator の歩き方

趣旨

scaffold_generator は Rails を代表するコードジェネレータである。scaffold(足場)という名前の通り、モデル名を指定することにより、そのモデルに対する CRUD 操作を行うコントローラやビューを吐き出してくれる。 Rails 使いならば、足を向けては寝ることができないというありがたい代物である。昨日に引き続き、scaffold_generator がどう動くのか、しつこく追っかけてみる。

ジェネレータ本体 scaffold_generator.rb

ジェネレータ本体 ScaffoldGenerator は、$GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb にある。
その核心にあたるのは、例によって manifest() というメソッドである。これを全文掲載する。

  def manifest
    record do |m|
      # Check for class naming collisions.
      m.class_collisions controller_class_path, 
      	"#{controller_class_name}Controller", 
      	"#{controller_class_name}ControllerTest", 
      	"#{controller_class_name}Helper" # <= 長いので改行した。

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

      # Depend on model generator but skip if the model exists.
      m.dependency 'model', [singular_name], :collision => :skip, :skip_migration => true

      # Scaffolded forms.
      m.complex_template "form.rhtml",
        File.join('app/views',
                  controller_class_path,
                  controller_file_name,
                  "_form.rhtml"),
        :insert => 'form_scaffolding.rhtml',
        :sandbox => lambda { create_sandbox },
        :begin_mark => 'form',
        :end_mark => 'eoform',
        :mark_id => singular_name


      # Scaffolded views.
      scaffold_views.each do |action|
        m.template "view_#{action}.rhtml",
                   File.join('app/views',
                             controller_class_path,
                             controller_file_name,
                             "#{action}.rhtml"),
                   :assigns => { :action => action }
      end

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

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

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

      # Layout and stylesheet.
      m.template 'layout.rhtml',
                  File.join('app/views/layouts',
                            controller_class_path,
                            "#{controller_file_name}.rhtml")

      m.template 'style.css',     'public/stylesheets/scaffold.css'


      # Unscaffolded views.
      unscaffolded_actions.each do |action|
        path = File.join('app/views',
                          controller_class_path,
                          controller_file_name,
                          "#{action}.rhtml")
        m.template "controller:view.rhtml", path,
                   :assigns => { :action => action, :path => path}
      end
    end
  end
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

たったこれだけのコードであの scaffolding が行われているのかと思うと感慨深い。構造を鳥瞰してみると、

  def manifest
    record do |m|
      # (A) クラス名の衝突をチェック

      # (B) コントローラ・ヘルパー・ビュー・テストの各ディレクトリを作成(もしなければ)
      
      # (C) モデルがまだ生成されてなければ生成
 
      # (D) 入力フォームのビュー(_form.rhtml)を生成
      
      # (E) その他の scaffold のビューを生成
      
      # (F) コントローラ・機能テスト・ヘルパの各ファイルを生成

      # (G) レイアウトファイルとスタイルシートを生成
      
      # (H) もし通常 scaffold される以外のアクションが特に指定されていたら、それに対応するビューを生成
    end
  end

ほとんどコメントを和訳しただけである。ソースコードとの対応はすぐ付くだろう。この中でちょっと面倒なのは (D) の入力フォームを作るところだけだ。この話は後回しにして、簡単なところから説明すると、

(A) クラス名の衝突をチェック
# Check for class naming collisions.
m.class_collisions controller_class_path, 
  "#{controller_class_name}Controller", 
  "#{controller_class_name}ControllerTest", 
  "#{controller_class_name}Helper"
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

m は Rails::Generator::Manifest のインスタンスになっている。*1 class_collisions() は、引数に指定されたクラス名がすでに RubyRails によって使われているかチェックする。もしかぶってしまうと、例外を送出してコード生成を中断する。ちなみに class_collision() の実装は、Manifest ではなく Rails::Generator::Commands::Create にある。Manifest はアクションを登録するための単なる入れ物であり、実際の動作は他のクラスに行わせている。これは、以下の directory() や template() でも同様である。

controller_class_path は、ScaffoldGenerator#initialize で初期化されている。Generator でクラスパスと言ったら、A::B::C の A::B の部分を指す。つまり外側のモジュールの入れ子関係のことである。controller_name が A::B::C だとすると、実際には controller_class_path は配列で ["a", "b"] となる。ScaffoldGenerator#initialize ではこの手のインスタンス属性をいろいろ初期化しているので各自ソースコードを確認してほしい。

(B) コントローラ・ヘルパー・ビュー・テストの各ディレクトリを作成(もしなければ)
# Controller, helper, views, and test directories.
m.directory File.join('app/controllers', controller_class_path)
m.directory File.join('app/helpers', controller_class_path)
m.directory File.join('app/views', controller_class_path, controller_file_name)
m.directory File.join('app/views/layouts', controller_class_path)
m.directory File.join('test/functional', controller_class_path)
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

これは簡単だ。Rails::Generator::Commands::Create#directory() は引数で指定されるパスが存在するか確認して、なければディレクトリを作っている。

(C) モデルがまだ生成されてなければ生成
m.dependency 'model', [singular_name], :collision => :skip, :skip_migration => true
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

Rails::Generator::Commands::Create#dependency は別のジェネレータを呼び出すメソッドである。ここでは、モデルのジェネレータを呼び出している。singular_name は Rails::Generator::NamedBase#assign_names!() で初期化されるインスタンス属性で、モデルの単数形になっている。

(E) その他の scaffold のビューを生成
scaffold_views.each do |action|
  m.template "view_#{action}.rhtml",
	   File.join('app/views',
		     controller_class_path,
		     controller_file_name,
		     "#{action}.rhtml"),
	   :assigns => { :action => action }
end
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

ScaffoldGenerator#scaffold_views は実際には決めうちで %w(list show new edit) となっている。Rails::Generator::Commands::Create#template() は第一引数にテンプレートファイル、第二引数に生成するファイルのパスを指定し、ファイルを生成する。

残り

(F) コントローラ・機能テスト・ヘルパの各ファイルを生成
(G) レイアウトファイルとスタイルシートを生成
(H) もし通常 scaffold される以外のアクションが特に指定されていたら、それに対応するビューを生成

これらは自明だろう。特に説明しない。

(D) 入力フォームのビュー(_form.rhtml)を生成

複雑なのは、これである。もう一度ソースコードを載せる。

m.complex_template "form.rhtml",
  File.join('app/views',
	  controller_class_path,
	  controller_file_name,
	  "_form.rhtml"),
  :insert => 'form_scaffolding.rhtml',
  :sandbox => lambda { create_sandbox },
  :begin_mark => 'form',
  :end_mark => 'eoform',
  :mark_id => singular_name
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

Rails::Generator::Commands::Create#complex_template() を詳しく追っかけてみる。準備として上の引数を検討してみよう。

:sandbox => lambda { create_sandbox }

:sandbox => lambda { create_sandbox } の lambda の内部では create_sandbox() を呼び出している。

def create_sandbox
  sandbox = ScaffoldingSandbox.new
  sandbox.singular_name = singular_name
  begin
    sandbox.model_instance = model_instance
    sandbox.instance_variable_set("@#{singular_name}", sandbox.model_instance)
  rescue ActiveRecord::StatementInvalid => e
    logger.error "Before updating scaffolding from new DB schema, try creating a table for your model (#{class_name})"
    raise SystemExit
  end
  sandbox.suffix = suffix
  sandbox
end
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

ScaffoldigSandbox というクラスのインスタンスを生成し、singular_name, model_instance, suffix といった属性を設定している。model_instance() は実際にモデルのインスタンスを生成する。たとえば % script/generate scaffold User というコマンドを実行しているとするならば、User.new を返す。*2

ScaffoldingSandbox とは何か?

class ScaffoldingSandbox
  include ActionView::Helpers::ActiveRecordHelper

  attr_accessor :form_action, :singular_name, :suffix, :model_instance

  def sandbox_binding
    binding
  end
  
  def default_input_block
    Proc.new { |record, column| "<p><label for=\"#{record}_#{column.name}\">#{column.human_name}</label><br/>\n#{input(record, column.name)}</p>\n" }
  end
  
end
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

sandbox_binding() はこのクラスのインスタンスの実行環境を返している。binding() はコードを動的に評価するときにしばしば使用される。
default_input_block() は、ActiveRecordHelper の同名のメソッドの上書きである。

:insert => 'form_scaffolding.rhtml'

form_scaffolding.rhtml の中身を見てみよう。

<%= all_input_tags(@model_instance, @singular_name, {}) %>
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/templates/form_scaffolding.rhtml)

たった一行である。では all_input_tags() はどこで定義されているのか?これは、ActionView::Helpers::ActiveRecordHelper に定義がある。

def all_input_tags(record, record_name, options)
  input_block = options[:input_block] || default_input_block   # (A)
  record.class.content_columns.collect{ |column| input_block.call(record_name, column) }.join("\n") # (B)
end
# ($GEMSHOME/actionpack-1.13.3/lib/action_view/helpers/active_record_helper.rb)

先走って言うと、form_scaffolding.rhtml の all_input_tags() は :sandbox => lambda { create_sandbox } で作られた ScaffoldSandbox インスタンスの環境で実行される。したがって、(A) の default_input_block は ScaffoldSandBox#default_input_block() を呼び出す。そして (B) では、モデルクラスの通常のカラム(id とか特殊なカラムを除いたもの)に対して (A) で入手した Proc オブジェクトを実行して、各カラムに対応する HTML を生成することになる。

もういちどまとめると、

m.complex_template "form.rhtml",
  File.join('app/views',
	  controller_class_path,
	  controller_file_name,
	  "_form.rhtml"),
  :insert => 'form_scaffolding.rhtml',
  :sandbox => lambda { create_sandbox },
  :begin_mark => 'form',
  :end_mark => 'eoform',
  :mark_id => singular_name
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/generators/components/scaffold/scaffold_generator.rb)

という complex_template() の呼び出しにおいて

:insert => 'form_scaffolding.rhtml' (部分テンプレート)
:sandbox => ScaffoldSandbox のインスタンス

が引数として指定される。:begin_mark, :end_mark, :mark_id については本筋ではないので省略する。

Rails::Generator::Commands::Create#complex()

ようやく準備ができた。Rails::Generator::Commands::Create#complex() の中身を見てみる。

def complex_template(relative_source, relative_destination, template_options = {})
  options = template_options.dup
  options[:assigns] ||= {}
  options[:assigns]['template_for_inclusion'] = render_template_part(template_options) # (A)
  template(relative_source, relative_destination, options)                             # (B)
end
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/commands.rb)

どうやら、まず render_template_part() でテンプレートからコードの一部を生成し、さらにそれを別のテンプレートに流し込むという二段階のステップを踏んでいるようだ。
(B) に関しては通常のテンプレート展開なので、特に説明する必要はない。
重要なのは (A) のほうだ。 render_template_part() について考える。

def render_template_part(template_options)
  # Getting Sandbox to evaluate part template in it
  part_binding = template_options[:sandbox].call.sandbox_binding # (A)
  part_rel_path = template_options[:insert] #(B)
  part_path = source_path(part_rel_path)    #(C)

  # Render inner template within Sandbox binding
  rendered_part = ERB.new(File.readlines(part_path).join, nil, '-').result(part_binding) #(D)
  begin_mark = template_part_mark(template_options[:begin_mark], template_options[:mark_id])
  end_mark = template_part_mark(template_options[:end_mark], template_options[:mark_id])
  begin_mark + rendered_part + end_mark
end
# ($GEMSHOME/rails-1.2.3/lib/rails_generator/commands.rb)

(A) において、ScaffoldSandbox インスタンス内部の実行環境を得ている。
(B)(C) では、form_scaffolding.rhtml へのパスを得ている。
そして (D) で form_scaffolding.rhtml を ScaffoldSandbox の環境で評価して、部分コードを生成している。

まとめると complex_template() は第3引数の template_options において、

template_options[:insert] 部分的に適用されるテンプレートファイル名
template_options[:sandbox] 部分テンプレートを展開する実行環境

を設定して呼び出す。第1引数・第2引数は、template() と同じ。第1引数で指定されるメインなテンプレートが展開されるときには、部分テンプレートの展開結果は template_for_inclusion というローカル変数に格納されている。

オレオレ scaffold_generator への道

complex_template() の説明はわかっていただけただろうか。これだけ知識があれば、自分だけのオレオレ scaffold_generator を作ることはそれほど難しくないはずだ。scaffold するときに、モデルからカラム情報を取得することは必須である。そのためには complex_template() をきちん理解して、Sandbox#default_input_block() を正しく上書きすることが必要になってくるだろう。

今日の話がみなさまのお役に立てたなら、うれしいのだけど

*1:$GEMSHOME/rails-1.2.3/lib/rails_generator/manifest.rb

*2:model_instance() の実装はなんだか複雑怪奇に見える。これは、モデルがまだ存在しなくてもそれらしき ActiveRecord::Base のサブクラスを生成するためらしい。しかし良い子のみなさんは、scaffold するまえにモデルは作っていると思うのであまり気にしなくても良いだろう。