41
20

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.

Ruby on RailsAdvent Calendar 2019

Day 1

Rails newの処理を追ってみた

Last updated at Posted at 2019-12-01

動機

せっかくアドベントカレンダーの初日なので、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'するか(rggrepのすごい版)。

お、なんか見つけた。

見覚えのあるオプションっぽいものがあるし、ここが見るべきコードらしい。

リーディング開始

とりあえず、オプションなしの挙動を調べよう。

なるほど、AppBaseというやつを継承しているのか。

さらにBaseを継承しているっぽい。

BaseThor::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

なるほど、このクラスは上書きされる想定なのか。つまり、ジェネレータではなくてこのビルダクラスを継承して書き換えることで、ジェネレータの全体像を知らなくてもピンポイントで処理を変更できるということか。よくできているなあ。

で、中身を見てみると、なんか見たことあるようなメソッドが大量に定義されているなあ…AppBasebuildがあって、そこではこんな感じのことが書いてあった。

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の柔軟性の為せる技だなあと思う。

中間まとめ

ここまででわかったことは、

  • オプションの定義はAppBaseAppGeneratorクラスにある
  • 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

問題のコードは以上。invokeinvoke('application', ['new'])として起動されている(実際はinvoke('application', ['new', 'my_app']とかの可能性が高いけど、一番シンプルなケース)。
if char = namespace =~ /:(\w+)$/の条件文はfalseだと思われる(コロンは入ってない)ので、command_namenamespaceはともに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')が呼び出されていることになる。subclassesindex_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で参照されている。

おそらくはここでアプリ名を取得し、同名のディレクトリを作成していると思われる。

41
20
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
41
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?