Rails 中间件的初始化是怎么完成的

from @unsplash

据说 Rails 是由中间件(middleware)组合而成的,但他们是怎么组合的呢?

我们先来看一个中间件的代码:

# actionpack-5.1.6/lib/action_dispatch/middleware/request_id.rb
module ActionDispatch
  class RequestId
    X_REQUEST_ID = "X-Request-Id".freeze #:nodoc:

    def initialize(app)
      @app = app
    end

    def call(env)
      req = ActionDispatch::Request.new env
      req.request_id = make_request_id(req.x_request_id)
      @app.call(env).tap { |_status, headers, _body| headers[X_REQUEST_ID] = req.request_id }
    end

    # ...
end

这个中间件是比较简单的,在 Rails 中的作用是为每个请求创建一个唯一的 request_id。他有一些特点,是所有中间件都会有的特点(TODO:核对别人的博客和 Rails Guide):

  1. 构造函数的第一个参数接受另外一个中间件对象,并把这个对象封装进一个实例变量(通常叫 app)
  2. 都包含一个叫 call 的实例方法,call 方法接受一个 env 参数(包含各种 http 请求参数),并且一定返回 http_status, http_headers, response_body 三个对象
  3. 在 call 的实例方法中,一定会用本中间件对象中的那个别的中间件对象(通常叫app)调用他自己 call 方法。

因此我们可以把一个中间件对象这样表示:

image

我们从这个结构中可以看出,这些中间件明显就是要和其他中间件组合起来,形成一种链式的或者栈式的调用。那么,多个中间件联合起来就是这样的:

image

但这个链条不会无限的进行,他会有两个端点。这个我们后面再说,现在我们先来看看这些中间件对象是一种怎样的存在。

首先说,这些中间件是有顺序存在的,可能是因为某些层的中间件需要互相依赖,也可能只是因为乱序的调用实现上比较麻烦,或者是其他的一些原因。但总之,如果我们在一个 Rails 项目的目录下执行 rails middleware 就能看到顺序显示的中间件们的类。比如这样:

$ rails middleware
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
run YourProject::Application.routes

这些只是中间件的类,但具体要调用上面提到的 call 方法的是中间件的实例对象。这些对象是如何产生的呢?是每次请求的时候,这些类调用 new 方法构造一个新的对象,还是这些中间件的对象一直存在一个地方,每次请求的时候,Rails 都通过一些方法取出一个中间件的对象呢(比如,从一个固定的位置取出)?

这个答案隐藏在 Rails 这个对象中。

Rails 本来是个 module (模块),里面有很多关于 Rails 的代码(见 railties-5.1.6/lib/rails.rb),但就像大家都知道的那样, module 在 ruby 中也是一个对象。我们能够隐约感觉到这里面不只有代码,而是也存了一些项目信息的,是通过这些常用方法:

Rails.root # => #<Pathname:/path/to/your/project>
# or
Rails.env # => "development"

我们当然可以认为当我们调用这些方法的时候,代码去访问了环境变量,然后把这些信息返回回来,但也可能是存在 Rails 这个 module 对象中。我们去看看源代码就好了:

