「rails敏捷开发」 rails启动过程调研

the startup process of rails

Posted by Mingyu on November 30, 2022

目前关于rails的启动过程中的调研博客,大多停留在rails 5版本,但是当前的rails 7版本的启动过程已经有了更新的改变,因此对这一过程进行了进一步的调研。

ruby版本:ruby 3.0.0

rails版本:rails 7.0.4

本文中的路径和包版本均为本人电脑中的版本

找到rails

首先查看rails命令的路径,执行 which rails 命令,可以得到

1
/home/zhengmingyu/.rvm/gems/ruby-3.0.0/bin/rails

打开rails文件,可以看到其中的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# The application 'railties' is installed as part of a gem, and
# this file is here to facilitate running it.
#

require 'rubygems'

version = ">= 0.a"

str = ARGV.first
if str
  str = str.b[/\A_(.*)_\z/, 1]
  if str and Gem::Version.correct?(str)
    version = str
    ARGV.shift
  end
end

if Gem.respond_to?(:activate_bin_path)
load Gem.activate_bin_path('railties', 'rails', version)
else
gem "railties", version
load Gem.bin_path("railties", "rails", version)
end

在其中加载了

/home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/railties-7.0.4/exe/rails

这一文件的内容

1
2
3
4
5
6
7
8
9
10
#!/usr/bin/env ruby
# frozen_string_literal: true

git_path = File.expand_path("../../.git", __dir__)

if File.exist?(git_path)
  railties_path = File.expand_path("../lib", __dir__)
  $:.unshift(railties_path)
end
require "rails/cli"

其中最重要的是 require "rails/cli",它位于/home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/railties-7.0.4/lib/rails/cli.rb

查看 cli.rb 中的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# frozen_string_literal: true

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) }

require "rails/command"

if ARGV.first == "plugin"
  ARGV.shift
  Rails::Command.invoke :plugin, ARGV
else
  Rails::Command.invoke :application, ARGV
end

其中,Rails::AppLoader.exec_app执行了app加载任务,在这里,rails开始从我们创建的app里面获取项目信息来准备启动。

server命令

我们可以查看exec_app函数的内容,它将 APP_PATH 设置为 config/application,然后加载 config/bootrails/commands

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def exec_app
    original_cwd = Dir.pwd

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

            if /(APP|ENGINE)_PATH/.match?(contents)
                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) && return if Pathname.new(Dir.pwd).root?

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

执行程序需要 require "rails/commands",它位于 /home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/railties-7.0.4/lib/rails/commands.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# frozen_string_literal: true

require "rails/command"

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

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

Rails::Command.invoke command, ARGV

我们可以看到在commands.rb中,我们的命令 rails s 等价于 rails server ,并调用了invoke方法,该方法位于 /home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/railties-7.0.4/lib/rails/command.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Receives a namespace, arguments, and the behavior to invoke the command.
def invoke(full_namespace, args = [], **config)
    namespace = full_namespace = full_namespace.to_s

    if char = namespace =~ /:(\w+)$/
        command_name, namespace = $1, namespace.slice(0, char)
    else
        command_name = namespace
    end

    command_name, namespace = "help", "help" if command_name.blank? || HELP_MAPPINGS.include?(command_name)
    command_name, namespace, args = "application", "application", ["--help"] if rails_new_with_no_path?(args)
    command_name, namespace = "version", "version" if %w( -v --version ).include?(command_name)

    original_argv = ARGV.dup
    ARGV.replace(args)

    command = find_by_namespace(namespace, command_name)
    if command && command.all_commands[command_name]
        command.perform(command_name, args, config)
    else
        args = ["--describe", full_namespace] if HELP_MAPPINGS.include?(args[0])
        find_by_namespace("rake").perform(full_namespace, args, config)
    end
    ensure
    ARGV.replace(original_argv)
end

在该方法中,调用了同文件下的方法 find_by_namespace ,获取了server对应的command内容,对应server_command.rb的内容,该文件位于: /home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/railties-7.0.4/lib/rails/commands/server/server_command.rb

得到command之后调用了其中的perform方法,在server_command.rb中该方法的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def perform
    extract_environment_option_from_argument
    set_application_directory!
    prepare_restart

    Rails::Server.new(server_options).tap do |server|
        # Require application after server sets environment to propagate
        # the --environment option.
        require APP_PATH
        Dir.chdir(Rails.application.root)

        if server.serveable?
            print_boot_information(server.server, server.served_url)
            after_stop_callback = -> { say "Exiting" unless options[:daemon] }
            server.start(after_stop_callback)
        else
            say rack_server_suggestion(options[:using])
        end
    end
end

回顾app

在rails官方文档中,给出了app的启动过程:

Booting process

