動機
せっかくアドベントカレンダーの初日なので、rails new
のオプションの解説をしようと思ったんですが、それだけだとありきたりなのでコードを読んでみることにしました。
前提条件
ソースコードはv6.0.1
のタグが付いているもの(執筆時点の最新安定版)。
注意書き
この記事は筆者がガチでコードを読みつつ書いたものです。順番の前後や過度のライブ感がありますがご了承ください。
とっかかり
ここかなあ…
module Rails
module Command
class NewCommand < Base # :nodoc:
no_commands do
def help
Rails::Command.invoke :application, [ "--help" ]
end
end
def perform(*)
say "Can't initialize a new Rails application within the directory of another, please change to a non-Rails directory first.\n"
say "Type 'rails' for help."
exit 1
end
end
end
end
あっ、違う。ここに置かれているのはRailsアプリを作ってから使われるものらしい。
じゃあ別のところにあるってことなので、雑にrg 'rails new'
するか(rg
はgrep
のすごい版)。
お、なんか見つけた。
見覚えのあるオプションっぽいものがあるし、ここが見るべきコードらしい。
リーディング開始
とりあえず、オプションなしの挙動を調べよう。
なるほど、AppBase
というやつを継承しているのか。
さらにBase
を継承しているっぽい。
Base
はThor::Group
というのを継承しているのか…さすがに追うのに疲れてきたし、Thor
はRubyでCLIを書くためのツールキットみたいなやつだったかな。これ以上追ってもRails固有の処理は出てこないだろうから、ここから読み始めよう。
…とは言うものの、書いてある処理が汎用的過ぎるなあ、AppBase
に戻ろう。
ここでDatabase
なるモジュールをinclude
している。見てみよう。
Database
なるほど、ここではDBの種類とgemへのマッピングとかをやっているのか。
def gem_for_database(database = options[:database])
case database
when "mysql" then ["mysql2", [">= 0.4.4"]]
when "postgresql" then ["pg", [">= 0.18", "< 2.0"]]
when "sqlite3" then ["sqlite3", ["~> 1.4"]]
when "oracle" then ["activerecord-oracle_enhanced-adapter", nil]
when "frontbase" then ["ruby-frontbase", nil]
when "sqlserver" then ["activerecord-sqlserver-adapter", nil]
when "jdbcmysql" then ["activerecord-jdbcmysql-adapter", nil]
when "jdbcsqlite3" then ["activerecord-jdbcsqlite3-adapter", nil]
when "jdbcpostgresql" then ["activerecord-jdbcpostgresql-adapter", nil]
when "jdbc" then ["activerecord-jdbc-adapter", nil]
else [database, nil]
end
end
MySQLのソケット、地道…
def mysql_socket
@mysql_socket ||= [
"/tmp/mysql.sock", # default
"/var/run/mysqld/mysqld.sock", # debian/gentoo
"/var/tmp/mysql.sock", # freebsd
"/var/lib/mysql/mysql.sock", # fedora
"/opt/local/lib/mysql/mysql.sock", # fedora
"/opt/local/var/run/mysqld/mysqld.sock", # mac + darwinports + mysql
"/opt/local/var/run/mysql4/mysqld.sock", # mac + darwinports + mysql4
"/opt/local/var/run/mysql5/mysqld.sock", # mac + darwinports + mysql5
"/opt/lampp/var/mysql/mysql.sock" # xampp for linux
].find { |f| File.exist?(f) } unless Gem.win_platform?
end
AppName
Database
モジュールは短かったけど、もう一つinclude
されているものがあった。AppName
のほうはどうだろう。
こちらも短いし全部プライベートメソッドだけど、面白いものを見つけた。
RESERVED_NAMES = %w(application destroy plugin runner test)
def valid_const?
if /^\d/.match?(app_const)
raise Error, "Invalid application name #{original_app_name}. Please give a name which does not start with numbers."
elsif RESERVED_NAMES.include?(original_app_name)
raise Error, "Invalid application name #{original_app_name}. Please give a " \
"name which does not match one of the reserved rails " \
"words: #{RESERVED_NAMES.join(", ")}"
elsif Object.const_defined?(app_const_base)
raise Error, "Invalid application name #{original_app_name}, constant #{app_const_base} is already in use. Please choose another application name."
end
end
ここで無効なアプリ名を弾いているのか。generator
はアプリ名としては正当なもの…なのか?
AppBase
見知ったオプションたち
AppBase
に戻ってくると、ようやく見慣れたオプションを見つけることができた。
def self.add_shared_options_for(name)
class_option :template, type: :string, aliases: "-m",
desc: "Path to some #{name} template (can be a filesystem path or URL)"
class_option :database, type: :string, aliases: "-d", default: "sqlite3",
desc: "Preconfigure for selected database (options: #{DATABASES.join('/')})"
class_option :skip_gemfile, type: :boolean, default: false,
desc: "Don't create a Gemfile"
class_option :skip_git, type: :boolean, aliases: "-G", default: false,
desc: "Skip .gitignore file"
class_option :skip_keeps, type: :boolean, default: false,
desc: "Skip source control .keep files"
class_option :skip_action_mailer, type: :boolean, aliases: "-M",
default: false,
desc: "Skip Action Mailer files"
class_option :skip_action_mailbox, type: :boolean, default: false,
desc: "Skip Action Mailbox gem"
class_option :skip_action_text, type: :boolean, default: false,
desc: "Skip Action Text gem"
class_option :skip_active_record, type: :boolean, aliases: "-O", default: false,
desc: "Skip Active Record files"
class_option :skip_active_storage, type: :boolean, default: false,
desc: "Skip Active Storage files"
class_option :skip_puma, type: :boolean, aliases: "-P", default: false,
desc: "Skip Puma related files"
class_option :skip_action_cable, type: :boolean, aliases: "-C", default: false,
desc: "Skip Action Cable files"
class_option :skip_sprockets, type: :boolean, aliases: "-S", default: false,
desc: "Skip Sprockets files"
class_option :skip_spring, type: :boolean, default: false,
desc: "Don't install Spring application preloader"
class_option :skip_listen, type: :boolean, default: false,
desc: "Don't generate configuration that depends on the listen gem"
class_option :skip_javascript, type: :boolean, aliases: "-J", default: name == "plugin",
desc: "Skip JavaScript files"
class_option :skip_turbolinks, type: :boolean, default: false,
desc: "Skip turbolinks gem"
class_option :skip_test, type: :boolean, aliases: "-T", default: false,
desc: "Skip test files"
class_option :skip_system_test, type: :boolean, default: false,
desc: "Skip system test files"
class_option :skip_bootsnap, type: :boolean, default: false,
desc: "Skip bootsnap gem"
class_option :dev, type: :boolean, default: false,
desc: "Setup the #{name} with Gemfile pointing to your Rails checkout"
class_option :edge, type: :boolean, default: false,
desc: "Setup the #{name} with Gemfile pointing to Rails repository"
class_option :rc, type: :string, default: nil,
desc: "Path to file containing extra configuration options for rails command"
class_option :no_rc, type: :boolean, default: false,
desc: "Skip loading of extra configuration options from .railsrc file"
class_option :help, type: :boolean, aliases: "-h", group: :rails,
desc: "Show this help message and quit"
end
これほぼrails new
のときのオプションだけど、でもここではまだクラスメソッドの定義に過ぎない。これを呼んでいる箇所をrg
すると…いた!
add_shared_options_for "application"
add_shared_options_for "plugin"
そうか、Engine作るときとかも同じオプションだからここにあるのか。
こうやってクラスメソッドを呼び出すことでオプション定義に展開される、と。
Gemfileの中身
生成されるGemfile
の中身が定義されているっぽい箇所があるね。
def gemfile_entries # :doc:
[rails_gemfile_entry,
database_gemfile_entry,
web_server_gemfile_entry,
assets_gemfile_entry,
webpacker_gemfile_entry,
javascript_gemfile_entry,
jbuilder_gemfile_entry,
psych_gemfile_entry,
cable_gemfile_entry,
@extra_entries].flatten.find_all(&@gem_filter)
end
で、database_gemfile_entry
を見てみると、
def database_gemfile_entry # :doc:
return [] if options[:skip_active_record]
gem_name, gem_version = gem_for_database
GemfileEntry.version gem_name, gem_version,
"Use #{options[:database]} as the database for Active Record"
end
おー、gem_for_database
はさっきDatabase
モジュールの中で見たやつだ、ここで使うんだ!
:doc:なプライベートメソッドたち
プライベートメソッドにたくさん:doc
が付いているなあ。これってどういう意図なんだっけ?実装はプライベートだけどエンドユーザーにオーバーライドしてほしいやつみたいな感じだったっけ?
def builder # :doc:
@builder ||= begin
builder_class = get_builder_class
builder_class.include(ActionMethods)
builder_class.new(self)
end
end
def build(meth, *args) # :doc:
builder.send(meth, *args) if builder.respond_to?(meth)
end
この辺気になるなあ。get_builder_class
の結果はおそらくアプリケーションとプラグインで違うクラスになるんだろう。
ActionMethods
はさっき見た気もする。
ActionMethods
module ActionMethods # :nodoc:
attr_reader :options
def initialize(generator)
@generator = generator
@options = generator.options
end
private
%w(template copy_file directory empty_directory inside
empty_directory_with_keep_file create_file chmod shebang).each do |method|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
def #{method}(*args, &block)
@generator.send(:#{method}, *args, &block)
end
RUBY
end
def method_missing(meth, *args, &block)
@generator.send(meth, *args, &block)
end
end
おー、これはわかりやすいプロキシオブジェクト。初期化時に受け取ったgenerator
オブジェクトにガンガン移譲してる。一部のメソッドは事前にclass_eval
からdef
することでmethod_missing
の多用を回避しているっぽい。
これを呼んでいるコードに戻ると、
def builder # :doc:
@builder ||= begin
builder_class = get_builder_class
builder_class.include(ActionMethods)
builder_class.new(self)
end
end
このself
は各具象のジェネレータだと思うから、ここでようやくAppGenerator
を読むタイミングが来たようだ。
AppGenerator
AppBuilder
さっきの流れだと、このファイルのどこかにget_builder_class
があるはず…あった!
で、探すべきクラス(AppBuilder
)はこちらに。
# The application builder allows you to override elements of the application
# generator without being forced to reverse the operations of the default
# generator.
#
# This allows you to override entire operations, like the creation of the
# Gemfile, README, or JavaScript files, without needing to know exactly
# what those operations do so you can create another template action.
#
# class CustomAppBuilder < Rails::AppBuilder
# def test
# @generator.gem "rspec-rails", group: [:development, :test]
# run "bundle install"
# generate "rspec:install"
# end
# end
なるほど、このクラスは上書きされる想定なのか。つまり、ジェネレータではなくてこのビルダクラスを継承して書き換えることで、ジェネレータの全体像を知らなくてもピンポイントで処理を変更できるということか。よくできているなあ。
で、中身を見てみると、なんか見たことあるようなメソッドが大量に定義されているなあ…AppBase
にbuild
があって、そこではこんな感じのことが書いてあった。
def build(meth, *args) # :doc:
builder.send(meth, *args) if builder.respond_to?(meth)
end
このbuilder
が今見たクラスなら、コード中にbuild(:readme)
とかがあって、それは実際にはAppBuilder.new.readme
的なコードになっているということになる。というわけでbuild
を探すとすぐ見つかった。
ここはAppGenerator
の中。
全体像
ようやく全体像が見えてきた。無理を承知で単純化すると、AppBuilder
クラスはrails new
するときの各工程における具体的な処理が書いてある。各工程はAppGenerator
クラスのパブリックメソッドとして列挙してある。
後者については実はズルをした。あのあと気になってThor::Group
について調べたらWikiがあって。
要はメソッドを上から順に呼ぶコマンドを定義するらしい。なるほど、rails new
したときにやっていることってAppGenerator
のメソッドを上から読むと確かにそのままだ。
というわけで、rails new
が何をしているのかを知りたければこの辺を読めばいいらしい。でもオプションが渡されたときの挙動も面白い。
options
AppGenerator
にはapi
オプションが定義されている。これはRails 5から導入されたAPIモードのためのオプションだ。このオプションは各所の挙動を変更している、例えば:
# Force sprockets and yarn to be skipped when generating API only apps.
# Can't modify options hash as it's frozen by default.
if options[:api]
self.options = options.merge(skip_sprockets: true, skip_javascript: true).freeze
end
なるほど、APIモードならSprocketsもYarnもいらない、確かに。
他にも、_if_api_option
で終わるメソッドがたくさんあって、ファイルの削除をしているようだ。
あとは、options[:pretend]
というものがある。これは定義箇所が見つからなかったのでおそらくThor
の内部で定義されているオプションだと思われる。このオプションが指定されていると色々とスキップされる(そのためのオプションなので当然ではある)。
def master_key
return if options[:pretend] || options[:dummy_app]
require "rails/generators/rails/master_key/master_key_generator"
master_key_generator = Rails::Generators::MasterKeyGenerator.new([], quiet: options[:quiet], force: options[:force])
master_key_generator.add_master_key_file_silently
master_key_generator.ignore_master_key_file_silently
end
def credentials
return if options[:pretend] || options[:dummy_app]
require "rails/generators/rails/credentials/credentials_generator"
Rails::Generators::CredentialsGenerator.new([], quiet: options[:quiet]).add_credentials_file_silently
end
こんな感じ。
余談ながら、こうやって途中でrequire
を呼べたりするのはRubyの柔軟性の為せる技だなあと思う。
中間まとめ
ここまででわかったことは、
- オプションの定義は
AppBase
とAppGenerator
クラスにある -
rails new
の処理の概要に関してはAppGenerator
クラスの各パブリックメソッドに書かれている - 具体的な処理は
AppBuilder
に書かれており、こちらは継承してメソッドをオーバーライドすることで処理の変更が可能
そしてその他の細かい色々(destroy
という名前のアプリケーションは作れない、とか!)。
では、そもそもそうやってこのAppGenerator
クラスが起動されるのかまで調べてみよう。
ジェネレータが呼ばれるまで
rails
実行ファイル
冷静に考えると、rails new
するとき、rails
の部分は実行可能ファイルとして扱われていることになる。実行可能ファイルを提供しているgemでは、それらはexe
ディレクトリの下に置かれている。Railsも同じだとすると…あった!
require "rails/cli"
しているのが本体っぽいので、そちらに移動しよう。
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
なるほど、rails new
の場合、ARGV.first == "new"
となるはずなので、下のコード、Rails::Command.invoke :application, ARGV
が呼ばれるのか。次はRails::Command.invoke
を見るべき。
Commandクラス
# 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 = "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
# Rails finds namespaces similar to Thor, it only adds one rule:
#
# Command names must end with "_command.rb". This is required because Rails
# looks in load paths and loads the command just before it's going to be used.
#
# find_by_namespace :webrat, :rails, :integration
#
# Will search for the following commands:
#
# "rails:webrat", "webrat:integration", "webrat"
#
# Notice that "rails:commands:webrat" could be loaded as well, what
# Rails looks for is the first and last parts of the 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
問題のコードは以上。invoke
はinvoke('application', ['new'])
として起動されている(実際はinvoke('application', ['new', 'my_app']
とかの可能性が高いけど、一番シンプルなケース)。
if char = namespace =~ /:(\w+)$/
の条件文はfalse
だと思われる(コロンは入ってない)ので、command_name
とnamespace
はともにapplication
。
ヘルプとバージョンは今回は飛ばしてよいのだけど、find_by_namespace
にあるlookup
ってどこにあるんだろうと思ったらautoload
されているBehaviour
モジュールの中にあった。
# Receives namespaces in an array and tries to find matching generators
# in the load path.
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
なんだかとらえどころのないコードだけど、要はrequire
するためのコードらしい。
さっきのコードに戻ると、find_by_namespace('application', 'application')
が呼び出されていることになる。subclasses
をindex_by
でハッシュにしているけど、ここでのサブクラスってRails::Command
のサブクラスってことだよな。字面的にRails::Command::ApplicationCommand
クラスがありそう…あった!
ApplicationCommand
おお、我らがAppGenerator
があるじゃないか!
module Command
class ApplicationCommand < Base # :nodoc:
hide_command!
def help
perform # Punt help output to the generator.
end
def perform(*args)
Rails::Generators::AppGenerator.start \
Rails::Generators::ARGVScrubber.new(args).prepare!
end
end
end
invoke
内でperform
が呼ばれていたし、これで全てつながった…
Rails::Generators::ARGVScrubber.new(args).prepare!
が唐突感あるので、これだけ読んで終わりにしよう。
ARGVScrubber
まさかのAppGenerator
と同じファイル…
# This class handles preparation of the arguments before the AppGenerator is
# called. The class provides version or help information if they were
# requested, and also constructs the railsrc file (used for extra configuration
# options).
#
# This class should be called before the AppGenerator is required and started
# since it configures and mutates ARGV correctly.
class ARGVScrubber # :nodoc:
def initialize(argv = ARGV)
@argv = argv
end
def prepare!
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)
if ["--version", "-v"].include?(argument)
require "rails/version"
puts "Rails #{Rails::VERSION::STRING}"
exit(0)
end
end
def handle_invalid_command!(argument, argv)
if argument == "new"
yield
else
["--help"] + argv.drop(1)
end
end
def handle_rails_rc!(argv)
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)
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 read_rc_file(railsrc)
extra_args = File.readlines(railsrc).flat_map(&:split)
puts "Using #{extra_args.join(" ")} from #{railsrc}"
extra_args
end
def insert_railsrc_into_argv!(argv, railsrc)
return argv unless File.exist?(railsrc)
extra_args = read_rc_file railsrc
argv.take(1) + extra_args + argv.drop(1)
end
end
@argv
には['new', 'my_app']
とかが入るのね。
で、まずhandle_version_request!
でバージョンの問い合わせをチェックしてからhandle_invalid_command!
でnew
のときだけブロックを呼び出し、最後にrailsrc
絡みの処理をして終了…簡単だね。
まとめ
謎に順番が前後したけれど、これでrails new
の謎はほぼ全て解けた。残る謎は自分でコードリーディングできるはず!
おまけ:アプリ名はどこで取得されている?
実は、ARGVScrubber
がアプリ名の引数を処理しているのかと思ったらそんなことはなかったので、さっきまで読んでいたコードを読み返すとそれらしきものがありました。
argument
はおそらくThor
由来。rails new hoge
の場合のhoge
をここで受け止めているのだろうと思われるけど流石に読み続ける気力がなくこれ以上の調査を断念…
ただ、ここでそれらしきメソッドが呼ばれているのを発見。
def set_default_accessors! # :doc:
self.destination_root = File.expand_path(app_path, destination_root)
self.rails_template = \
case options[:template]
when /^https?:\/\//
options[:template]
when String
File.expand_path(options[:template], Dir.pwd)
else
options[:template]
end
end
そして、このメソッドはAppGenerator
で参照されている。
おそらくはここでアプリ名を取得し、同名のディレクトリを作成していると思われる。