Railsのテンプレートレンダリングを分解調査する#2 ActionView編(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Railsのテンプレートレンダリングを分解調査する#2 ActionView編(翻訳)

本記事は、Railsのテンプレートレンダリングを分解調査する#1探索編の続きです(半年も前の記事で失礼!)。今回は、RailsがRubyオブジェクトからテンプレートをレンダリングする過程を解説します。

今回チェックするファイル

  • actionview/lib/action_view/renderer/template_renderer.rb
  • actionview/lib/action_view/template.rb

ここまでのおさらい

前回の記事では、Railsがテンプレートファイルを読み取り、それを用いてテンプレートオブジェクトを初期化するところまでをご紹介しました。

# actionview/lib/action_view/template/resolver.rb

def query(path, details, formats, outside_app_allowed)
  query = build_query(path, details)

  template_paths = find_template_paths(query)
  ......
  template_paths.map do |template|
    ......    
    contents = File.binread(template)

    Template.new(contents, File.expand_path(template), handler,
      virtual_path: path.virtual,
      format: format,
      variant: variant,
      updated_at: mtime(template)
    )
  end
end

Railsはテンプレートオブジェクトをどう使っているか

ActionView::TemplateRendererがテンプレートを見つけると、#render_templateを呼び出し、テンプレートを引数として渡します。このときにlayoutlocalsも同時に渡され、オプションから展開される点にご注意ください。

# actionview/lib/action_view/renderer/template_renderer.rb

module ActionView
  class TemplateRenderer < AbstractRenderer #:nodoc:
    def render(context, options)
      ……
      # Found your template
      template = determine_template(options)
      ……
      render_template(template, options[:layout], options[:locals])
    end
  end
end

そして以下が#render_templateです。

def render_template(template, layout_name = nil, locals = nil)
  .....

  render_with_layout(layout_name, locals) do |layout|
    # instrumenting block
      template.render(view, locals) { |*name| view._layout_for(*name) }
    # end
  end
end

実際のレンダリングがrender_with_layoutブロックでラップされていることがわかりますね。今回はこの部分をスキップしますが、また別のテンプレートレンダリング記事でご説明したいと思います。

Template#render

Template#renderでは2つのステップを実行します。テンプレートのコンテンツをメソッド(もちろんRubyのメソッドです)にコンパイルする作業と、そのメソッドを呼び出す作業です。これだけシンプルなら十分理解できますね。

def render(view, locals, buffer = nil, &block)
  ......
  compile!(view)
  view.send(method_name, locals, buffer, &block)
end

テンプレートの「コンパイル」とは

ここで言うテンプレートのコンテンツのコンパイルとは、RailsがActionView::Base(より正確にはActionView::CompiledTemplates)にメソッドを1つ定義することを指します。そのメソッドはlocalsを受け取って文字列を1つ返します。ここで例を用いて解説します。localを1つ受け取るsay_hiというテンプレートをレンダリングしたいとしましょう。

# say_hi.erbHi <%= name %>

テンプレートがコンパイルされてメソッドになると、以下のようにRailsで使えるようになります。

def say_hi(local_assigns)
  name = local_assigns[:name]
  “Hi #{name}”
end

view.say_hi(name: "Stan")
#=> Hi Stan

ここの仕組みはどうなっているのでしょうか?Railsはこれを実現するために、次のようにRubyのメタプログラミングサポートをフル活用しています。

require "erb"
# ViewはActionView::Base的に振る舞うクラス
class View
end

class Template
  def initialize(name, content)
    @name = name
    @content = content
  end

  def render(view)
    compile(view)
    view.send(@name)
  end

  def compile(view)
    # コンパイルが必要なのは1度だけ
    return if @compiled
    # テンプレートエンジンとしてerbを使う
    body = ERB.new(@content).src
    src = <<-end_src
      def #{@name}
        #{body}
      end
    end_src

    view.singleton_class.module_eval(src)

      @compiled = true
  end
end

view = View.new
template = Template.new("say_hi", "Hi!")
template.render(view) #=> Hi!
view.methods.first #=> :say_hi

しかしlocalsはどうやって処理しているのでしょうか?Railsでテンプレートごとに定義されるメソッドが1つだけだとしたら、どうやってlocalsを動的に渡せばよいのでしょう?私が最も見事だと思った設計はこの部分です。上のコードを少し変えてみましょう。

require "erb"

class View
end

class Template
  def initialize(name, content, locals)
    @name = name
    @content = content
    @locals = locals
  end

  def render(view, local_assigns)
    compile(view)
    view.send(@name, local_assigns)
  end

  def compile(view)
    return if @compiled
    body = ERB.new(@content).src

    src = <<-end_src
      def #{@name}(local_assigns)
        #{locals_code}
        #{body}
      end
    end_src

    view.singleton_class.module_eval(src)

      @compiled = true
  end

  def locals_code
    @locals.map do |local|
      "#{local} = local_assigns[:#{local}]"
    end.join("\n")
  end
end

view = View.new
template = Template.new("say_hi", "Hi! <%= name %>", [:name])
template.render(view, name: "Stan") #=> Hi! Stan

先の例で用いたsay_hiメソッド全体は、以下のように定義されます。

def say_hi(local_assigns)
  # locals_codeで生成される
  name = local_assigns[:name]

# 以下はERBで生成されるが、わかりやすくするため展開してある
  _erbout = +’’
  _erbout.<< “Hi! “.freeze
  erbout.<<(( name ).tos)
  _erbout
end

巧妙な動作だと思いませんか?私は何年も前にこの実装を初めて読んだときについ惚れ惚れとしてしまいました。そしてこの実装は今も変わっていないのです!

もちろん、実際の#compileメソッドはさらに複雑なケースに対応するため、これよりずっと複雑になっています。このあたりにご関心がおありの方は、こちらをご覧ください。

なお、以上のメカニズムはRailsコンソールを使って直接確認することもできます。

# 注: 以下の`posts`や`posts/index`は実際のRailsアプリのものに変更すること
paths = ActionController::Base.view_paths
view = ActionView::Base.new(paths)

# この時点ではコンパイル済みメソッドがない
view.methods.grep(/posts/) #=> []

# このrenderはaction_view/helpers/rendering_helper.rbが由来
view.render(template: "posts/index")

# コンパイル完了!
view.methods.grep(/posts/) #=> [:_app_views_posts_index_html_erb___4416254959938662165_70267190542480]

まとめ

ActionViewは実によく設計されたライブラリであると私は思っています。ActionViewによるテンプレートの探索やレンダリングの手法はエレガントです。そしてActionViewの基本設計はこの10年でほとんど変更されておらず、それでいて深刻な問題を引き起こしていないのです!私はActionViewのコードベースから実に多くのことを学びました。皆さんもお手すきのときにActionViewのコードを読んでみてはいかがでしょう。その価値は十分あると思います😋。

関連記事

Railsのテンプレートレンダリングを分解調査する#1探索編(翻訳)

Railsのフラグメントキャッシュを分解調査する(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の監修および半分程度を翻訳、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れて更新翻訳中。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好きで、Goで書かれたRubyライクなGoby言語のメンテナーでもある。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ

BPSアドベントカレンダー