カレーの恩返し

おいしいのでオススメ。

`rails new my_app`を解読してみた

Railsアプリを作成するときに毎回使う rails new my_appで何が起きているか知りたかったのでソースを読み解いてみました。

初学者なので解説が間違っている, 解説の粒度がバラバラで読みにくい可能性があります。
何かありましたらコメントください。
コードは時系列で見ていくのでファイルを行ったり来たりしています。

設定

  • .railsrcは作成していない
  • 開発用PCはmac

概要

(1) bundle install railsRailsをインストールする
(2) bundle exec rails new my_appがユーザーによって実行される。
(3) Rails::AppRailsLoader#exec_app_railsが実行される。
(4) Rails::CommandsTasks#newを実行する。
(5) Rails::Generators::AppGenerator#startを実行する。
(6)Rails::Generators::AppGenerator#invoke_allを実行する。
(7) Thor::Command#runを実行する。
(8) Rails::Generators::AppBase#buildを実行する。

コマンドの実行

まずはユーザーがbundle install railsRailsをインストールする。

次にbundle exec rails new my_appを実行する。
bundlerがどんな挙動をしてるのかよく分からないけど今回はrails newの解読なのでとりあえず放置。

railsコマンドはrailties/bin/railsにあった。

#railties/bin/rails

#!/usr/bin/env ruby

git_path = File.expand_path('../../../.git', __FILE__)

if File.exist?(git_path)
  railties_path = File.expand_path('../../lib', __FILE__)
  $:.unshift(railties_path)
end
require "rails/cli"
  • $:$LOAD_PATHのショートカットらしい。
  • railties/lib$LOAD_PATHに追加してrails/cliをrequireしている。

 

require "rails/cli"を追いかける。

#railties/lib/rails/cli.rb

require 'rails/app_rails_loader'

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

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
  • #exec_app_railsの内部でexecが実行されるため実行に成功すると処理が戻ってこないため以降は実行されない。

 

Rails::AppRailsLoader.exec_app_railsを追いかける。

require 'pathname'

module Rails
  module AppRailsLoader # :nodoc:
    extend self

    RUBY = Gem.ruby
    EXECUTABLES = ['bin/rails', 'script/rails']
    BUNDLER_WARNING = <<EOS
Looks like your app's ./bin/rails is a stub that was generated by Bundler.

In Rails 4, your app's bin/ directory contains executables that are versioned
like any other source code, rather than stubs that are generated on demand.

Here's how to upgrade:

  bundle config --delete bin    # Turn off Bundler's stub generator
  rake rails:update:bin         # Use the new Rails 4 executables
  git add bin                   # Add bin/ to source control

You may need to remove bin/ from your .gitignore as well.

When you install a gem whose executable you want to use in your app,
generate it and add it to source control:

  bundle binstubs some-gem-name
  git add bin/new-executable

EOS

    def exec_app_rails
      original_cwd = Dir.pwd
      # original_cwd => カレントディレクトリの絶対パス

      loop do
        if exe = find_executable
          # exe => 'bin/rails'
          contents = File.read(exe)
          # contents =>
          #   APP_PATH = File.expand_path('../../config/application', __FILE__)
          #   require_relative '../config/boot'
          #   require 'rails/commands'

          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

    def find_executable
      EXECUTABLES.find { |exe| File.file?(exe) }
    end
  end
end
  • contentsにはrailties/lib/rails/generators/rails/app/templates/bin/railsのソースが読み込まれている。
  • #exec_app_railsではrailties/lib/rails/generators/rails/app/templates/bin/railsARGVを引数として実行している。

 

exec RUBY, exe, *ARGVを追いかける。
exec RUBY, exe, *ARGV => % ruby railties/lib/rails/generators/rails/app/templates/bin/rails new my_app

#railties/lib/rails/generators/rails/app/templates/bin/rails

APP_PATH = File.expand_path('../../config/application', __FILE__)
require_relative '../config/boot'
require 'rails/commands'
  • ../config/bootrails/commandsをrequireしている。

 

