禾川資訊 Grass Brook

Text

研究 Rails 3 的 Action::Dispatch

當開發 Rails 3 應用程式時,了解新的 call stack 設計對除錯和開發新功能的幫助會很大:

call stack of Rails 2 and 3

首先 Action::Dispatch 成為整個 call stack 的主角,Jeremy McAnally 歸納出幾個特點:

  • Request handling and parameter parsing
  • Sessions, Rails’ flash, and cookie storage
  • File uploads
  • Routing, URL matching, and rescuing errors
  • HTTP conditional GETs
  • Client response and HTTP status code

如果熟悉 Rails 3 的初始化程序,應該可以了解 rack server 在接收到一個 HTTP request 後,要怎麼誘發 Rails::Application 的 .call 實體方法:

Rails::Server.new.start
Rack::Handler::Mongrel.run(wrapped_app)
Mongrel::HttpServer.new.run
Mongrel::HttpServer.new.process_client
Rack::Handler::Mongrel.new.process
Rack::Chunked.new.call(env)
Rack::ContentLength.new.call(env)
Rails::Rack::LogTailer.new.call(env)
YourApp::Application.call(env)
Rails::Application.call(env)
Rails::Application.app.call(env)
Rails::Application.middleware.build(routes).call(env) # @app.call(env)

Rails 3 為了符合 Rack 的設計理念(能回應 call 方法的物件),於是將 routes 和 middlewares 設計成串接式(cascade)型態的物件,並在執行 initializers 時實體化成 rails app stack 物件。其中有三個 initializer 跟 Action::Dispatch 最相關:

## actionpack/lib/action_dispatch/railtie.rb
initializer "action_dispatch.prepare_dispatcher" do |app|
  require 'rails/dispatcher'
  ActionDispatch::Callbacks.to_prepare { app.routes_reloader.reload_if_changed }
end
## railties/lib/rails/engine.rb
initializer :add_routing_paths do |app|
  paths.config.routes.to_a.each do |route|
    app.routes_reloader.paths.unshift(route) if File.exists?(route)
  end
end
## railties/lib/rails/application/finisher.rb
initializer :build_middleware_stack do
  app
end

其中 action_dispatch.prepare_dispatcher 會設定一個 ActionDispatch 的 callback 內容。而 add_routing_paths 會用 Rails::Application.routes_reloader 方法實體化一個 Rails::Application::RoutesReloader 物件,並將所有 Rails::Engine 的 config/routes.rb 加入到 @path 陣列。當執行 build_middleware_stack 時,會實體化 ActionDispatch::Routing::RouteSet 物件,連同各式剛實體化好的 middleware 物件一起放入 ActionDispatch::MiddlewareStack 陣列物件中,最後轉換成串接式型態的物件:

## railties/lib/rails/application.rb
class Rails::Application
  def app
    @app ||= middleware.build(routes)
  end
  def routes
    @routes ||= ActionDispatch::Routing::RouteSet.new
  end
end
## railties/lib/rails/engine.rb
class Rails::Engine
  delegate :middleware, :paths, :root, :to => :config
end
## railties/lib/rails/application/configuration.rb
class Rails::Application::Configuration
  def middleware
    @middleware ||= default_middleware_stack
  end
  def default_middleware_stack
    ActionDispatch::MiddlewareStack.new.tap do |middleware|
      ......
      middleware.use('::ActionDispatch::Callbacks', lambda { !cache_classes })
      ......
    end
  end
end
## actionpack/lib/action_dispatch/middleware/stack.rb
class ActionDispatch::MiddlewareStack
  def build(app = nil, &blk)
    app ||= blk
    raise "MiddlewareStack#build requires an app" unless app
    active.reverse.inject(app) { |a, e| e.build(a) }
  end
  def use(*args, &block)
    middleware = Middleware.new(*args, &block)
    push(middleware)
  end
end

