Rails rails s 启动过程分析

zkai · March 17, 2017 · Last by yfractal replied at September 29, 2017 · 6893 hits
Topic has been selected as the excellent topic by the admin.

学习 ruby on rails 有一段时间了,也写过一些简单的程序。但对 rails 一直充满神秘感,为什么我们把代码填充到 controller、view、model 里,再执行一下 rails s,就能得到想要的结果。rails 背后为我们隐藏了多少东西,如果一点都不清楚,这样写代码不是像在搭建空中阁楼吗?心里总觉得不牢靠。感觉要想进一步提高水平,我得看一下源代码,至少可以满足以下心里的好奇心。以前学些程序的时候,比如 C/C++ 什么的,都有一个程序入口点 main 函数。在 rails 里,rails s 貌似是一切开始的地方,于是就从这里开始吧,看看它都干了什么。 在看 railties 源码时,发现 caller_locations 方法可以显示调用栈,于是想用它来跟踪了一下 rails s 的执行过程,虽然它并不能显示所有调用的方法,但是能大致知道程序经过了哪些文件,调用了哪些方法。我在 config/environment.rb 文件的末尾加上 caller_locations.each { |call| puts call.to_s } 一行,然后执行 rails s,把输出的结果作为模糊的地图开始了 rails s 之旅。

环境说明 ruby 2.4.0, Rails 5.0.2

ruby gems 的位置 cd \gem environment gemdir rails`/gems`

rails 命令在这里 ~/.rvm/gems/ruby-2.4.0/bin/rails

load Gem.activate_bin_path('railties', 'rails', version)

Gem.activate_bin_path 的主要作用是找到 gem 包下的可执行文件,即 bin 或 exe 目录下的文件。这里找到的是 /.rvm/gems/ruby-2.4.0/gems railties-5.0.2/exe/rails,这个文件的主要作用是加载 require "rails/cli",该文件位于 .rvm/gems/ruby-2.4.0/gems railties-5.0.2/lib/rails/cli.rb

require 'rails/app_loader'

# If we are inside a Rails application this method performs an exec and thus
# the rest of this script is not run.
Rails::AppLoader.exec_app

require 'rails/ruby_version_check'
Signal.trap("INT") { puts; exit(1) }

if ARGV.first == 'plugin'
  ARGV.shift
  require 'rails/commands/plugin'
else
  require 'rails/commands/application'
end

如果已新建了 rails app 并在应用程序目录下,则加载 AppLoader 模块并执行 exec_app 方法启动应用,否则进入新建 app 流程。

Rails::AppLoader.exec_app 方法用来找到应用程序目录下的 bin/rails 文件并执行。该方法通过一个 loop 循环,从当前目录逐级向上查找 bin/rails ,所以即使在应用程序的子目录里也是可以执行 rails 命令的。如果找到,并且文件中设置了 APP_PATH,则执行该文件。 .rvm/gems/ruby-2.4.0/gems railties-5.0.2/lib/rails/app_loader.rb

def exec_app
  original_cwd = Dir.pwd

  loop do
    if exe = find_executable
      contents = File.read(exe)

      if contents =~ /(APP|ENGINE)_PATH/
        exec RUBY, exe, *ARGV
        break # non reachable, hack to be able to stub exec in the test suite
      elsif exe.end_with?('bin/rails') && contents.include?('This file was generated by Bundler')
        $stderr.puts(BUNDLER_WARNING)
        Object.const_set(:APP_PATH, File.expand_path('config/application', Dir.pwd))
        require File.expand_path('../boot', APP_PATH)
        require 'rails/commands'
        break
      end
    end

    # If we exhaust the search there is no executable, this could be a
    # call to generate a new application, so restore the original cwd.
    Dir.chdir(original_cwd) and return if Pathname.new(Dir.pwd).root?

    # Otherwise keep moving upwards in search of an executable.
    Dir.chdir('..')
  end
end

bin/rails 文件将 APP_PATH 设置为 config/application,然后加载 config/bootrails/commands

#!/usr/bin/env ruby
APP_PATH = File.expand_path('../config/application', __dir__)
require_relative '../config/boot'
require 'rails/commands'

config/boot 文件,主要作用是通过 Bundler.setup 将 Gemfile 中的 gem 路径添加到加载路径,以便后续 require。

ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)

require 'bundler/setup' # Set up gems listed in the Gemfile.

.rvm/gems/ruby-2.4.0/gems/railties-5.0.2/lib/rails/commands.rb,这里解析 rails 命令参数,根据不同的参数执行不同的任务。

ARGV << '--help' if ARGV.empty?

aliases = {
  "g"  => "generate",
  "d"  => "destroy",
  "c"  => "console",
  "s"  => "server",
  "db" => "dbconsole",
  "r"  => "runner",
  "t"  => "test"
}