require_relative '../config/boot'を追いかける。

#railties/lib/rails/generators/rails/app/templates/config/boot.rb

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

require 'bundler/setup' # Set up gems listed in the Gemfile.
  • デフォルトでGemfileに記述されているrubygemをrequireしている。

 

require 'rails/commands'を追いかける。

#railties/lib/rails/commands.rb

# ARGV => ['new', 'my_app']
ARGV << '--help' if ARGV.empty?  

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

command = ARGV.shift
command = aliases[command] || command
# command => 'new'

require 'rails/commands/commands_tasks'

Rails::CommandsTasks.new(ARGV).run_command!(command)
  • railsコマンドの引数を渡して生成したRails::CommandsTasksインスタンス'new'を引数にrun_command!メソッドを実行している。
  • 引数を与えなかったらhelpが表示される理由が分かった。

 

Rails::CommandsTasks.new(ARGV).run_command!(command)を追いかける。

#railties/lib/rails/commands/commands_tasks.rb

module Rails
  class CommandsTasks # :nodoc:
    attr_reader :argv

    # ...   

    COMMAND_WHITELIST = %w(plugin generate destroy console server dbconsole runner new version help)

    def initialize(argv)
      @argv = argv
    end

    def run_command!(command)
      command = parse_command(command)
      # command => 'new'
      if COMMAND_WHITELIST.include?(command)
        send(command)
      else
        write_error_message(command)
      end
    end
    
    # ...

    def new
      # argv.first => 'new'
      if %w(-h --help).include?(argv.first)
        require_command!("application")
      else
        exit_with_initialization_warning!
      end
    end

    private

      # ...

      def require_command!(command)
        require "rails/commands/#{command}"
      end

      # ...

      def parse_command(command)
        case command
        when '--version', '-v'
          'version'
        when '--help', '-h'
          'help'
        else
          command
        end
      end
end
  • #run_command!ではcommand#parse_commandを通した後COMMAND_WHITELISTに存在するか確かめて#newを実行している。
  • #newでは'new','-h''--help'がに含まれていないので#exit_with_initialization_warning!が実行されるのかと思ったけど#require_command!が実行されている。分からない。
  • #require_command!ではrails/commands/applicationをrequireしている。

 

require "rails/commands/#{command}"を追いかける。

#railties/lib/rails/commands/application.rb

require 'rails/generators'
require 'rails/generators/rails/app/app_generator'

module Rails
  module Generators
    class AppGenerator # :nodoc:
      # We want to exit on failure to be kind to other libraries
      # This is only when accessing via CLI
      def self.exit_on_failure?
        true
      end
    end
  end
end

# ARGV => ['new', 'my_app']
args = Rails::Generators::ARGVScrubber.new(ARGV).prepare!
# args => ['my_app']
Rails::Generators::AppGenerator.start args
  • ARGVについている各オプションを元にargsに整形する。今回はオプションをつけていないのであまり関係ない。
  • argsを渡してRailsの雛形ファイル群を生成すると思われる(コードが追えなくなったので確証はない)

 

args = Rails::Generators::ARGVScrubber.new(ARGV).prepare!を追いかける。

#railties/lib/rails/generater/rails/app/app_generator.rb

require 'rails/generators/app_base'

