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

概要

原著者の許諾を得て翻訳・公開いたします(パブリックドメイン)。

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

今回は「Rails分解調査シリーズ」第2弾です。前回の記事「Railsのフラグメントキャッシュを分解調査する」をお読み頂いてない方でも今回の記事を読むのに差し支えはありませんが、それでもご一読をおすすめいたします。

Railsのテンプレートレンダリングの背後では実に多くの処理が行われているので、2回に分けて詳しくご紹介いたします。その第1回目として、表示したいテンプレートをRailsのrenderメソッドがどのように探索しているかを説明いたします。続く第2回では、テンプレートオブジェクトがレスポンスに使えるHTMLに変換される過程を説明します。それでは始めましょう!

注目すべきファイル

ソースコードを自分でも見てみたい方向けに(私からも推奨します)、今回のテーマで注目すべきファイルをリストアップします。

訳注: Rails 5.2-stableのソースにリンクしました。

ユーザーインターフェイス

Railsはさまざまな方法でテンプレートをレンダリングします。その1つが#renderメソッドを手動で実行することです。そこでまず以下から始めることにします。

render template: "comments/index", formats: :json

薄々お気づきのように、このrenderメソッドはActionView::Helpers::RenderingHelperというヘルパーにあります。

# actionview/lib/action_view/helpers/rendering_helper.rb
def render(options = {}, locals = {}, &block)
  case options
  when Hash
    if block_given?
      view_renderer.render_partial(self, options.merge(partial: options[:layout]), &block)
    else
      view_renderer.render(self, options)
    end
  else
    view_renderer.render_partial(self, partial: options, locals: locals, &block)
  end
end

この#renderメソッドを見てみると、テンプレートのレンダリングを担当するview_rendererがあることがおよそ見て取れます。このview_rendererは実際にはActionView::Rendererオブジェクトなので、そちらの#renderメソッドを見てみましょう。

# actionview/lib/action_view/renderer/renderer.rb
module ActionView
  class Renderer
    def render(context, options)
      if options.key?(:partial)
        render_partial(context, options)
      else
        render_template(context, options)
      end
    end

    def render_template(context, options) #:nodoc:
      TemplateRenderer.new(@lookup_context).render(context, options)
    end

    def render_partial(context, options, &block)
      PartialRenderer.new(@lookup_context).render(context, options, block)
    end
  end
end

こちらを見ると、2つの異なるクラスがそれぞれtemplateのレンダリングとpartialのレンダリングを担当していることがわかります。partialのレンダリングはもう少し複雑なので、今回はtemplateのレンダリングのみをご紹介します。

ここで注目すべきは、Renderer@lookup_contextインスタンス変数です。探索するテンプレートに関する必要な情報はすべてここにあります。

#<ActionView::LookupContext:0x00007fa8d5c7f670
 @cache=true,
 @details=
  {:locale=>[:en],
   :formats=>
    [:html,
     :text,
     :js,
     :css,
       ......
     ],
   :variants=>[],
   :handlers=>[:raw, :erb, :html, :builder, :ruby]},
 @details_key=nil,
 @prefixes=[],
 @rendered_format=nil,
 @view_paths=
  #<ActionView::PathSet:0x00007fa8d5c7eba8
   @paths=
    [#<ActionView::FileSystemResolver:0x00007fa8d5c9c7c0
      @cache=#<ActionView::Resolver::Cache:0x7fa8d5c9c680 keys=0 queries=0>,
      @path="/Users/st0012/projects/rails/actionview/test/fixtures",
      @pattern=":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}">]>>

ActionView::LookupContextについて

私見では、このActionView::LookupContextこそがテンプレートのレンダリングにおける最も重要度の高いコンポーネントです。このコンポーネントの属性、特に@details@view_pathsを見てみましょう。

@detailsはハッシュで、localeformatsvariantshandlersが含まれています。@detailsの情報の使いみちは次の2とおりです。

