5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Rails コマンドの実行の流れをたどる旅

Posted at

Rails コマンド群がどのように呼び出されているのか気になったので、ソースコードを追いながらその流れを整理してみました。うーんとっても長い。

とっても長いので、ざっくり要約を載せておきます。

  • Rails コマンドはそれぞれコマンド名に対応したファイルを実行する
    • 例) rails generate → /rails/railties/lib/rails/commands/generate/generate_command.rb

もう、これだけ!後はホントに沼なので書く気にならん!w

さて、ここから先は自分の頭の中の思考をダダ漏らしにした感じでお送りするので、暇な方で覗いてみてください。

まずは rails コマンドの実行パスを確認します。こいつが何をしてるかっていう話ですよね。

$ which rails
=> /usr/local/bundle/bin/rails

実行パスが分かったので cat /usr/local/bundle/bin/rails を実行して中身を確認します。

/usr/local/bundle/bin/rails

#!/usr/bin/env ruby
#
# This file was generated by RubyGems.
#
# 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
  • Gem.activate_bin_path('railties', 'rails', version)
  • Gem.bin_path("railties", "rails", version)

このファイルでは上記のどちらかを実行して終わってるんですけど、どちらも同じ値 "/usr/local/bundle/gems/railties-5.2.3/exe/rails" を返してます。

Gem.activate_bin_path('railties', 'rails', '>= 0.a')
# => "/usr/local/bundle/gems/railties-5.2.3/exe/rails"
Gem.bin_path('railties', 'rails', '>= 0.a')
# => "/usr/local/bundle/gems/railties-5.2.3/exe/rails"

ということなので、このパスでロードして読み込まれるコードの中身を見てみます。

/railties/exe/rails
#!/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"
/railties/lib/rails/cli.rb
# 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

ここでコメントアウトに

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

と書いてあるので、 Rails アプリケーション内から読み出した場合は、 Rails::AppLoader.exec_app 以降のコードは実行されないようです。ので、この #exec_app メソッドの中身を見てみます。

/railties/lib/rails/app_loader.rb
# frozen_string_literal: true

require "pathname"
require "rails/version"

module Rails
  module AppLoader # :nodoc:
    extend self

    RUBY = Gem.ruby
    EXECUTABLES = ["bin/rails", "script/rails"]
    BUNDLER_WARNING = <<EOS
…(長いので省略)…
EOS

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

ここちょっとビックリしたんですけど、 extend self を書いとくと自身に特異メソッドを生やすことできるっぽい。だから Rails::AppLoader.exec_app を直接実行できるのね。へー…。

ここでは、 EXECUTABLES = ["bin/rails", "script/rails"] このどちらかのパスが存在したらそれを実行というロジックになってます。 bin/rails の中身を見てみます。

/path/to/work_dir/bin/rails
#!/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'

みたところ、 APP_PATHENGINE_PATH がなかった場合でも定数 APP_PATH の初期化と config/application.rbconfig/boot.rb の読み込み、そして最終的に rails/commands を読み込んでいるっぽい。

/railties/lib/rails/app_loader.rb

Object.const_set(:APP_PATH, File.expand_path("config/application", Dir.pwd))
require File.expand_path("../boot", APP_PATH)
require "rails/commands"

ということで、 rails/commands の中身を見てみます。

/railties/lib/rails/commands.rb
# 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

うおお、ここで Rails コマンドのエイリアスが出てきたぞ…。ここで ARGV に含まれていた値を取り出して、それを引数に Rails::Command.invoke command, ARGV を実行してます。

ここで言う ARGV は「 Ruby スクリプトに与えられた引数を表す配列」とのことで、実行ファイル名より後の値を配列でファイル内に渡してます。
https://docs.ruby-lang.org/ja/2.6.0/method/Object/c/ARGV.html

たとえば、

$ rails generate model

の実行時の引数 ARGV は以下のように保存されます。

['generate', 'model'] 

さて、 Rails::Command.invoke command, ARGV の中身を見てみます。
たとえば、 rails generate model を実行する場合、

full_namespace
# => 'generate'
args
# => ['model']

がそれぞれ代入されているはずです。
なお、これ以上の処理は、すべて以下のコマンドを実行する場合を前提とします。

$ rails generate model Foo foo:string

よし、 #invoke メソッドの中身見ていきます。