仔細地看 Rails::Application.app 物件真正的樣貌,就是把一個擁有 @app 類別變數的 middleware 物件當成另一個擁有 @app 類別變數的 middleware 物件的初始化參數,一個個串接下去形成一個大型的 rack app stack 物件。這 rack app stack 物件也會被當成參數成為 rack server stack 的串接式物件,以便呼應 rack server 傳來的 call 方法:

#> s1 = ::ActiveRecord::QueryCache.new(@routes)
#> s9 = ::ActionDispatch::Callbacks.new(s8, !cache_classes)
#> ……
#> @app = ::ActionDispatch::Static.new(s13, Rails.public_path)
#> Rails::Application.app.call(env) = @app.call(env)
@app = #<ActionDispatch::Static:0x2ff9fa4
  @app=#<Rack::Lock:0x2ffa1ac
  @app=#<Rack::Runtime:0x2ffa2b0
  @app=#<Rails::Rack::Logger:0x2ffaa1c
  @app=#<ActionDispatch::ShowExceptions:0x2ffaabc
  @app=#<ActionDispatch::Callbacks:0x2ffac60
  @app=#<ActionDispatch::Cookies:0x2ffae40
  @app=#<ActiveRecord::SessionStore:0x2ffaf1c
  @app=#<ActionDispatch::Flash:0x2ffb0ac
  @app=#<ActionDispatch::ParamsParser:0x2ffb50c
  @app=#<Rack::MethodOverride:0x2ffb64c
  @app=#<ActionDispatch::Head:0x2ffb944
  @app=#<ActiveRecord::ConnectionAdapters::ConnectionManagement:0x300e2d8 
  @app=#<ActiveRecord::QueryCache:0x300e33c
  @app=#<ActionDispatch::Routing::RouteSet:0x151c588»»»»»»»>

這裡要特別注意 ActionDispatch::Callbacks 這個 middleware 物件,當它初始化時會執行名為 :prepare 的 callback(先前的 initializer 已設定好內容)。這個 callback 執行時會用 Rails::Application.routes_reloader 方法來實體化一個 Rails::Application::RoutesReloader 物件,並動用 reload! 方法。這時會得到 ActionDispatch::Routing::RouteSet 實體物件,接著利用 clear! 方法取得一個新的 Rack::Mount::RouteSet 實體物件 @set,依序載入所有 Rails::Engine 的 config/routes.rb,最後執行 finalizer! 方法,將 @set 凍結住避免變動:

## railties/lib/rails/application.rb
class Rails::Application
  def routes_reloader
    @routes_reloader ||= RoutesReloader.new
  end
end
## actionpack/lib/action_dispatch/middleware/callback.rb
class ActionDispatch::Callbacks
  define_callbacks :prepare, :scope => :name
  def self.to_prepare(*args, &block)
    if args.first.is_a?(Symbol) && block_given?
      define_method :"__#{args.first}", &block
      set_callback(:prepare, :"__#{args.first}")
    else
      set_callback(:prepare, *args, &block)
    end
  end
  def initialize(app, prepare_each_request = false)
    @app, @prepare_each_request = app, prepare_each_request
    run_callbacks(:prepare)
  end
end
## railties/lib/rails/application/routes_reloader.rb
class Rails::Application::RoutesReloader
  def reload!
    routes = Rails::Application.routes
    routes.disable_clear_and_finalize = true
    routes.clear!
    paths.each { |path| load(path) }
    ActiveSupport.on_load(:action_controller) { routes.finalize! }
    nil
  ensure
    routes.disable_clear_and_finalize = false
  end
end
## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet
  def clear!
    # Clear the controller cache so we may discover new ones
    @controller_constraints = nil
    @finalized = false
    routes.clear
    named_routes.clear
    @set = ::Rack::Mount::RouteSet.new(:parameters_key => PARAMETERS_KEY)
  end
  def finalize!
    return if @finalized
    @finalized = true
    @set.add_route(NotFound)
    @set.freeze
  end
end

