rails新建项目时,已经进行过binstub。这时输入rails c,即调用了spring,会打印出“Running via Spring preloader in process”的字样。可凭这些日志信息,到源码中查找它的调用过程。发现日志由以下方法输出,于是加入pp caller,打印出它是怎样被调用的

# spring-2.0.2/lib/spring/application.rb
def serve(client)
  # ...

  pid = fork {
    pp caller
    STDERR.puts "Running via Spring preloader in process #{Process.pid}" unless Spring.quiet

    # ...
  }

  disconnect_database

  log "forked #{pid}"
  manager.puts pid

  wait pid, streams, client
end

再次输入spring stop; rails c,得调用栈如下

➜  rays git:(master) ✗ rails c
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/pry-rails-0.3.6/lib/pry-rails/prompt.rb:36: warning: constant Pry::Prompt::MAP is deprecated
["/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/railties-5.2.1/lib/rails/railtie.rb:127:in `config'",
"/home/z/test_ruby/rays/config/application.rb:12:in `'",
"/home/z/test_ruby/rays/config/application.rb:10:in `'",
"/home/z/test_ruby/rays/config/application.rb:9:in `<top (required)="">'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application.rb:92:in `require'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application.rb:92:in `preload'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application.rb:153:in `serve'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application.rb:141:in `block in run'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application.rb:135:in `loop'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application.rb:135:in `run'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application/boot.rb:19:in `<top (required)="">'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'",
"/home/z/.rbenv/versions/2.5.1/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'",
"-e:1:in `</top></top>
'"] Running via Spring preloader in process 4152 Loading development environment (Rails 5.2.1) [1] pry(main)>

其调用可追溯至以下文件

# spring-2.0.2/lib/spring/application/boot.rb
Process.setsid

require "spring/application"

app = Spring::Application.new(
  UNIXSocket.for_fd(3),
  Spring::JSON.load(ENV.delete("SPRING_ORIGINAL_ENV").dup),
  Spring::Env.new(log_file: IO.for_fd(4))
)

Signal.trap("TERM") { app.terminate }

Spring::ProcessTitleUpdater.run { |distance|
  "spring app    | #{app.app_name} | started #{distance} ago | #{app.app_env} mode"
}

app.eager_preload if ENV.delete("SPRING_PRELOAD") == "1"
app.run

然后app.run会等待manager和@interrupt,即上面的UNIXSocket.for_fd(3),以及IO.pipe的读端,一旦可读,则检查状态,执行exit或serve

# spring-2.0.2/lib/spring/application.rb
def run
  state :running
  manager.puts

  loop do
    IO.select [manager, @interrupt.first]

    if terminating? || watcher_stale? || preload_failed?
      exit
    else
      serve manager.recv_io(UNIXSocket)
    end
  end
end

那么UNIXSocket.for_fd(3)除了loop之前写了一下,平时是如何写入,以作进程间通信的呢?在源码里搜索数字3,得:

# spring-2.0.2/lib/spring/application_manager.rb
module Spring
  class ApplicationManager

    def start
      start_child
    end

    private

    def start_child(preload = false)
      @child, child_socket = UNIXSocket.pair

      Bundler.with_clean_env do
        @pid = Process.spawn(
          {
            "RAILS_ENV"           => app_env,
            "RACK_ENV"            => app_env,
            "SPRING_ORIGINAL_ENV" => JSON.dump(Spring::ORIGINAL_ENV),
            "SPRING_PRELOAD"      => preload ? "1" : "0"
          },
          "ruby",
          "-I", File.expand_path("../..", $LOADED_FEATURES.grep(/bundler\/setup\.rb$/).first),
          "-I", File.expand_path("../..", __FILE__),
          "-e", "require 'spring/application/boot'",
          3 => child_socket,
          4 => spring_env.log_file,
        )
      end

      start_wait_thread(pid, child) if child.gets
      child_socket.close
    end

    # ....

  end
end

这里Spring::ApplicationManager在start之后,会启动一个子进程,让子进程的文件描述符3指向自己建立的一对UNIXSocket的其中一端,并且之后在自己这个父进程中关闭该端,只保留从@child发信到子进程manager的功能

那么start_child又是从何调用的呢?

于是加入pp caller,再rails c。但无任何输出,考虑到这里这里代码所处的进程可能不是最终rails c所看到的进程,又或是其文件描述符被重定向过,只好将调用栈输出到文件

# spring-2.0.2/lib/spring/application_manager.rb
def start_child(preload = false)
  File.open('/tmp/spring_manager_start_child', 'w+') do |f|
    f.puts caller
    f.puts '------------'
  end

  @child, child_socket = UNIXSocket.pair
  # ...
end

重新spring stop; rails c,在检查日志文件,可见调用栈如下:

➜  ~ cat /tmp/spring_manager_start_child
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application_manager.rb:93:in `open'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application_manager.rb:93:in `start_child'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application_manager.rb:26:in `start'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application_manager.rb:52:in `block in with_child'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application_manager.rb:20:in `synchronize'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application_manager.rb:39:in `with_child'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/application_manager.rb:60:in `run'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/server.rb:65:in `serve'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/server.rb:49:in `block in start_server'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/server.rb:49:in `loop'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/server.rb:49:in `start_server'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/server.rb:43:in `boot'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/server.rb:14:in `boot'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/server.rb:10:in `call'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/command.rb:7:in `call'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client.rb:30:in `run'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/bin/spring:49:in `
'

再看回执行rails c的步骤:

因文件bin/rails被binstub过,所以会在执行rails命令前,先加载bin/spring

#!/usr/bin/env ruby
begin
  load File.expand_path('../spring', __FILE__)
rescue LoadError => e
  raise unless e.message.include?('spring')
end
APP_PATH = File.expand_path('../config/application', __dir__)
require_relative '../config/boot'
require 'rails/commands'

而bin/spring会运行spring这个gem的spring/binstub

#!/usr/bin/env ruby
unless defined?(Spring)
  require 'rubygems'
  require 'bundler'

  lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read)
  spring = lockfile.specs.detect { |spec| spec.name == "spring" }
  if spring
    Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
    gem 'spring', spring.version
    require 'spring/binstub'
  end