# railties-5.1.6/lib/rails.rb#Line24
module Rails
  # ...
  class << self
    @application = @app_class = nil

    attr_writer :application
    attr_accessor :app_class, :cache, :logger
    def application
      @application ||= (app_class.instance if app_class)
    end
    
    # ...
    
    def root
      application && application.config.root
    end

    def env
      @_env ||= ActiveSupport::StringInquirer.new(ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development")
    end
  end
  # ...
end

我们可以看到,在 Rails 这个对象中,至少存了 @_env 这个实例变量供我们查询 env。当然,这里更有 @application 这个我们要用到的实例变量。

后面我们会看到,这个 @application 是一个对象,他里面有一个叫做 @app 的实例变量,指向第一层的 middleware。不过,我们先来看看这个 @application 是怎么生成的。

我们都知道我们的项目在 rails new(即创建项目)之后,会有一个 config/application.rb,在这里,rails 会用我们的项目名称,自动的创建一个 module,比如叫 YourProject:

# config/application.rb
module YourProject
    class Application < Rails::Application
        # ...
    end
end

这里的重点是,因为我们的 YourProject::Application组成的这个类,继承了Rails::Application,就会有一些事情发生。请看 Rails::Application 中的代码:

# railties-5.1.6/lib/rails/application.rb#Line86
module Rails
  class Application < Engine
    # ...
    class << self
      def inherited(base)
        super
        Rails.app_class = base
        add_lib_to_load_path!(find_root(base.called_from))
        ActiveSupport.run_load_hooks(:before_configuration, base)
      end

      def instance
        super.run_load_hooks!
      end
      # ...
    end
  end
end

这里重要的是 inherited 方法(这是一个类似于继承后回调的方法),其中的 Rails.app_class = base,会把刚刚说到的 Rails 这个对象中的 app_class 属性赋值为YourProject::Application,也就是跟你的项目挂钩。

我们回到上面 Rails module 中的一段代码:

# railties-5.1.6/lib/rails.rb#Line37
def application
  @application ||= (app_class.instance if app_class)
end

这里 app_class 又会去调用 instance 方法,这个方法我们就不暴露了,但总之是创建了一个 YourProject::Application 对象。

好了,我们再来看看上面提到的 inherited 方法是什么时候调用的,这可以让我们知道 @applicatoin 中的对象是什么时候生成的。

首先说,当代码加载 config/application.rb 的时候,就会执行 class YourProject::Application < Rails::Application 这行代码,进而调用 inherited 方法。而 config/application.rb 是什么时候加载的呢?

大家知道 Rails 项目启动的时候会由 puma 之类的服务器调用 config.ru 文件:

# config.ru
require_relative 'config/environment'
run Rails.application

我们顺着从这里开始的,一层层 require 的文件树,就能在看到如下一些代码:

# config/environment.rb
require_relative 'application'
Rails.application.initialize!

其中的 require_relative 'application' 就是在 require 我们的 config/application.rb 了,也就是说在启动 rails 的过程中,Railsapp_class 就已经和我们项目名称绑定,将一个 @application 对象放在 Rails 中。

又因为,Rails 这个常量是唯一的(在一个 ruby 进程中),他的 application 属性也是唯一的,或者说 @application 对象是唯一的。那里面存储着用我们的项目命名的一个类的对象。

接下来,rails 项目会执行很多叫做 initializer 的代码块,这些 initializer 中,有相当一部分的动作都可以简单理解为:往 Rails.application 这个对象中添加一些实例变量,存储一些数据。跟中间件相关的这个 initializer 也不例外:

# railties-5.1.6/lib/rails/application/finisher.rb#Line44
initializer :build_middleware_stack do
  build_middleware_stack
end

(顺带说一句,initializer 特别多,而有些需要先执行,有些需要后执行,这种位置关系让他们形成了一个图的结构,rails 在执行这个图中的 intializer 之前会遍历这个图中的节点,形成一个没有回路的路径?学名单源最短路径?)

当执行到这里的时候,Rails.application 开始调用一个叫做 build_middleware_stack 的方法。看看这个名字就知道和我们要探索的 middleware 有关了。

这个方法是另外一个方法(app方法)的别名:

# railties-5.1.6/lib/rails/engine.rb#Line503
def app
  @app || @app_build_lock.synchronize {
    @app ||= begin
      stack = default_middleware_stack
      config.middleware = build_middleware.merge_into(stack)
      config.middleware.build(endpoint)
    end
  }
end

一旦这里执行完成,我们的 Rails.application 中就会多一个叫做 @app 的实例变量。这个变量叫做 @app,和中间件对象中的 @app 实例变量不是巧合的对应。

他都做了什么呢?

首先说,这里的 config.middleware 中包含的是我们中间件的各个类(class,不是对象,这些类怎么加载进来的还没有调查,但其中一些是通过 config.middleware.use ActionDispatch::Cookies 这样的语句,那些类常量就被存在了另外一个我们这里不会讲的实例变量中,然后这里通过 config.middleware 把他们取出来)。

更重要的是 config.middleware 去调用 build 方法,同时传入了一个 endpoint。endpoint 顾名思义,是middleware链条的终点,这个方法返回的默认是 YourProject::Application.routes,也就是 rails middleware 这个指令输出的最后一个所谓的中间件,而其实 YourProject::Application.routes 是方法,这个方法返回的是一个 ActionDispatch::Routing::RouteSet对象。当然,如果有其他配置或许就不一样了。但我们这里还是以默认的情况为例子。

顺带说一句,其中的 @app_build_lock 是个线程锁,显然这里 Rails 考虑到 rails 项目启动时如果 puma 这样的服务器已经启动了多线程,就要避免竞争关系。

接下来,带着这个 RouteSet 的对象作为终点,我们看看 config.middleware.build 方法都做了什么:

# actionpack-5.1.6/lib/action_dispatch/middleware/stack.rb#Line98
def build(app = Proc.new)
  middlewares.freeze.reverse.inject(app) { |a, e| e.build(a) }
end

传入的是 endpoint,从它开始,递归地构建前一层的中间件对象(看 inject 方法递归)。而这些 middlewares 的类,本来是在一个数组中,按照从起点到终点的方式排序的,但是因为我们是从终点开始递归创建的,所以这里要 reverse 一下。大家可以想想看 reverse 后第一个被创建的中间件是哪个:就是第二靠近内层的中间件,对吧。

inject 的 block 里面的 build 其实就是用 e 这个类去调用构造函数,这个就不再赘述:

# actionpack-5.1.6/lib/action_dispatch/middleware/stack.rb#Line34
def build(app)
  klass.new(app, *args, &block)
end

总之,就在这样一层一层从最内层向外创建中间件对象,同时把内层的中间件封装在外层的中间件中,这个递归的过程会遍历完 middlewares 这个数组,然后开始创建最外层的中间件的对象,然后返回给 Rails.application 这个对象,使其中拥有一个叫做 @app 的实例变量。

到这里我们可以看出,一个 rails 的进程中,有唯一的 Rails 这个对象,其中有唯一的 application 属性了,其中又有一个启动后初始化完成的 @app 对象。这个 @app 对象就是最外层的中间件。所以,刚刚我们说找到了中间件链条的终点(routes),现在我们也找到了起点(Rails.application)。

画成图,就是这样的:

image

如果想把 Rails.application 的结构打印出来,可以用 pp Rails.application, 很长:

#<YourProject::Application:0x007ff5f59989f8
 @app=
  #<Rack::Sendfile:0x007ff5f5e13460
   @app=
    #<ActionDispatch::Static:0x007ff5f5e13690
     @app=
      #<ActionDispatch::Executor:0x007ff5f5e136e0
       @app=
        #<ActiveSupport::Cache::Strategy::LocalCache::Middleware:0x007ff5f5e0ae78
         @app=
          #<Rack::Runtime:0x007ff5f5e13708
           @app=
            #<ActionDispatch::RequestId:0x007ff5f5e13780
             @app=
              #<ActionDispatch::RemoteIp:0x007ff5f5e137a8
               @app=
                #<Rails::Rack::Logger:0x007ff5f5e13820
                 @app=
                  #<ActionDispatch::ShowExceptions:0x007ff5f5e13848
                   @app=
                    #<ActionDispatch::DebugExceptions:0x007ff5f5e13870
                     @app=
                      #<ActionDispatch::Reloader:0x007ff5f5e138e8
                       @app=
                        #<ActionDispatch::Callbacks:0x007ff5f5e13910
                         @app=
                          #<ActiveRecord::Migration::CheckPending:0x007ff5f5e13938
                           @app=
                            #<Rack::Head:0x007ff5f5e13960
                             @app=
                              #<Rack::ConditionalGet:0x007ff5f5e13988
                               @app=
                                #<Rack::ETag:0x007ff5f5e139b0
                                 @app=
                                  #<ActionDispatch::Cookies:0x007ff5f5e139d8
                                   @app=
                                    #<ActionDispatch::Session::CookieStore:0x007ff5f5e13ac8
                                     @app=
                                      #<RequestLog:0x007ff5f5e13e88
                                       @app=
                                        #<ActionDispatch::Routing::RouteSet:0x007ff5f5f499d8
                                         @append=
                                          [#<Proc:0x007ff5f5b6a4c0@/Users/SiningLiu/.rvm/gems/ruby-2.3.4/gems/railties-5.1.6/lib/rails/application/finisher.rb:30>],
                                         @config=
                                          #<struct ActionDispatch::Routing::RouteSet::Config
                                           relative_url_root=nil,

这个调查过程能回答一个问题,就是 rails 在接受每个 request 的时候,并不会重新构建中间件的对象,而是他们早在项目初始化的时候就已经存在于一个地方,一个叫做 Rails.application 的地方。

当然,这个调查过程中展现的其他信息或许还有别的用,希望大家以后能够遇到。


微信公众号:刘思宁