module Rails
  # ...

  module Generators
    # We need to store the RAILS_DEV_PATH in a constant, otherwise the path
    # can change in Ruby 1.8.7 when we FileUtils.cd.
    RAILS_DEV_PATH = File.expand_path("../../../../../..", File.dirname(__FILE__))
    RESERVED_NAMES = %w[application destroy plugin runner test]

    # ...

    class ARGVScrubber # :nodoc:
      def initialize(argv = ARGV)
        @argv = argv
        # @argv => ['new', 'my_app']
      end

      def prepare!
        # @argv => ['new', 'my_app']
        handle_version_request!(@argv.first)
        handle_invalid_command!(@argv.first, @argv) do
          handle_rails_rc!(@argv.drop(1))
        end
      end

      def self.default_rc_file
        File.expand_path('~/.railsrc')
      end

      private

        def handle_version_request!(argument)
          # argument => 'new'
          if ['--version', '-v'].include?(argument)
            require 'rails/version'
            puts "Rails #{Rails::VERSION::STRING}"
            exit(0)
          end
        end

        def handle_invalid_command!(argument, argv)
          # argument => 'new'
          # argv => ['new', 'my_app']
          if argument == "new"
            yield
          else
            ['--help'] + argv.drop(1)
          end
        end

        def handle_rails_rc!(argv)
          # argv => ['my_app']
          if argv.find { |arg| arg == '--no-rc' }
            argv.reject { |arg| arg == '--no-rc' }
          else
            railsrc(argv) { |rc_argv, rc| insert_railsrc_into_argv!(rc_argv, rc) }
          end
        end

        def railsrc(argv)
          #argv => ['my_app']
          if (customrc = argv.index{ |x| x.include?("--rc=") })
            fname = File.expand_path(argv[customrc].gsub(/--rc=/, ""))
            yield(argv.take(customrc) + argv.drop(customrc + 1), fname)
          else
            yield argv, self.class.default_rc_file
          end
        end

        # ...

        def insert_railsrc_into_argv!(argv, railsrc)
          # argv => ['my_app']
          # railsrc => '~/.railsrcの絶対パス'
          return argv unless File.exist?(railsrc)
          extra_args = read_rc_file railsrc
          argv.take(1) + extra_args + argv.drop(1)
        end
    end
  end
end
  • #handle_version_request!ではargument'--version','-v'が含まれていないので関係ない。
  • #handle_invalid_command!ではargument'new'なのでブロックにそのままargument, argvを渡す。
  • #handle_rails_rc!ではargv'--no--rc'が含まれていないので#railsrcを実行する。
  • #railsrcではargvの各要素に'--rc='が含まれていないのでブロックにargv, self.class.default_rc_fileを渡す。
  • #self.default_rc_fileでは~/.railsrc絶対パスを取得する。なんでこのメソッドはprivateメソッドじゃなくて特異メソッドなんだろう。
  • #insert_railsrc_into_argv!では~/.railsrcが存在しないのでargvを返す。

 

Rails::Generators::AppGenerator#startを探す旅が始まる。
Rails::Generators::AppGenerator.start argsを追いかける。

#railties/lib/rails/generators/rails/app/app_generator.rb

require 'rails/generators/app_base'

module Rails
  # ...

  module Generators
    # ...

    class AppGenerator < AppBase # :nodoc:
      # ...

    end
  end
end
  • Rails::Generators::AppGeneratorでは#startは定義されていなかった。
  • AppBaseを継承していた。

 

rails/generators/app_baseをチェック。

#railties/lib/rails/generators/app_base.rb

require 'digest/md5'
require 'active_support/core_ext/string/strip'
require 'rails/version' unless defined?(Rails::VERSION)
require 'open-uri'
require 'uri'
require 'rails/generators'
require 'active_support/core_ext/array/extract_options'

module Rails
  module Generators
    class AppBase < Base # :nodoc:
      # ...

    end
  end
end
  • Rails::Generators::AppBaseでは#startは定義されていなかった。
  • Baseを継承していた。どこでrequireしてるのか分からなかった。

 

rails/generators/baseをチェック。

#railties/lib/rails/generators/base.rb

begin
  require 'thor/group'
rescue LoadError
  puts "Thor is not available.\nIf you ran this command from a git checkout " \
       "of Rails, please make sure thor is installed,\nand run this command " \
       "as `ruby #{$0} #{(ARGV | ['--dev']).join(" ")}`"
  exit
end

module Rails
  module Generators
    # ...

    class Base < Thor::Group
      # ...

    end
  end