end

来到spring/binstub,它会执行spring这个gem的bin/spring文件。而如果是rails命令,还会先将rails压到ARGV的前端

# lib/spring/binstub.rb
command  = File.basename($0)
bin_path = File.expand_path("../../../bin/spring", __FILE__)

# ...

if command == "spring"
  load bin_path
else
  disable = ENV["DISABLE_SPRING"]

  if Process.respond_to?(:fork) && (disable.nil? || disable.empty? || disable == "0")
    ARGV.unshift(command)
    load bin_path
  end
end

而bin/spring是这样的,将ARGV传给Spring::Client.run

lib = File.expand_path("../../lib", __FILE__)
$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) # enable local development
require 'spring/client'
Spring::Client.run(ARGV)

于是便接上了上面ApplicationManager#run -> ApplicationManager#start_child的调用栈。调用栈中涉及的server代码如下

# spring-2.0.2/lib/spring/server.rb
module Spring
  class Server
    def self.boot(options = {})
      new(options).boot
    end

    attr_reader :env

    def initialize(options = {})
      @foreground   = options.fetch(:foreground, false)
      @env          = options[:env] || default_env
      @applications = Hash.new { |h, k| h[k] = ApplicationManager.new(k, env) }
      @pidfile      = env.pidfile_path.open('a')
      @mutex        = Mutex.new
    end

    def boot
      Spring.verify_environment

      write_pidfile
      set_pgid unless foreground?
      ignore_signals unless foreground?
      set_exit_hook
      set_process_title
      start_server
    end

    def start_server
      server = UNIXServer.open(env.socket_name)
      log "started on #{env.socket_name}"
      loop { serve server.accept }
    rescue Interrupt
    end

    def serve(client)
      log "accepted client"
      client.puts env.version

      app_client = client.recv_io
      command    = JSON.load(client.read(client.gets.to_i))

      args, default_rails_env = command.values_at('args', 'default_rails_env')

      if Spring.command?(args.first)
        log "running command #{args.first}"
        client.puts
        client.puts @applications[rails_env_for(args, default_rails_env)].run(app_client)
      else
        log "command not found #{args.first}"
        client.close
      end
    rescue SocketError => e
      raise e unless client.eof?
    ensure
      redirect_output
    end