command = ARGV.shift
command = aliases[command] || command

require 'rails/commands/commands_tasks'

Rails::CommandsTasks.new(ARGV).run_command!(command)

因为这里我们的命令参数是 s(server),所以 run_command! 函数调用了 server 方法 .rvm/gems/ruby-2.4.0/gems/railties-5.0.2/lib/rails/commands/commands_tasks.rb

def server
  set_application_directory!
  require_command!("server")

  Rails::Server.new.tap do |server|
    # We need to require application after the server sets environment,
    # otherwise the --environment option given to the server won't propagate.
    require APP_PATH
    Dir.chdir(Rails.application.root)
    server.start
  end
end

server 方法首先设置应用程序目录,即包含 config.ru 文件的目录。然后加载 Rails::Server 模块。然后加载 APP_PATH,即 config/application.rb 文件,该文件加载了 rails 的全部组件 require "rails/all",并定义了我们自己的 rails 应用程序类,如 class Application < Rails::Application,然后启动服务器。

.rvm/gems/ruby-2.4.0/gems/railties-5.0.2/lib/rails/commands/server.rb

def start
  print_boot_information
  trap(:INT) { exit }
  create_tmp_directories
  setup_dev_caching
  log_to_stdout if options[:log_stdout]

  super
ensure
  # The '-h' option calls exit before @options is set.
  # If we call 'options' with it unset, we get double help banners.
  puts 'Exiting' unless @options && options[:daemonize]
end

start 方法做了一些准备和处理,直接调用 super 进入父类的 start 方法。Rails::Server 继承自 Rack::Server。

def start &blk
  ....

  check_pid! if options[:pid]

  # Touch the wrapped app, so that the config.ru is loaded before
  # daemonization (i.e. before chdir, etc).
  wrapped_app

  daemonize_app if options[:daemonize]

  write_pid if options[:pid]

  trap(:INT) do
    if server.respond_to?(:shutdown)
      server.shutdown
    else
      exit
    end
  end

  server.run wrapped_app, options, &blk
end

在这里通过 Rack 分别通过两个模块 Rack::Builder Rack::Handler 创建 app 和选择服务器。wrapped_app 方法通过调用 app 方法创建应用程序实例,并调用 build_app 方法加载所有中间件。在 default_middleware_by_environment 方法里可以看到默认的中间件。 接着调用 server 方法选择 server,然后调用 server.run 启动服务器。

.rvm/gems/ruby-2.4.0/gems/rack-2.0.1/lib/rack/server.rb

def wrapped_app
  @wrapped_app ||= build_app app
end

def build_app(app)
  middleware[options[:environment]].reverse_each do |middleware|
    middleware = middleware.call(self) if middleware.respond_to?(:call)
    next unless middleware
    klass, *args = middleware
    app = klass.new(app, *args)
  end
  app
end

def app
  @app ||= options[:builder] ? build_app_from_string : build_app_and_options_from_config
end

由于在创建 Rails::Server 实例时没有传递参数,所以初始化是调用 default_options 方法设置了 options。其中有一项 optioins[:config] 被设置为 "config.ru",这决定了 app 的创建方式是根据该配置文件创建。

def build_app_and_options_from_config
  if !::File.exist? options[:config]
    abort "configuration #{options[:config]} not found"
  end

  app, options = Rack::Builder.parse_file(self.options[:config], opt_parser)
  @options.merge!(options) { |key, old, new| old }
  app
end

具体的创建是通过调用 Rack::Builder.parse_file 方法实现。该方法首先解析 config.ru 文件,然后实例化 Rack::Builder 对象,然后在实例上下文中执行 config.ru 文件中的代码,然后调用 to_app 方法返回 app 对象。