/railties/lib/rails/command.rb
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 = "version", "version" if %w( -v --version ).include?(command_name)

  command = find_by_namespace(namespace, command_name)
  if command && command.all_commands[command_name]
    command.perform(command_name, args, config)
  else
    find_by_namespace("rake").perform(full_namespace, args, config)
  end
end

前段にいろいろとコマンド名の処理がありますが、最終的には command.perform(command_name, args, config) の中身が分かればいいので、まずは変数 command に代入している #find_by_namespace メソッドを見ます。

/railties/lib/rails/command.rb
def find_by_namespace(namespace, command_name = nil) # :nodoc:
  lookups = [ namespace ]
  lookups << "#{namespace}:#{command_name}" if command_name
  lookups.concat lookups.map { |lookup| "rails:#{lookup}" }

  lookup(lookups)

  namespaces = subclasses.index_by(&:namespace)
  namespaces[(lookups & namespaces.keys).first]
end

冒頭 3 行の処理で変数 lookups には以下の値が代入され、 #lookup メソッドに渡されます。

lookups
# => ["generate", "generate:generate", "rails:generate", "rails:generate:generate"]

それでは #lookup メソッドの中身を見てみます。どうやらここでは namespaces を元に各コマンドの処理が入ってるファイルを require してるようです。なるほど、メソッド名通りの処理だ。

/railties/lib/rails/command/behavior.rb
def lookup(namespaces)
  paths = namespaces_to_paths(namespaces)

  paths.each do |raw_path|
    lookup_paths.each do |base|
      path = "#{base}/#{raw_path}_#{command_type}"

      begin
        require path
        return
      rescue LoadError => e
        raise unless e.message =~ /#{Regexp.escape(path)}$/
      rescue Exception => e
        warn "[WARNING] Could not load #{command_type} #{path.inspect}. Error: #{e.message}.\n#{e.backtrace.join("\n")}"
      end
    end
  end
end

ここでは、 #namespaces_to_paths / #lookup_paths / #command_type 3 つのプライベートメソッドが呼ばれています。各メソッドの中身は単純なので割愛しますが、それぞれ以下の値を返します。

namespaces_to_paths
# => ["generate/generate", "generate", "generate/generate/generate", "rails/generate/generate", "rails/generate", "rails/generate/generate/generate"]
lookup_paths
# => ["rails/commands", "commands"]
command_type
# => "command"

ほんで、 require が true を返すのは "rails/commands/generate/generate_command" の時ですね。なんなんだろう、すごいトリッキーなことやってる気がするw

そうこうして #find_by_namespace メソッドに戻ってきました。

/railties/lib/rails/command.rb
def find_by_namespace(namespace, command_name = nil) # :nodoc:
  lookups = [ namespace ]
  lookups << "#{namespace}:#{command_name}" if command_name
  lookups.concat lookups.map { |lookup| "rails:#{lookup}" }

  lookup(lookups)

  namespaces = subclasses.index_by(&:namespace)
  namespaces[(lookups & namespaces.keys).first]
end

さて、次に実行される #subclasses メソッドが謎を呼ぶんですが、中身はこうなっています。

def subclasses
  @subclasses ||= []
end

これ pry でデバッグ中に試しに実行してみると分かるんですが、なんともう値 [Rails::Command::GenerateCommand] が入ってます。い、いつ代入されたの…??????って話なんですが、実は require path の時点で代入されていました。

そもそも xxxx_command.rb の中身はざっくり以下のような構成になっており、必ず Rails::Command::Base クラスを継承するようになっています。

module Rails
  module Command
    class HogehogeCommand < Base
    end
  end
end

この < Base のタイミングで Rails::Command::Base.inherited が実行されます。メソッドの中身を見ると分かりますが、 @subclassesbase が追加されてるのが分かるかと思います。

module Rails
  module Command
    class Base < Thor
      class << self
        
        def inherited(base) #:nodoc:
          super
          if base.name && base.name !~ /Base$/
            Rails::Command.subclasses << base
          end
        end
     …
      end
    end
  end
end

ソースコードリーディングっていろんなファイルを飛び回るからアタマ混乱するなあ…。
さて、その @subclasses に #index_by をかけて Hash 化します。

/railties/lib/rails/command.rb
def find_by_namespace(namespace, command_name = nil) # :nodoc:
  lookups = [ namespace ]
  lookups << "#{namespace}:#{command_name}" if command_name
  lookups.concat lookups.map { |lookup| "rails:#{lookup}" }

  lookup(lookups)

  namespaces = subclasses.index_by(&:namespace)
  namespaces[(lookups & namespaces.keys).first]
