rails s 启动过程分析

学习 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 的神秘感减少了几分吧。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容