def self.parse_file(config, opts = Server::Options.new)
  options = {}
  if config =~ /\.ru$/
    cfgfile = ::File.read(config)
    if cfgfile[/^#\\(.*)/] && opts
      options = opts.parse! $1.split(/\s+/)
    end
    cfgfile.sub!(/^__END__\n.*\Z/m, '')
    app = new_from_string cfgfile, config
  else
    require config
    app = Object.const_get(::File.basename(config, '.rb').split('_').map(&:capitalize).join(''))
  end
  return app, options
end

def self.new_from_string(builder_script, file="(rackup)")
  eval "Rack::Builder.new {\n" + builder_script + "\n}.to_app",
    TOPLEVEL_BINDING, file, 0
end

def initialize(default_app = nil, &block)
  @use, @map, @run, @warmup = [], nil, default_app, nil
  instance_eval(&block) if block_given?
end

config.ru 文件中的代码是在 Rack::Builder 的实例上下文中执行的,所以 run Rails.application 实际上是执行 Rack::Builder#run。它只是简单的把 Rails.application,即我们自己的 Rails 应用程序实例赋值给 Rack::Server@app。在此之前它先加载了 config/environment.rb 文件初始化应用 Rails.application.initialize!,而后者又加载了 config/application.rb,该文件中加载了 rails 的全部组件 require 'rails/all' 和定义了我们自己 Application 类 class Application < Rails::Application

def run(app)
  @run = app
end

def to_app
  app = @map ? generate_map(@run, @map) : @run
  fail "missing run or map statement" unless app
  app = @use.reverse.inject(app) { |a,e| e[a] }
  @warmup.call(app) if @warmup
  app
end

以下是 config.ru 文件代码
# This file is used by Rack-based servers to start the application.

require_relative 'config/environment'

# run is a method of Rack::Handler that used for setting up rack server.
run Rails.application

以下是 config/environment.rb 文件代码
# Load the Rails application.
require_relative 'application'

# Initialize the Rails application.
Rails.application.initialize!

到此 Rack app 对象已经建立,Rails.application 实例被保存在 Rack::Server 实例的 @app 里。接着调用 server.run ,并将 app 作为参数传递给它。

server 方法通过 Rack::Handler 选择服务器。由于我们创建 server 实例时没有提供 options,默认的 options[:server] 不存在。所以,通过 Rack::Handler.default 方法进行猜选,先查看 ENV 环境变量里有没有设置,如果没有则按照 ['puma', 'thin', 'webrick'] 顺序猜选。通过调用 Rack::Handler.get 方法获取 server 类常量。首先查看@handlers 中是否有匹配的,如没有则尝试通过 try_require('rack/handler', server) 加载。比如对于 puma,该方法的加载路径相当于 rack/handler/puma。可能你会想这个路径哪里来的,从 RAILS 5 开始,Gemfile 文件里有一行 gem 'puma', '~> 3.0',然后启动应用程序时,在 config/boot.rb 文件里已经通过 Bundler.setup 加载了所有依赖的路径。最终 get 方法会返回 Rack::Handler::Puma 这样一个类。

def server
  @_server ||= Rack::Handler.get(options[:server])

  unless @_server
    @_server = Rack::Handler.default

    # We already speak FastCGI
    @ignore_options = [:File, :Port] if @_server.to_s == 'Rack::Handler::FastCGI'
  end

  @_server
end

def self.default
  # Guess.
  if ENV.include?("PHP_FCGI_CHILDREN")
    Rack::Handler::FastCGI
  elsif ENV.include?(REQUEST_METHOD)
    Rack::Handler::CGI
  elsif ENV.include?("RACK_HANDLER")
    self.get(ENV["RACK_HANDLER"])
  else
    pick ['puma', 'thin', 'webrick']
  end
end

def self.get(server)
  return unless server
  server = server.to_s

  unless @handlers.include? server
    load_error = try_require('rack/handler', server)
  end

  if klass = @handlers[server]
    klass.split("::").inject(Object) { |o, x| o.const_get(x) }
  else
    const_get(server, false)
  end

rescue NameError => name_error
  raise load_error || name_error
end

Rack::Server#start 方法里调用的 server.run wrapped_app, options, &blk 实际上是执行了 Rack::Handler::Puma.run 。该方法在.rvm/gems/ruby-2.4.0/gems/puma-3.7.1/lib/rack/handler/puma.rb文件里。到这里暂且不用往下挖了,可以想象 Puma 服务器进程启动起来,一切就绪,进入服务器端口监听循环,随时等待接收客户端发来的请求,或者直到收到系统中断信号,关闭服务器,回到最开始的位置。还记得 rails s 吗?这时它正微笑着和你打招呼“嘿,骚年我以为你迷路了呢?”

小结

从敲下 rails s 命令开始,到服务器启动起来,其实用很短的话就可以概括:rails 命令行解析,创建 Rails::Server 对象,加载 Rails.application,启动服务器监听用户请求。绕了这么一个大圈子,似乎懂了点什么,又好像没懂什么,至少对 Rails 的神秘感减少了几分吧。

代码高亮可以指定 ruby 语言

puts 'hello world'

谢谢,改了。

jasl mark as excellent topic. 17 Mar 21:29

前几天想要给 server 加个 startup hook,研究了一番。

Reply to wushexu

puma 文档里有的

辛苦辛苦,继续钻研

Reply to jasl

这还真没注意。 我开始对 rails 加 hook,rails s 启动可以,但发现 rackup 启动的话没有 hook 到。我最终对 rack 加。这样也更通用一点。

Reply to wushexu

看你干什么了,已经执行到 rack 层就很靠后了,比较普遍的使用 hook 的需求是类似重载 Gemfile、强制 AR 打开、关闭连接等操作,也都是通用需求(涉及热重启还有 CoW 优化),但必须在 Web 容器这层来做

Reply to jasl

的确。我的需求比较简单。

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