技術勉強ノート

日々の勉強をまとめます。Rails / JS / Go

【Rails】 FormHelperのソースコードを読んでみた

はじめに

以前、当ブログにてRailsのcheck_boxのソースコードを調べてみました。

存在するはずなのにドキュメントには載っていないmultipleオプションが気になったからでした。
peitetsu.hatenablog.com



↑の記事にて、1箇所だけ疑問が残り、あまり時間が無かったので後日調査することにしました。

check_boxメソッドの処理はたった1行
`@template.check_box(@object_name, method, objectify_options(options), checked_value, unchecked_value)`

↑のメソッドはFormBuilderクラスに定義されている。
@templateはview_contextで、つまるところFormHelperの同名メソッドを呼ぶことになっているらしい。 
参考:https://blog.freedom-man.com/rails-formhelper-codereading/

# TODO: この辺りの正確な理解ができなかったので、後日view_contextについて調べる。

 今日は、FormHelperのソースコードを読み、上記箇所の疑問を解消できたので、まとめます。

課題

FormBuilderクラスのメソッドでは、下記のように@templateの同名メソッドを呼んでいます。

def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
  @template.check_box(@object_name, method, objectify_options(options), checked_value, unchecked_value)
end

この@templateが最終的にFormHelperのインスタンスになっているらしい。
参考: Railsのform helper周りのコードリーディング

どのような流れでFormHelperに繋がっているのか、疑問を解消したい、というのが本記事の趣旨です。

処理の流れを追いかけて見る

フォームが作られる際の処理の流れを整理しながら、真相に迫って行きたいと思います。

まず、一般にRailsでフォームを実装する際はViewで form_with タグなどを呼ぶと思います。
(Rails5.1より前のバージョンだと、form_forやform_tagでした。)
form_withはFormHelperクラスに定義されています。

def form_with(model: nil, scope: nil, url: nil, format: nil, **options)
  options[:allow_method_names_outside_object] = true
  options[:skip_default_ids] = !form_with_generates_ids

  if model
    url ||= polymorphic_path(model, format: format)

    model   = model.last if model.is_a?(Array)
    scope ||= model_name_from_record_or_class(model).param_key
  end

  if block_given?
    builder = instantiate_builder(scope, model, options)
    output  = capture(builder, &Proc.new)
    options[:multipart] ||= builder.multipart?

    html_options = html_options_for_form_with(url, model, options)
    form_tag_with_body(html_options, output)
  else
    html_options = html_options_for_form_with(url, model, options)
    form_tag_html(html_options)
  end
end

https://github.com/rails/rails/blob/master/actionview/lib/action_view/helpers/form_helper.rb#L742-L764

`if block_given?` の分岐の中で、フォーム全体を作っているような処理が書かれています。
(form_withにブロックを渡さない使い方なんて有るの?)

instantiate_builder が怪しいですね。ビルダーをセットしていそうです。
定義を見てみましょう。

def instantiate_builder(record_name, record_object, options)
  case record_name
  when String, Symbol
    object = record_object
    object_name = record_name
  else
    object = record_name
    object_name = model_name_from_record_or_class(object).param_key if object
  end

  builder = options[:builder] || default_form_builder_class
  builder.new(object_name, object, self, options)
end

https://github.com/rails/rails/blob/master/actionview/lib/action_view/helpers/form_helper.rb#L1553-L1565

どうやら↓でビルダークラスをセットして初期化していますね。

builder = options[:builder] || default_form_builder_class
builder.new(object_name, object, self, options)

`options[:builder]`はアプリケーションのViewでform_withに渡された値ですね。

では、`default_form_builder_class`はどう定義されているのでしょうか?
上記、`instantiate_builder`メソッドの直後に有りました。

def default_form_builder_class
  builder = default_form_builder || ActionView::Base.default_form_builder
  builder.respond_to?(:constantize) ? builder.constantize : builder
end

`default_form_builder`か`ActionView::Base.default_form_builder`が、
FormBuilderクラスに繋がっていそうです。
これはゴール間近だと確信しました。
が、、、

再び混乱

`default_form_builder`の定義を探してみたが、それっぽいのが下記ぐらいしか見当たらない。

attr_internal :default_form_builder

https://github.com/rails/rails/blob/master/actionview/lib/action_view/helpers/form_helper.rb#L119

`attr_internal`は、内部変数として`@_defalut_form_builder`が定義される、というもの。
`@_default_form_builder`が使われている様子は無く、途方に暮れる。

困ったので試しにblameして見ると、それらしきコミットがヒットしました。
Override default form builder for a controller · rails/rails@2b8acdc · GitHub

コントローラ側で下記のように書いて、コントローラ毎にFormBuilderを設定できる、という仕様らしい。

class AdminController < ApplicationController
  default_form_builder AdminFormBuilder
end

どのような処理がされているのか一目では分からなかったが、今回の主題から逸れるため割愛。

ようやく答えに

さて、`default_form_builder`が無いときにbuilderにセットされる`ActionView::Base.default_form_builder`ですが、
これはform_helper.rbファイルの最後に書かれていました。

ActiveSupport.on_load(:action_view) do
  cattr_accessor :default_form_builder, instance_writer: false, instance_reader: false, default: ::ActionView::Helpers::FormBuilder
end

https://github.com/rails/rails/blob/master/actionview/lib/action_view/helpers/form_helper.rb#L2342-L2344

action_viewが読み込まれた際に、`ActionView::Base`のクラス変数`default_form_builder`に値をセットするというもの。
そしてそのデフォルト値が`::ActionView::Helpers::FormBuilder`となっています。

少し話を戻して、`instantiate_builder`メソッドでは下記の処理をしていました。

builder = options[:builder] || default_form_builder_class
builder.new(object_name, object, self, options)

つまり、builderには`::ActionView::Helpers::FormBuilder`がセットされ、次の行でインスタンス化されています。

本題

さて、本記事で知りたいのは、「FormBuilderクラスのメソッドで使われている@templateの中身は何になっているのか」
というものでした。

FormBulderクラスの初期化メソッドを見てみると、

def initialize(object_name, object, template, options)
  @nested_child_index = {}
  @object_name, @object, @template, @options = object_name, object, template, options
  @default_options = @options ? @options.slice(:index, :namespace, :skip_default_ids, :allow_method_names_outside_object) : {}
  @default_html_options = @default_options.except(:skip_default_ids, :allow_method_names_outside_object)

  ==省略==
end

https://github.com/rails/rails/blob/master/actionview/lib/action_view/helpers/form_helper.rb#L1660-L1678
第三引数が`@template`にセットされています。
`instantiate_builder`メソッドで第三引数として渡されているのは、`self`です。
ここでの`self`は、つまりFormHelperのインスタンスですね。

ということで、`@template`とは、FormHelperのインスタンスで有り、
FormBuilder内のほとんどのメソッドでは、FormHelperの同名メソッドをそのまま呼ぶだけ、という作りになっていました。

まとめ

フォームを作るときに使う`form_with`メソッドは中でビルダーをセットしている箇所が有ります。(`instantiate_builder`)
ビルダーのデフォルトには、`::ActionView::Helpers::FormBuilder`がセットされ、
その初期化時に、@templateにFormHelperのインスタンスがセットされています。
したがって、例えば`text_field`や`check_box`は最終的にFormHelperで定義されたものが使われるようになっています。