分享 尝试理解 ActionController::Base#render

angelfan · September 25, 2016 · Last by geniousli replied at July 27, 2017 · 5218 hits
Topic has been selected as the excellent topic by the admin.

尝试理解 ActionController::Base#render

我是带着以下两个问题去翻 ActionView 相关的源码的,标题是后来取的

  1. 是通过什么手段将 controller 的实例变量复制到 view 中的
  2. 这些实例变量是怎么被使用的,是怎将 erb(也可能是 slim haml 等) 转化成标准的 html 的

在我们的 controller 的书写中 是通过 render 来渲染 view 的 比如我们会写:

def index
  render :index
end

# 虽然我们一般不写, 不过最终还是会调用render
# actionpack/lib/action_controller/metal/implicit_render.rb中实现了隐式调用
# 就是通过重写send_action
# send_action 原名send, 在AbstractController::Base#process_action中被调用
# process_action在识别路由分发请求之后会被调用, 
# https://ruby-china.org/topics/30916简单介绍了下rails如何识别路由并转发到对应的controller

接下来 我的切入点就变成寻找#render

#render开始

# actionpack/lib/abstract_controller/rendering.rb
# 它在ActionController::Base中被include
# 同时ActionController::Base还include ActionView::Layouts(这是controller转战view的关键点)
# 详细include了那些模块看actionpack/lib/action_controller/base.rb

module AbstractController
  module Rendering

    def render(*args, &block)
      options = _normalize_render(*args, &block)
      rendered_body = render_to_body(options)
      if options[:html]
        _set_html_content_type
      else
        _set_rendered_content_type rendered_format
      end
      self.response_body = rendered_body
    end
  end
end

一看就知道 render_to_body 是关键点,因为 response_boy 内容全靠它

# config.ru
require 'action_dispatch'
require 'action_controller'

routes = ActionDispatch::Routing::RouteSet.new
routes.draw do
  get '/' => 'test#index'
end

class TestController < ActionController::Metal
  # Rendering在ActionController::Base中才被include
  # 这里用的是Metal  
  include AbstractController::Rendering

  def index
    @var = 'you had got the instance variable'
    render 'index'
    # 这个'index'在我们这里其实是无意义的, 其实可以render任何东西
    # 比如 render 'xxx', 因为我们的render_to_body没有使用这些参数
    # 调用render只是为了使render_to_body被调用
  end

  def render_to_body(*_args)
    template = ERB.new File.read("#{params[:action]}.html.erb")
    template.result(binding)
  end
end

run routes

# index.html.erb
Message: <%= @var %>

在看#render_to_body之前我先看了一下#_normalize_render

# actionpack/lib/abstract_controller/rendering.rb 
# AbstractController::Rendering
# render各种用法详见 http://guides.rubyonrails.org/layouts_and_rendering.html
# 这里主要是对参数进行处理
# 比如是否需要特别处理 例如render status: 200
# 是否需要套用layout
# 是否需要加上prefixes  => YourControler._prefixes
# 处理完之后才会交给render_to_body, 因为它需要就根据你写的代码见招拆招
def _normalize_render(*args, &block)
  options = _normalize_args(*args, &block)
  if defined?(request) && !request.nil? && request.variant.present?
    options[:variant] = request.variant
  end

  _normalize_options(options)
  options
end

# ActionController::Base中引用了太多的模块很多方法被多个模块不断的重写, 不同模块的重写附加的职能是不同的
# 为了知道一个方法被那些模块重写 把下面这段代码复制粘贴到`rails c`下就能用
class Module
  def ancestors_that_implement_instance_method(instance_method)
    ancestors.find_all do |ancestor|
      (ancestor.instance_methods(false) + ancestor.private_instance_methods(false)).include?(instance_method)
    end
  end
end

# 贴一下这两个方法被那些模块重写过
ActionController::Base.ancestors_that_implement_instance_method(:_normalize_args)
# [
#     [0] ActionController::Rendering,
#     [1] ActionView::Rendering,
#     [2] AbstractController::Rendering
# ]

ActionController::Base.ancestors_that_implement_instance_method(:_normalize_options)
# [
#     [0] ActionController::Rendering,
#     [1] ActionView::Layouts,
#     [2] ActionView::Rendering,
#     [3] AbstractController::Rendering
# ]