end
  • Rails::Generators::Baseでは#startは定義されていなかった。
  • Thor::Groupを継承していた。外部のgemに行ってしまった。

 

thor/groupをチェック。

#thor/lib/thor/group.rb

require "thor/base"

class Thor::Group
  class << self
    # ...

    include Thor::Base
end
  • Thor::Groupでは#startは定義されていなかった。
  • Thor::Baseをインクルードしていた。

 

thor/baseをチェック。

#thor/lib/thor/base.rb

require "thor/command"
require "thor/core_ext/hash_with_indifferent_access"
require "thor/core_ext/ordered_hash"
require "thor/error"
require "thor/invocation"
require "thor/parser"
require "thor/shell"
require "thor/line_editor"
require "thor/util"

class Thor
  autoload :Actions,    "thor/actions"
  autoload :RakeCompat, "thor/rake_compat"
  autoload :Group,      "thor/group"

 # ...

  module Base
    # ...

    class << self
      def included(base) #:nodoc:
        base.extend ClassMethods
        base.send :include, Invocation
        base.send :include, Shell
      end

      # ...

    end

    module ClassMethods
      # ...

      def start(given_args = ARGV, config = {})
        # given_args => ['my_app']
        # config => {}
        config[:shell] ||= Thor::Base.shell.new
        # config { shell: Thor::Shell::Colorオブジェクト }
        dispatch(nil, given_args.dup, nil, config)
      rescue Thor::Error => e
        config[:debug] || ENV["THOR_DEBUG"] == "1" ? (raise e) : config[:shell].error(e.message)
        exit(1) if exit_on_failure?
      rescue Errno::EPIPE
        # This happens if a thor command is piped to something like `head`,
        # which closes the pipe when it's done reading. This will also
        # mean that if the pipe is closed, further unnecessary
        # computation will not occur.
        exit(0)
      end

      # ...
    end
  end
  • Thor::Baseがインクルードされたときフックメソッドのincluded()が実行される。included()Thor::Base::ClassMethodsをextendしてThor::Base::ClassMethodsのメソッドがThor::Baseのクラスメソッドとして使えるようにしている。
  • Thor::Base::ClassMethod#startでは設定や開発環境に合わせてshellでの出力を指定してRailsの雛形ファイル群を生成している。

 

config[:shell] ||= Thor::Base.shell.newを追いかける。

#thor/lib/thor/shell.rb

require "rbconfig"

class Thor
  module Base
    class << self
      attr_writer :shell

      # Returns the shell used in all Thor classes. If you are in a Unix platform
      # it will use a colored log, otherwise it will use a basic one without color.
      #
      def shell
        @shell ||= if ENV["THOR_SHELL"] && ENV["THOR_SHELL"].size > 0
          Thor::Shell.const_get(ENV["THOR_SHELL"])
        # macの場合 RbConfig::CONFIG["host_os"] => 'darwin'
        # windows系の場合 RbConfig::CONFIG["host_os"] => 'mswin' or 'mingw'
        elsif RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ && !ENV["ANSICON"]
          Thor::Shell::Basic
        else
          Thor::Shell::Color
        end
      end
    end
  end

  # ...

end
  • 環境変数を設定してなくてPCはmacだとしたとき、Thor::Base#shellThor::Shell::Colorを返す。
  • rails new my_appを実行したときに出力に色がついていたのはこの辺の設定だったのか。

 

dispatch(nil, given_args.dup, nil, config)を追いかける。

#thor/lib/thor/group.rb

require "thor/base"