The application is also responsible for setting up and executing the booting process. From the moment you require config/application.rb in your app, the booting process goes like this:

  1. require "config/boot.rb" to set up load paths.
  2. require railties and engines.
  3. Define Rails.application as class MyApp::Application < Rails::Application.
  4. Run config.before_configuration callbacks.
  5. Load config/environments/ENV.rb.
  6. Run config.before_initialize callbacks.
  7. Run Railtie#initializer defined by railties, engines, and application. One by one, each engine sets up its load paths and routes, and runs its config/initializers/* files.
  8. Custom Railtie#initializers added by railties, engines, and applications are executed.
  9. Build the middleware stack and run to_prepare callbacks.
  10. Run config.before_eager_load and eager_load! if eager_load is true.
  11. Run config.after_initialize callbacks.

server_command.rbperform方法中我们看到了set_application_directory将程序目录指定为包含了 config.ru 的目录,然后require APP_PATH即引入了config/application.rb,在该文件中则引入了config/boot.rb,其内容如下:

1
2
3
4
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)

require "bundler/setup" # Set up gems listed in the Gemfile.
require "bootsnap/setup" # Speed up boot time by caching expensive operations.             

在这里通过 Bundler.setup 将 Gemfile 中的 gem 路径添加到加载路径。

继续在server_command.rbperform方法中查看,调用server.start方法

1
2
3
4
5
6
7
8
9
10
def start(after_stop_callback = nil)
    trap(:INT) { exit }
    create_tmp_directories
    setup_dev_caching
    log_to_stdout if options[:log_stdout]

    super()
    ensure
    after_stop_callback.call if after_stop_callback
end

Rails::Server是继承自Rack::Server的,所以start方法最后的super()是调用Rack::Server中的start方法。该方法位于rack下的server.rb中,路径为/home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/rack-2.2.4/lib/rack/server.rb,我们可以查看该方法的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def start(&block)
    if options[:warn]
        $-w = true
    end

    if includes = options[:include]
        $LOAD_PATH.unshift(*includes)
    end

    Array(options[:require]).each do |library|
        require library
    end

    if options[:debug]
        $DEBUG = true
        require 'pp'
        p options[:server]
        pp wrapped_app
        pp app
    end

    check_pid! if options[:pid]
    
    # Touch the wrapped app, so that the config.ru is loaded before
    # daemonization (i.e. before chdir, etc).
    handle_profiling(options[:heapfile], options[:profile_mode], options[:profile_file]) do
        wrapped_app
    end

    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, &block)
end

其中最重要的是获取wrapped_app,并在最后调用server.run(wrapped_app, **options, &block)

wrapped_app中,执行了我们app的config.ruconfig.ru内容如下:

1
2
3
4
5
6
# This file is used by Rack-based servers to start the application.

require_relative "config/environment"

run Rails.application
Rails.application.load_server

至此,app已经创建完成。

选择服务器

在上述start方法中的最后将app实例通过server.run方法进行调用,server 方法通过 Rack::Handler 选择服务器,位于/home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/rack-2.2.4/lib/rack/handler.rb,有关内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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]
        const_get(klass)
    else
        const_get(server, false)
    end

rescue NameError => name_error
    raise load_error || name_error
end

# Select first available Rack handler given an `Array` of server names.
# Raises `LoadError` if no handler was found.
#
#   > pick ['thin', 'webrick']
#   => Rack::Handler::WEBrick
def self.pick(server_names)
    server_names = Array(server_names)
    server_names.each do |server_name|
        begin
            return get(server_name.to_s)
        rescue LoadError, NameError
        end
    end

    raise LoadError, "Couldn't find handler for: #{server_names.join(', ')}."
end

SERVER_NAMES = %w(puma thin falcon webrick).freeze
private_constant :SERVER_NAMES

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 SERVER_NAMES
    end
end

在该default方法中,如果ENV中没有设置,那么调用pick方法,按照 puma thin falcon webrick 的顺序选择,应用get方法进行选择,查看@handlers 中是否有匹配的,如没有则尝试通过 try_require('rack/handler', server) 加载。

当我们加载puma成功之后,返回到server.run方法,它实际上是执行了 Rack::Handler::Puma.run,我们可以在gems中的puma包下找到,路径为 /home/zhengmingyu/.rvm/gems/ruby-3.0.0/gems/puma-5.6.5/lib/rack/handler/puma.rb,对应方法的调用过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def self.run(app, **options)
    conf   = self.config(app, options)

    events = options.delete(:Silent) ? ::Puma::Events.strings : ::Puma::Events.stdio

    launcher = ::Puma::Launcher.new(conf, :events => events)

    yield launcher if block_given?
    begin
        launcher.run
    rescue Interrupt
        puts "* Gracefully stopping, waiting for requests to finish"
        launcher.stop
        puts "* Goodbye!"
    end
end

至此,服务启动完成。