end

ここで最後の処理に使う namespaceslookups の値を確認しておきましょう。現時点でこんな感じになっています。

namespaces
# => {"rails:generate"=>Rails::Command::GenerateCommand}
lookups
# => ["generate", "generate:generate", "rails:generate", "rails:generate:generate"]

なので、最後の行の返り値はこんな感じになります。

namespaces[(lookups & namespaces.keys).first]
# => Rails::Command::GenerateCommand

やっと #find_by_namespace(namespace, command_name) の返り値がわかったので、 #invoke メソッドに戻ります。今まで見てきたのは変数 command にどんな値が代入されるかということでした。上記で見た通り、 Rails::Command::GenerateCommand が代入されることが分かりました。

/railties/lib/rails/command.rb
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 = "version", "version" if %w( -v --version ).include?(command_name)

  command = find_by_namespace(namespace, command_name)
  if command && command.all_commands[command_name]
    command.perform(command_name, args, config)
  else
    find_by_namespace("rake").perform(full_namespace, args, config)
  end
end

仮に command が nil だった場合は Rails::Command::RakeCommand を元に rake コマンドが走るようですが一旦それは置いておきます。 command.perform(command_name, args, config) の中身を見ていきます。なお引数の値はこんな感じになっています。

command_name
# => "generate"
args
# => ["model"]
config
# => {}
/railties/lib/rails/command/base.rb
def perform(command, args, config) # :nodoc:
  if Rails::Command::HELP_MAPPINGS.include?(args.first)
    command, args = "help", []
  end

  dispatch(command, args.dup, nil, config)
end

ここの #dispatch は Rails ではなく Thor のメソッドです。Thor 内部の動きについてはまた色々ありますが、ここでは割愛します。なんだかんだあった後に Rails::Command::GenerateCommand#perform メソッドが呼び出されます。

/railties/lib/rails/commands/generate/generate_command.rb
def perform(*)
  generator = args.shift
  return help unless generator

  require_application_and_environment!
  load_generators

  ARGV.shift

  Rails::Generators.invoke generator, args, behavior: :invoke, destination_root: Rails::Command.root
end

さて、ここから沼に入っていきます。しばらく自分がどこにいるか分からなくなります。ちょっと最後の方まで書いてたんですが説明するのがめんどくさくなったので、興味のある方は覗いてみてください。 Rails って大きいなあ(小並)ってのがひしひしと分かります。

要するに Rails コマンドとそれを実行するファイル名は対応していて、 rails generate なら generate_command.rb、 rails console なら console_command.rb を見に行けばなんとなく中で何をやっているかが分かる、というしくみです。
いつもやっているソースコードリーディングの思考をそのまま書いたみたいな感じでまとまりないのは申し訳ないですが、備忘録程度に書いてるので、まあそんなもんだと思ってください。

Thor について(おまけ)

なお、各コマンドのオプションについては、 class_option で大半を定義しています。これは Thor の機能で http://whatisthor.com/#class-options 、Thor クラスを継承したクラスにこのメソッドを書くと、クラス全体で定義しておきたいオプションを設定することができる、というものです。

たとえば、

sample.rb
#!/usr/bin/env ruby
require 'thor'

class Nya < Thor
  class_option :nyanchu, type: :boolean, default: false

  desc 'hello NAME', 'say hello to NAME'
  def hello(name)
    if options[:nyanchu]
      puts "#{name}, nyanchu~!"
    else
      puts "#{name}, nya~!"
    end
  end
end

Nya.start(ARGV)

としておくと、勝手に [--nyanchu], [--no-nyanchu] オプションをつけてくれるようになります。コマンド名を指定せずにファイルを実行すると、よく見かける README を出力してくれます。なにこれ便利。

root@10161f5ac926:/hoge# ./bin/sample
Commands:
  sample hello NAME      # say hello to NAME
  sample help [COMMAND]  # Describe available commands or one specific command

Options:
  [--nyanchu], [--no-nyanchu]  

なので、このオプションにしたがって以下のように実行してみると、ちゃんと引数を解釈してくれます。

root@10161f5ac926:/hoge# ./bin/sample hello Waku --nyanchu
Waku, nyanchu~!

もっと詳しく Thor について知りたい方は公式ドキュメントか GitHub のリポジトリを見に行っても良いかもしれません。
http://whatisthor.com/
https://github.com/erikhuda/thor

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?