class Thor::Group # rubocop:disable ClassLength
  class << self
    # ...

  protected

    # The method responsible for dispatching given the args.
    def dispatch(command, given_args, given_opts, config) #:nodoc:
      # command => nil
      # given_args => ['my_app']
      # given_opts => nil
      # config => { shell: Thor::Shell::Colorのインスタンス }

      # Thor::HELP_MAPPINGS => ['-h', '-?', '--help', '-D']
      if Thor::HELP_MAPPINGS.include?(given_args.first)
        help(config[:shell])
        return
      end

      args, opts = Thor::Options.split(given_args)
      # args => ['my_app']
      # opts => []
      opts = given_opts || opts
      # opts => []

      instance = new(args, opts, config)
      yield instance if block_given?

      if command
        instance.invoke_command(all_commands[command])
      else
        instance.invoke_all
      end
    end
  • instance = new(args, opts, config)で突如出現する#newが何をnewしているのか分からなかったけどデバッグするとRails::Generators::AppGeneratorだと判明した。
  • commandnilなので#invoke_allを実行している。

 

instance.invoke_allを追いかける。

#thor/lib/thor/invocation.rb

class Thor
  module Invocation

    # Make initializer aware of invocations and the initialization args.
    def initialize(args = [], options = {}, config = {}, &block) #:nodoc:
      @_invocations = config[:invocations] || Hash.new { |h, k| h[k] = [] }
      @_initializer = [args, options, config]
      super
    end

    # ...

    # Invoke the given command if the given args.
    def invoke_command(command, *args) #:nodoc:
      # self.class => Rails::Generators::AppGenerator
      current = @_invocations[self.class]

      unless current.include?(command.name)
        current << command.name
        command.run(self, *args)
      end
    end

    # Invoke all commands for the current instance.
    def invoke_all #:nodoc:
      self.class.all_commands.map { |_, command| invoke_command(command) }
    end

    # ...
  end
end
  • #invoke_allでデフォルトの雛形ファイル群を全部生成している。
  • #invoke_all内のself.class.all_commandsでは以下のようなキーがコマンド名で値がThor::Commandオブジェクトのハッシュを生成している。
{
  "set_default_accessors!"=>
    #<struct Thor::Command 
      name = "set_default_accessors!",
      description = nil, 
      long_description = nil, 
      usage = nil, 
      options = {}
    >, 
  "create_root"=>
    #<struct Thor::Command 
      name = "create_root", 
      description = nil, 
      long_description = nil, 
      usage = nil, 
      options = {}
    >,
  "create_root_files"=>
    #<struct Thor::Command 
      name = "create_root_files", 
      description = nil, 
      long_description = nil, 
      usage = nil, 
      options = {}
    >, ... 
}
  • #invoke_allで処理を個々に切り分け、#invoke_commandThor::Commandオブジェクトを渡してファイルを生成している。
  • #invoke_commandでは実行するコマンドが重複しないようにcurrentで管理している。

 

command.run(self, *args)を追いかける。

#thor/lib/thor/command.rb

class Thor
  class Command < Struct.new(:name, :description, :long_description, :usage, :options)

    def initialize(name, description, long_description, usage, options = nil)
      super(name.to_s, description, long_description, usage, options || {})
    end

    # ...

    # By default, a command invokes a method in the thor class. You can change this
    # implementation to create custom commands.
    def run(instance, args = [])
      # instance => Rails::Generators::AppGeneratorオブジェクト
      arity = nil

      if private_method?(instance)
        instance.class.handle_no_command_error(name)
      elsif public_method?(instance)
        arity = instance.method(name).arity
        instance.__send__(name, *args)
      elsif local_method?(instance, :method_missing)
        instance.__send__(:method_missing, name.to_sym, *args)
      else
        instance.class.handle_no_command_error(name)
      end
    rescue ArgumentError => e
      handle_argument_error?(instance, e, caller) ? instance.class.handle_argument_error(self, e, args, arity) : (raise e)
    rescue NoMethodError => e
      handle_no_method_error?(instance, e, caller) ? instance.class.handle_no_command_error(name) : (fail e)
    end

    # ...

  protected
    # ...

    # Given a target, checks if this class name is a public method.
    def public_method?(instance) #:nodoc:
      !(instance.public_methods & [name.to_s, name.to_sym]).empty?
    end

    def private_method?(instance)
      !(instance.private_methods & [name.to_s, name.to_sym]).empty?
    end

    def local_method?(instance, name)
      methods = instance.public_methods(false) + instance.private_methods(false) + instance.protected_methods(false)
      !(methods & [name.to_s, name.to_sym]).empty?
    end
  • #runではRails::Generators::AppGeneratorにコマンド名と同一のpublicメソッドが存在したとき、Rails::Generators::AppGenerator#コマンド名を実行している。
  • Rails::Generators::AppGeneratorにコマンド名と同一のprotectedメソッドが存在したときはNoMethodErrorを返し、同一のprivateメソッドが存在したときと同一のメソッドが存在しなかったときはUndefinedCommandErrorを返す仕様になっている。一体何の差なんだろうか。

 