1. 見つかったテンプレートのキャッシュに使われるキャッシュキーの一部となる(コード

# actionview/lib/action_view/template/resolver.rb
def find_all(name, prefix = nil, partial = false, details = {}, key = nil, locals = [])
  cached(key, [name, prefix, partial], details, locals) do
    find_templates(name, prefix, partial, details)
  end
end

2. Railsはこの情報を元にテンプレートのファイル拡張子をフィルタする(コード

# actionview/lib/action_view/template/resolver.rb
module ActionView
  class PathResolver < Resolver #:nodoc:
    EXTENSIONS = { locale: ".", formats: ".", variants: "+", handlers: "." }
    DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}"
  .....
  end
end

次の@view_pathsは、ActionView::PathSetのインスタンスです。PathSetはテンプレートの探索対象となるパスのセットで、各パスはResolverというオブジェクトで次のようにガードされます。

#<ActionView::FileSystemResolver:0x00007fa8d5c9c7c0
  # これは見つかったテンプレートのキャッシュに用いる
  @cache=#<ActionView::Resolver::Cache:0x7fa8d5c9c680 keys=0 queries=0>,
  # これはテンプレートを探索すべき対象
  @path="/Users/st0012/projects/sample/app/views",
  # このパターンは、テンプレートクエリの組み立てに用いられ
  # 多くの場合違いはほとんどない
  @pattern=":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}">

通常の場合、アプリのRAILS_PROJECT/app/viewsはビューテンプレートの置き場所の1つなので、この場所をガードするリゾルバがこの場所でのテンプレート探索を補助します。一部のRailsエンジンkaminarideviseなど)を使っている場合、kaminari/app/viewsdevise/app/viewもリゾルバによってガードされ、これらのgemのテンプレート探索を補助します。

ご覧いただいたように、LookupContextはテンプレートの探索場所や探索すべきテンプレートの種類をRailsに伝える役割を果たします。ここがテンプレートのレンダリングで最も重要な箇所であると申し上げた理由はこれです。

Railsがテンプレートを探索するまでの道のり

ここでActionView::TemplateRenderer#renderに戻りましょう。

# actionview/lib/action_view/renderer/template_renderer.rb
module ActionView
  class TemplateRenderer < AbstractRenderer
    def render(context, options)
      ......
      template = determine_template(options)
      ......
        render_template(template, options[:layout], options[:locals])
    end

    def determine_template(options)
      ......
      if ......
      elsif options.key?(:template)
        ......
        find_template(options[:template], options[:prefixes], false, keys, @details)
        ......
      end
    end
  end
end

テンプレートをレンダリングするには、まずテンプレートオブジェクトの取得が必要なので、これより説明します。ここからの数ステップはメソッド委譲が連続しているだけなので、説明を少し簡略化します。ステップは次のようになります。

1. TemplateRenderer#find_template@lookup_contextに委譲)
2. LookupContext#find_template#findのエイリアス)
3. LookupContext#find@view_pathsに委譲)
4. PathSet#find#find_allを呼び出し、その#find_all#find_allを呼び出す
5. PathSet#_find_allpath (resolver)eachで回して#find_allを呼び出す

# actionview/lib/action_view/path_set.rb
def _find_all(path, prefixes, args, outside_app)
  prefixes = [prefixes] if String === prefixes
  prefixes.each do |prefix|
    paths.each do |resolver|
      ......
      templates = resolver.find_all(path, prefix, *args)
      ......
      return templates unless templates.empty?
    end
  end
  []
end

6.  Resolver#find_allからPathResolver#find_templateを呼び出す

これでやっと、実際のテンプレート探索ロジックにたどり着きました(コード)。

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

def find_templates(name, prefix, partial, details, outside_app_allowed = false)
  path = Path.build(name, prefix, partial)
  query(path, details, details[:formats], outside_app_allowed)
end

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

  template_paths = find_template_paths(query)
  template_paths = reject_files_external_to_app(template_paths) unless outside_app_allowed

  template_paths.map do |template|
    handler, format, variant = extract_handler_and_format_and_variant(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

実際のテンプレート探索の3つのステップ

実際のテンプレート探索は大きく3つのステップからなります。

1. テンプレートクエリのビルド

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

def find_templates(name, prefix, partial, details, outside_app_allowed = false)
  path = Path.build(name, prefix, partial)
  query(path, details, details[:formats], outside_app_allowed)
end

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

できあがったクエリは次のような感じになります。

"/Users/stanlow/projects/sample/app/views/posts/index{.en,}{.html,}{}{.raw,.erb,.html,.builder,.ruby,.coffee,.jbuilder,}"

2. テンプレートのクエリをかける

# 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)
  ......
end

def find_template_paths(query)
  Dir[query].uniq.reject do |filename|
    File.directory?(filename) ||
      !File.fnmatch(query, filename, File::FNM_EXTGLOB)
  end
end

3. 見つかったテンプレートでAcrtionView::Templateを初期化

# 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

まとめ

テンプレート探索の道のりは非常に長く、個人的にも必要以上に長いと思います。テンプレート探索の実際のロジックは非常に素直かつシンプルですが、メソッド委譲が折り重なっていることで覆い隠されています。これに比べれば、本編であるテンプレートレンダリング(テンプレートオブジェクトから出力を得る)の方がずっと興味深く、かつテンプレートレンダリングのしくみはかなりよくできていると思います。そこで次回は、いよいよerbテンプレートがHTMLドキュメントに変わるまでを追いかけます。どうぞお見逃しなく😄

関連記事

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

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

この記事の著者

hachi8833

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

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