进入 render_to_body

先看一下哪些地方重写了#render_to_body

ActionController::Base.ancestors_that_implement_instance_method(:render_to_body)
# rails5中会搜到了5个同名方法 api那边会重写一次
# [
#     [0] ActionController::Renderers, # 负责处理:json, :js, :xml等
#     [1] ActionController::Rendering, # 负责处理 :body, :text, :plain, :html
#     [2] ActionView::Rendering, #  恩 这个就是我要找的
#     [3] AbstractController::Rendering # 它什么都不干 就是一个接口, 毕竟人家叫Abstract
# ]
# actionview/lib/action_view/rendering.rb
module ActionView
  module Rendering
    def render_to_body(options = {})
      _process_options(options)
      _render_template(options)
    end
  end
end

主要看#_render_template

# 到这里我的第一个问题的答案就水落石出了(是通过什么手段将controller的实例变量复制到view中的)
def _render_template(options) #:nodoc:
  variant = options.delete(:variant)
  assigns = options.delete(:assigns)
  context = view_context

  context.assign assigns if assigns
  lookup_context.rendered_format = nil if options[:formats]
  lookup_context.variants = variant if variant

  view_renderer.render(context, options)
end

def view_context
  view_context_class.new(view_renderer, view_assigns, self)
end

# view_context接受三个参数, 其中包括view_assigns
def view_assigns
  protected_vars = _protected_ivars
  variables      = instance_variables

  variables.reject! { |s| protected_vars.include? s }
  variables.each_with_object({}) { |name, hash|
    hash[name.slice(1, name.length)] = instance_variable_get(name)
  }
end

# #view_assigns会把我们在controller中定义的实例变量拿过来先放到hash中存起来, 不过会去掉_protected_ivars(实现这套框架引入的实例变量)
# 然后交给ActionView::Base
def initialize(context = nil, assigns = {}, controller = nil, formats = nil) #:nodoc:
  ...
  assign(assigns)
  ...
end

# 将hash通过instance_variable_set变成view(ActionView::Base)的实例变量
def assign(new_assigns) # :nodoc:
  @_assigns = new_assigns.each { |key, value| instance_variable_set("@#{key}", value) }
end

OK 第一个问题解决,我很早之前有猜测过是用了binding

继续寻找第二个问题的答案 #view_context

def view_context_class
  @view_context_class ||= begin
    ......
    # 这就是为什么在view中输出self.class是匿名的class
    Class.new(ActionView::Base) do
      if routes
        include routes.url_helpers(supports_path)
        include routes.mounted_helpers
      end

      if helpers
        include helpers
      end
    end
  end
end

def view_context
  # self就是当前controller的实例
  view_context_class.new(view_renderer, view_assigns, self)
end

#lookup_context

def lookup_context
  @_lookup_context ||=
    ActionView::LookupContext.new(self.class._view_paths, details_for_lookup, _prefixes)
end

# 它知道要去哪里找view
# YourController.new.lookup_context.view_paths.paths

#view_renderer

def view_renderer
  @_view_renderer ||= ActionView::Renderer.new(lookup_context)
end

# 它知道什么情况下怎么处理, 比如render text: 'xx', render html: 'xx' 等等

看到这里我一开始其实是有点迷糊的 因为在_render_template中最终通过view_renderer#render输出 为什么 view_context 还要拿个 view_renderer 在手里 后来才想起来在 view 中也会 render 它同样要依靠 ActionView::Renderer

通过运行 ActionView::Base.ancestors_that_implement_instance_method(:render) 会发先 view 中的#render在 ActionView::Helpers::RenderingHelper 中

module ActionView
  module Helpers
    # = Action View Rendering
    #
    # Implements methods that allow rendering from a view context.
    # In order to use this module, all you need is to implement
    # view_renderer that returns an ActionView::Renderer object.
    module RenderingHelper
    end
  end
end
# 看文档注释 果然是这样

通过猜 + 翻看注释 发现我寻找答案应该是在 view_renderer 中,一路看下去

module ActionView
  class Renderer
    def render(context, options)
      if options.key?(:partial)
        render_partial(context, options)
      else
        render_template(context, options)
      end
    end

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

def render_template(template, layout_name = nil, locals = nil) #:nodoc:
  view, locals = @view, locals || {}

  render_with_layout(layout_name, locals) do |layout|
    instrument(:template, identifier: template.identifier, layout: layout.try(:virtual_path)) do
      template.render(view, locals) { |*name| view._layout_for(*name) }
    end
  end
end

# 最终进入
module ActionView
  # = Action View Template
  class Template

    def render(view, locals, buffer=nil, &block)
      instrument_render_template do
        compile!(view)
        view.send(method_name, locals, buffer, &block)
      end
    rescue => e
      handle_render_error(view, e)
    end

    def compile!(view) #:nodoc:
      return if @compiled

      @compile_mutex.synchronize do
        ...

        instrument("!compile_template") do
          compile(mod)
        end

        ...
      end
    end

    def compile(mod) #:nodoc:
      ...
      method_name = self.method_name
      code = @handler.call(self)

      source = <<-end_src
        def #{method_name}(local_assigns, output_buffer)
          _old_virtual_path, @virtual_path = @virtual_path, #{@virtual_path.inspect};_old_output_buffer = @output_buffer;#{locals_code};#{code}
        ensure
          @virtual_path, @output_buffer = _old_virtual_path, _old_output_buffer
        end
      end_src

      mod.module_eval(source, identifier, 0)
      ObjectSpace.define_finalizer(self, Finalizer[method_name, mod])
    end

    def method_name #:nodoc:
      @method_name ||= begin
        m = "_#{identifier_method_name}__#{@identifier.hash}_#{__id__}"
        m.tr!('-', '_')
        m
      end
    end
  end
end

code = @handler.call(self)

@handler => ActionView::Template::Handlers::ERB

self => ActionView::Template

它会输出一串字符串

就像 ActionView::Template::Handlers::Erubis.new("<h1><%= @abc %></h1>").src 会输出

"@output_buffer = output_buffer || ActionView::OutputBuffer.new;@output_buffer.safe_append='<h1>'.freeze;@output_buffer.append=( @abc );@output_buffer.safe_append='</h1>'.freeze;@output_buffer.to_s"

这样相当于

def method_name(local_assigns, output_buffer) # method_name每次都不一样, 用完之后会通过#define_finalizer回收
  ...
  @output_buffer = output_buffer || ActionView::OutputBuffer.new
  @output_buffer.safe_append='<h1>'.freeze
  @output_buffer.append=( @abc )
  @output_buffer.safe_append='</h1>'.freeze
  @output_buffer.to_s
  ...
end

这个方法通过 mod.module_eval(source, identifier, 0) 作用到 ActionView::Base 上了 所以可以抓到@abc这个实例变量

至此完成 erb 到 html 的转换

两个问题解完

小结

rails中的v(view)和c(controller)是靠着AbstractController::Rendering#render来承上(c)启下(v), 通过#render_to_body 作为接口来获取 view 的结果作为 response_body 返回给客户端

在我们的 view 中会使用一些 helper, 比如我们自己定义的 helper method 以及通过路由生成的 url helper, 所以在 ActionView::Rendering 会以 Class.new(ActionView::Base) 的方式来加载这个 helper method

三个比较重要的东西 view_context lookup_context view_renderer, xxx_context 其实什么事情都不会干,只是有一堆关于上下文环境相关的东西 就好比军师, view_renderer 就好比士兵吧,军师把相关信息告诉士兵,然后士兵执行 (渲染出 view)

view 其实是先被 compile 成一堆字符串 (OutputBuffer 相关), 然后通过 module_eval 转换成成相应的方法

@matrixbirds 我貌似加过你 qq, 不过在 at 你的时候 发现有好多类似的名字,你是『下面』君么? 😂

#2 楼 @angelfan 你过来我保证不下面给你吃…

推荐看 Crafting Rails 4 Applications 这本书的第三章,里面有很详细的描述以及教你像 CMS 一样从数据库中去寻找你要 render 的 template。

这个panoramic相当于书中内容的 demo 吧。

jasl mark as excellent topic. 28 Sep 01:34

@jasl 感谢大大鼓励,不过 01:34 还没睡,注意休息啊 😂

楼主一般如何阅读源码?有一些比较快速的方法吗?ruby-mine 打断点调试?

You need to Sign in before reply, if you don't have an account, please Sign up first.