instance.__send__(name, *args)を追いかける。

#railties/lib/rails/generators/rails/app/app_generators.rb

require 'rails/generators/app_base'

module Rails
  # ...

  module Generators    
    # ...

    class AppGenerator < AppBase # :nodoc:

      public_task :set_default_accessors!
      public_task :create_root

      def create_root_files
        build(:readme)
        build(:rakefile)
        build(:configru)
        build(:gitignore) unless options[:skip_git]
        build(:gemfile)   unless options[:skip_gemfile]
      end

      def create_app_files
        build(:app)
      end

      def create_bin_files
        build(:bin)
      end

      # ... コマンド名と同じメソッドが続く
  • やっとRailsに戻ってきた。
  • それぞれのメソッドで#buildを実行している。
  • 普通のメソッドではなくpublic_taskで定義されているものもあるけど疲れたので放置。

 

build()を追いかける。

#railties/lib/rails/generators/app_base.rb

require 'digest/md5'
require 'active_support/core_ext/string/strip'
require 'rails/version' unless defined?(Rails::VERSION)
require 'open-uri'
require 'uri'
require 'rails/generators'
require 'active_support/core_ext/array/extract_options'

module Rails
  module Generators
    class AppBase < Base # :nodoc:
      # ...

    protected
      # ...

      def builder
        @builder ||= begin
          builder_class = get_builder_class
          # builder_class => Rails::AppBuilder
          builder_class.send(:include, ActionMethods)
          # self => Rails::Generators::AppGeneratorオブジェクト
          builder_class.new(self)
        end
      end

      def build(meth, *args)
        builder.send(meth, *args) if builder.respond_to?(meth)
      end
  • #builderではRails::ActionMethodsをインクルードしてRails::ActionMethods#newを用いてRails::Generators::AppGeneratorsオブジェクトを引数にRails::AppBuilderを生成している(はず)。
  • #buildではmethと同一の#builderで生成したRails::AppBuilderオブジェクトのインスタンスメソッドを実行している。

 

builder.send(meth, *args)を追いかける。

#railties/lib/rails/generators/rails/app/app_generators.rb

require 'rails/generators/app_base'

module Rails
  # ...

  class AppBuilder
    def rakefile
      template "Rakefile"
    end

    def readme
      copy_file "README.rdoc", "README.rdoc"
    end

    def gemfile
      template "Gemfile"
    end

    def configru
      template "config.ru"
    end

    def gitignore
      template "gitignore", ".gitignore"
    end

    def app
      directory 'app'

      keep_file  'app/assets/images'
      keep_file  'app/mailers'
      keep_file  'app/models'

      keep_file  'app/controllers/concerns'
      keep_file  'app/models/concerns'
    end

    def bin
      directory "bin" do |content|
        "#{shebang}\n" + content
      end
      chmod "bin", 0755 & ~File.umask, verbose: false
    end

    # ... methと同じメソッドが続く
  end
  
  # ...

end
  • #template#directory, #keep_fileThor::Actionのメソッド。
  • 各行で何が生成されてるか分かるようになってきたのでそろそろ終わろう。

 

おわりに

  • RailsRubyそのものについての様々な知見が得られた。
  • Rails開発者に足を向けて寝られなくなった。

 

参考にさせていただきました

`rails s`読んだ - AnyType