當 routes_reloader 載入 config/routes.rb 時,會誘發 Rails::Application.routes 執行 draw 實體方法。這個 draw 方法會把 routes 自己(ActionDispatch::Routing::RouteSet 實體物件)當成參數實體化一個 ActionDispatch::Routing::Mapper 物件 mapper,並成為 mapper 的 @set 變數,而 mapper 會負責解譯 block 中定義的 Rails routes mapping DSL 內容。

例如當定義一個 match(‘/’, :to => “your_home#index”) 時,首先會把傳入 match 方法的參數轉換成 ActionDispatch::Routing::Mapper::Mapping 實體物件,再轉換成參數陣列 mapping。接著 mapper 的 @set 變數(routes)會以 add_route 方法把 mapping 參數實體化成 ActionDispatch::Routing::Route 物件 route。由於 routes 本身也有一個 @set 變數是個 Rack::Mount::RouteSet 實體物件,同樣會以 add_route 方法將 route 加入這個 @set 變數,以便串接 rack mount 的 router 辨識功能:

## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet
  def draw(&block)
    clear! unless @disable_clear_and_finalize
    mapper = Mapper.new(self)
    if block.arity == 1
      mapper.instance_exec(DeprecatedMapper.new(self), &block)
    else
      mapper.instance_exec(&block)
    end
    finalize! unless @disable_clear_and_finalize
    nil
  end
  # @set = ::Rack::Mount::RouteSet.new
  def add_route(app, conditions = {}, requirements = {}, defaults = {}, name = nil, anchor = true)
    route = Route.new(app, conditions, requirements, defaults, name, anchor)
    @set.add_route(*route)
    named_routes[name] = route if name
    routes << route
    route
  end
end
class ActionDispatch::Routing::Dispatcher
  def initialize(options={})
    @defaults = options[:defaults]
    @glob_param = options.delete(:glob)
  end
end
## actionpack/lib/action_dispatch/routing/mapper.rb
class ActionDispatch::Routing::Mapper
  def initialize(set)
    @set = set
  end
  # @set = ::ActionDispatch::Routing::RouteSet.new
  def match(*args)
    mapping = Mapping.new(@set, @scope, args).to_route
    @set.add_route(*mapping)
    self
  end
end
class ActionDispatch::Routing::Mapper::Mapping
  def to_route
    [ app, conditions, requirements, defaults, @options[:as], @options[:anchor] ]
  end
  def app
    Constraints.new(
      to.respond_to?(:call) ? to : Routing::RouteSet::Dispatcher.new(:defaults => defaults),
      blocks
    )
  end
end

產生 mapping 陣列時會實體化一個 Routing::RouteSet::Dispatcher 物件 app,負責拆解 params,執行特定 controller 的 action 內容,屬於仲介 route 到 controller 的主要 app stack 物件。這時 initializer 才算完成整個 rails app stack 的準備工作。

當一個 HTTP request 從 rack server stack 傳遞到 rails app stack 後,會先經過 middlewares 一層層地處理,直到 ActionDispatch::Routing::RouteSet 實體物件傳遞給 Rack::Mount::RouteSet 實體物件,才會辨識 http request 該由哪個 ActionDispatch::Routing::Route 實體物件來執行,並經由 ActionDispatch::Routing::RouteSet::Dispatcher 實體物件誘發特定 controller 的 action 內容:

Rails::Application.middleware.build(routes).call(env) # @app.call(env)
ActionDispatch::Static.new.call(env) # @app.call(env)
…… # cascade @app.call(env) of middlewares
ActionDispatch::Routing::RouteSet.new.call(env) # @app.call(env)
Rack::Mount::RouteSet.new.call(env) # @set.call(env)
ActionDispatch::Routing::Route.new.call(env) # route.app.call(env)
ActionDispatch::Routing::RouteSet::Dispatcher.new.call(env) # @app.call
YourHomeController.action(params[:action]).call(env)
YourHomeController.middleware_stack.build(default_stack_proc).call(env)
#> default_stack_proc = Proc.new { new.dispatch(name, klass.new(env)) }
YourHomeController.new.dispatch(params[:action], request)
#> request = ActionDispatch::Request.new(env)
YourHomeController.new.send(params[:action])
YourHomeController.new.to_a

從程式碼中可以發覺 ActionDispatch::Routing::RouteSet::Dispatcher 實體物件利用 controller.action(params[:action]) 方法來取得任一個 controller 的 action dispatch stack 串接式物件(ActionDispatch::MiddlewareStack 物件),這個 stack 預設是一個 Proc 物件,能夠執行 controller 的某個 action:

## railties/lib/rails/application.rb
class Rails::Application
  def call(env)
    env["action_dispatch.parameter_filter"] = config.filter_parameters
    app.call(env)
  end
end
## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet
  def call(env)
    finalize!
    @set.call(env)
  end
end
## rack-mount/lib/rack/mount/recognition/route_set.rb
class Rack::Mount::RouteSet
  def call(env)
    raise 'route set not finalized' unless @recognition_graph
    env[PATH_INFO] = Utils.normalize_path(env[PATH_INFO])
    request = nil
    req = @request_class.new(env)
    recognize(req) do |route, matches, params|
      # TODO: We only want to unescape params from uri related methods
      params.each { |k, v| params[k] = Utils.unescape_uri(v) if v.is_a?(String) }
      if route.prefix?
        env[Prefix::KEY] = matches[:path_info].to_s
      end
      env[@parameters_key] = params
      result = route.app.call(env)
      return result unless result[1][X_CASCADE] == PASS
    end
    request || [404, {'Content-Type' => 'text/html', 'X-Cascade' => 'pass'}, ['Not Found']]
  end
end
## actionpack/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet::Dispatcher
  def call(env)
    params = env[PARAMETERS_KEY]
    prepare_params!(params)
    # Just raise undefined constant errors if a controller was specified as default.
    unless controller = controller(params, @defaults.key?(:controller))
      return [404, {'X-Cascade' => 'pass'}, []]
    end
    controller.action(params[:action]).call(env)
  end
end
## actionpack/action_controller/metal.rb
class ActionController::Metal
  class_attribute :middleware_stack
  self.middleware_stack = ActionDispatch::MiddlewareStack.new
  def self.inherited(base)
    self.middleware_stack = base.middleware_stack.dup
    super
  end
  def self.action(name, klass = ActionDispatch::Request)
    middleware_stack.build do |env|
      new.dispatch(name, klass.new(env))
    end
  end
  def dispatch(name, request)
    @_request = request
    @_env = request.env
    @_env['action_controller.instance'] = self
    process(name)
    to_a
  end
  def to_a
    response ? response.to_a : [status, headers, response_body]
  end
end
## actionpack/abstract_controller/base.rb
class AbstractController::Base
  def process(action, *args)
    @_action_name = action_name = action.to_s
    unless action_name = method_for_action(action_name)
      raise ActionNotFound, "The action '#{action}' could not be found"
    end
    @_response_body = nil
    process_action(action_name, *args)
  end
  def process_action(method_name, *args)
    send_action(method_name, *args)
  end
  alias send_action send
end

由於 controller 的 action 不會再傳遞 HTTP request 到其他物件,而是直接回傳標準的 rack response 陣列 [status, headers, response_body],成了所謂的 rack endpoint。最後再由 rack server 把 HTTP response 送回到使用者個瀏覽器中解讀。

以上就是 Action::Dispatch 整個 call stack 大略的樣貌。如果想要在這中間做些處理,則是安插個 middleware 進去,如 José Valim 就寫了個 devise 提供 rack 風格的 authentication 功能。

Posted on Wednesday, April 7 2010. Tagged with: rails 3action dispatchcall stackmiddleware stackdispatch stack
禾川資訊 Grass Brook We are a studio focused on Ruby, Rails and Agile Development.
Ask me anything Submit
Previous Next