这里可以看到,server其实是UNIXServer.open(env.socket_name),于是搜下socket_name,看还有哪里会用它来建立管道,得:

module Spring
  module Client
    class Run < Command
      attr_reader :server

      def connect
        File.open('/tmp/spring_client_run_connect', 'w+') do |f|
          f.puts caller
          f.puts '-----------------'
        end
        @server = UNIXSocket.open(env.socket_name)
      end

      def call
        begin
          connect
        rescue Errno::ENOENT, Errno::ECONNRESET, Errno::ECONNREFUSED
          cold_run
        else
          warm_run
        end
      ensure
        server.close if server
      end

在上面代码加入调用栈打印后,再运行spring stop;rails c,得:

➜  ~ cat /tmp/spring_client_run_connect
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/run.rb:26:in `open'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/run.rb:26:in `connect'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/run.rb:61:in `cold_run'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/run.rb:37:in `rescue in call'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/run.rb:34:in `call'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/command.rb:7:in `call'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/rails.rb:24:in `call'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client/command.rb:7:in `call'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/client.rb:30:in `run'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/bin/spring:49:in `<top (required)="">'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/binstub.rb:31:in `load'
/home/z/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/spring-2.0.2/lib/spring/binstub.rb:31:in `<top (required)="">'
/home/z/.rbenv/versions/2.5.1/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:70:in `require'
/home/z/.rbenv/versions/2.5.1/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:70:in `require'
/home/z/test_ruby/rays/bin/spring:15:in `<top (required)="">'
bin/rails:3:in `load'
bin/rails:3:in `</top></top></top>
'

但因为刚才有spring stop,所以这次进入了cold_run。那么它如何分辨cold_run还是warm_run呢?其实就是作为server的UNIXSocket.open(env.socket_name)文件并未建立。于是,如下面源码所述,它会进入boot_server方法,里面会另起一个进程,执行shell命令spring server(env.server_command)

# spring-2.0.2/lib/spring/client/run.rb
def connect
  @server = UNIXSocket.open(env.socket_name)
end

def call
  begin
    connect
  rescue Errno::ENOENT, Errno::ECONNRESET, Errno::ECONNREFUSED
    cold_run
  else
    warm_run
  end
ensure
  server.close if server
end

def cold_run
  boot_server
  connect
  run
end

def boot_server
  env.socket_path.unlink if env.socket_path.exist?

  pid     = Process.spawn(gem_env, env.server_command, out: File::NULL)
  timeout = Time.now + BOOT_TIMEOUT

  @server_booted = true

  until env.socket_path.exist?
    _, status = Process.waitpid2(pid, Process::WNOHANG)

    if status
      exit status.exitstatus
    elsif Time.now > timeout
      $stderr.puts "Starting Spring server with `#{env.server_command}` " \
                   "timed out after #{BOOT_TIMEOUT} seconds"
      exit 1
    end

    sleep 0.1
  end
end

而根据lib/spring/client.rb里的命令匹配,shell命令spring server对应的就是Spring::Client::Server,其执行的就是Spring::Server.boot,于是这就又接回了上面ApplicationManager#run -> ApplicationManager#start_child的调用栈。

至此,所有进程间通讯都可串联起来了,图形如下: