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/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"
ということなので、このパスでロードして読み込まれるコードの中身を見てみます。
#!/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"
# 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 メソッドの中身を見てみます。
# 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
の中身を見てみます。
#!/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_PATH
か ENGINE_PATH
がなかった場合でも定数 APP_PATH
の初期化と config/application.rb
と config/boot.rb
の読み込み、そして最終的に rails/commands
を読み込んでいるっぽい。
Object.const_set(:APP_PATH, File.expand_path("config/application", Dir.pwd))
require File.expand_path("../boot", APP_PATH)
require "rails/commands"
ということで、 rails/commands
の中身を見てみます。
# 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 メソッドの中身見ていきます。
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 メソッドを見ます。
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 してるようです。なるほど、メソッド名通りの処理だ。
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 メソッドに戻ってきました。
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 が実行されます。メソッドの中身を見ると分かりますが、 @subclasses
に base
が追加されてるのが分かるかと思います。
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 化します。
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
ここで最後の処理に使う namespaces
と lookups
の値を確認しておきましょう。現時点でこんな感じになっています。
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
が代入されることが分かりました。
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
# => {}
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 メソッドが呼び出されます。
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 クラスを継承したクラスにこのメソッドを書くと、クラス全体で定義しておきたいオプションを設定することができる、というものです。
たとえば、
#!/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