概要
原著者の許諾を得て翻訳・公開いたします(パブリックドメイン)。
- 英語記事: Disassembling Rails — Template Rendering (1) – Stan Lo – Medium
- 原文公開日: 2017/07/04
- 著者: Stan Lo — Goby言語の作者でありRails開発者です。
Railsのテンプレートレンダリングを分解調査する#1探索編(翻訳)
今回は「Rails分解調査シリーズ」第2弾です。前回の記事「Railsのフラグメントキャッシュを分解調査する」をお読み頂いてない方でも今回の記事を読むのに差し支えはありませんが、それでもご一読をおすすめいたします。
Railsのテンプレートレンダリングの背後では実に多くの処理が行われているので、2回に分けて詳しくご紹介いたします。その第1回目として、表示したいテンプレートをRailsのrender
メソッドがどのように探索しているかを説明いたします。続く第2回では、テンプレートオブジェクトがレスポンスに使えるHTMLに変換される過程を説明します。それでは始めましょう!
注目すべきファイル
ソースコードを自分でも見てみたい方向けに(私からも推奨します)、今回のテーマで注目すべきファイルをリストアップします。
訳注: Rails 5.2-stableのソースにリンクしました。
actionview/lib/action_view/helpers/rendering_helper.rb
actionview/lib/action_view/renderer/renderer.rb
actionview/lib/action_view/lookup_context.rb
actionview/lib/action_view/renderer/template_renderer.rb
actionview/lib/action_view/path_set.rb
ユーザーインターフェイス
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
はハッシュで、locale
、formats
、variants
、handlers
が含まれています。@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エンジン(kaminari
やdevise
など)を使っている場合、kaminari/app/views
やdevise/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_all
はpath (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ドキュメントに変わるまでを追いかけます。どうぞお見逃しなく