Rails rails s 启动过程分析

zkai · 2017年03月17日 · 最后由 yfractal 回复于 2017年09月29日 · 6947 次阅读
本帖已被管理员设置为精华贴

学习 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 将本帖设为了精华贴。 03月17日 21:29

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

wushexu 回复

puma 文档里有的

辛苦辛苦,继续钻研

jasl 回复

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

wushexu 回复

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

jasl 回复

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

需要 登录 后方可回复, 如果你还没有账号请 注册新账号