`bin/webpack`を読んだ

webpackerを理解するため、rails g webpacker:installで追加されるbin/webpackや設定の中身を読むことにした。

bin/webpack

newenv  = { "NODE_PATH" => NODE_MODULES_PATH.shellescape }
cmdline = ["yarn", "run", "webpack", "--", "--config", WEBPACK_CONFIG] + ARGV

Dir.chdir(APP_PATH) do
  exec newenv, *cmdline
end
  • bin/webpackでは実際にはyarn run webpack -- --config WEBPACK_CONFIGを実行している。
  • WEBPACK_CONFIGconfig/webpack/#{NODE_ENV}.jsとなっているため、config/webpack/development.jsなどとなる。

config/webpack/development.js

const sharedConfig = require('./shared.js')

module.exports = merge(sharedConfig, {
  // ...
})
  • config/webpack/shared.jsというファイルが環境ごとの設定ファイルでmergeされているようだ。

config/webpack/shared.js

const { env, settings, output, loaderDir } = require('./configuration.js')
  • settingsconfig/webpacker.ymlをロードしたオブジェクトを参照している。
    • settings.extensions: [.coffee, .erb, .js, .jsx, .ts, .vue, ...]
    • settings.source_path: app/javascript
    • settings.source_entry_path: packs
  • outputpathpublicPathというプロパティをもったオブジェクトを参照している。
    • path: public/packs
    • publicPath: ‘/packs’
      • ASSET_HOSTという環境変数を指定することでホストを変更できそう。
  • loadersDirconfig/webpack/loaders/を参照している。
const extensionGlob = `**/*{${settings.extensions.join(',')}}*`
const entryPath = join(settings.source_path, settings.source_entry_path)
const packPaths = sync(join(entryPath, extensionGlob))

module.exports = {
  entry: packPaths.reduce(
    // ...
  )
}
  • entryはwebpackによってbundleされる対象のファイルを設定する。
  • synchttps://github.com/isaacs/node-globからexportされている。同期的にglobサーチをしている。
  • ここでは、app/javascript/packs/**/*{.coffee,.erb,.js,.jsx}*のようなglobでファイルを検索し、マッチしたファイルのリストがpackPathsに代入されている。
  • つまり、app/javascript/packs/以下のsettings.extensionsで指定された拡張子をもつファイルがwebpackによってbundleされるということになる。
module.exports = {
  entry: packPaths.reduce(
    (map, entry) => {
      const localMap = map
      const namespace = relative(join(entryPath), dirname(entry))
      localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry)
      return localMap
    }, {}
  )
}
  • entryにオブジェクトが指定された場合、プロパティごとにbundleされるファイルが分割される。output.filename[name]と指定された箇所にプロパティ名が入る。
const { env, settings, output, loaderDir } = require('./configuration.js')

module.exports = {
  output: {
    filename: '[name].js',
    path: output.path,
    publicPath: output.publicPath
  }
}
  • outputはbundleされたファイルの出力先を設定する。
  • output.filenameでbundleされたファイル名を設定する。entryがオブジェクトで指定されているため、[name]にはオブジェクトの各プロパティ名が代入される。
  • output.pathは出力先のパスを設定する。上記の通りpublic/packsが設定されている。
  • output.publicPathは本番ビルド時のCSSやHTML内のURLを設定する。これは本番のみCDNを使う場合に便利。上述の通りこれは/packsが設定されているが、ASSET_HOSTという環境変数でこれを変更することができるようになっている。
module.exports = {
  module: {
    rules: sync(join(loadersDir, '*.js')).map(loader => require(loader))
  }
}
  • rulesはwebpackのモジュールを設定する。
  • config/webpack/loaders/*.jsにマッチするファイルを検索している。
  • マッチしたファイルをrequireしている。各ファイルは以下のようになっている。これによって、config/webpack/loaders/*.js内の設定を展開している。
module.exports = {
  test: /\.(jpg|jpeg|png|gif|svg|eot|ttf|woff|woff2)$/i,
  use: [{
    loader: 'file-loader',
    options: {
      publicPath,
      name: env.NODE_ENV === 'production' ? '[name]-[hash].[ext]' : '[name].[ext]'
    }
  }]
}
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const ManifestPlugin = require('webpack-manifest-plugin')

module.exports = {
  plugins: [
    new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(env))),
    new ExtractTextPlugin(env.NODE_ENV === 'production' ? '[name]-[hash].css' : '[name].css'),
    new ManifestPlugin({
      publicPath: output.publicPath,
      writeToFileEmit: true
    })
  ]
}
= stylesheet_pack_tag "application" # load /packs/application-xxxxxxxx.css
{
  "application.css": "/packs/application-xxxxxxxx.css"
}
module.exports = {
  resolve: {
    extensions: settings.extensions,
    modules: [
      resolve(settings.source_path),
      'node_modules'
    ]
  }
}
  • resolveはモジュール解決方法を設定する。webpackはデフォルトではいい感じに設定されている。
  • resolve.extensionsはファイル名からモジュールを解決する際に自動的に付与する拡張子を設定する。
  • resolve.modulesはモジュールを解決する際に検索されるディレクトリを設定する。

github.com/rails/webpacker/lib/webpacker/helper.rb

#stylesheet_pack_tagマニフェストファイルからどのようにアセットを参照するかを確認する。

def stylesheet_pack_tag(*names, **options)
  unless Webpacker.dev_server.running? && Webpacker.dev_server.hot_module_replacing?
    stylesheet_link_tag(*sources_from_pack_manifest(names, type: :stylesheet), **options)
  end
end

def sources_from_pack_manifest(names, type:)
  names.map { |name| Webpacker.manifest.lookup(pack_name_with_extension(name, type: type)) }
end

def pack_name_with_extension(name, type:)
  "#{name}#{compute_asset_extname(name, type: type)}"
end
  • #sources_from_pack_manifestマニフェストからアセットのファイル名を解決しているようだ。
  • ActionView::Helpers::AssetUrlHelper#compute_asset_extnameはファイル名とtypeから適切な拡張子を返す。
  • Webpacker.manifestWebpacker::Manifestインスタンスを返す。

github.com/rails/webpacker/lib/webpacker/manifest.rb

def lookup(name)
  compile if compiling?
  find name
end

def find(name)
  data[name.to_s] || handle_missing_entry(name)
end

def data
  if env.development?
    refresh
  else
    @data ||= load
  end
end

def refresh
  @data = load
end

def load
  if config.public_manifest_path.exist?
    JSON.parse config.public_manifest_path.read
  else
    {}
  end
end
  • #lookupマニフェストファイルの中身にアクセスしている。
  • マニフェストファイルの中身はJSON.parseした結果をメモリに保持している。開発環境ではアクセス毎にJSON.parseし直している。

Homebrew にコントリビュートした

あらすじ

前回、 naoty/todo というツールを Homebrew で配布しようとしたときにとある問題にハマった。 todo という名前を持つファイルが README.mdLICENSE のようなメタファイルとして判断されエラーが起きてしまうという問題だった。

Homebrew に Pull request を送った

前回は、メタファイルではない適当な名前の空ファイルを置いてこの問題を回避していたが、なんというか負けな気がしてきたので、 Homebrew に Pull request を送った。前回、コードを読んで何が原因なのかは把握していたので、修正すべきポイントもおおよそ見当がついていた。

当初は実行ファイルであればメタファイルではないという方針で Pull request を送ってみたが、パーミッションが正しく付与されていないものも稀にあるらしかった。そこで、コミッターのアドバイスを基にメタファイルは keg のルートディレクトリにしか存在しないだろうという前提で修正し、無事に merge された。

この修正によって、 todo, changelog, license といったメタファイルっぽい実行ファイルを配布したいときに Empty installation エラーによって失敗することはなくなった。また一つ、世界が便利になった。

Homebrewで自作Formulaを作るときの落とし穴

naoty/todo という CLI ツールを Homebrew で配布しようとしたときにハマったことを書く。

naoty/todo は Go で書かれており、コンパイル済みのバイナリを GitHub Releases にアップロードしてそこから配信したいと思っていた。ドキュメント等を調べると以下のように formula を書くことでインストールが完了するものと思っていた。

class Todo < Formula
  desc "A todo management tool just for myself"
  homepage "https://github.com/naoty/todo"
  url "https://github.com/naoty/todo/releases/download/0.2.0/todo.tar.gz"
  sha256 "be20e4069c0ae49998dfc00a010ca8f5d49d26193bd0c3e8611a4bf53cac469d"

  def install
    bin.install "todo"
  end
end

しかし、実際には Empty installation というエラーが発生してインストールができない現象に遭遇した。ドキュメントを調べてみるも、なぜこれが失敗するのか突き止めることはできなかった。そこで、エラーメッセージを頼りに Homebrew のソースコードを読むことにした。

まず、 Homebrew のソースコード/usr/local/Homebrew/Library/Homebrew にある。そこで ag で Empty installation というエラーメッセージを検索してみると、以下のようなコードを見つけることができた。

if !formula.prefix.directory? || Keg.new(formula.prefix).empty_installation?
  raise "Empty installation"
end

ここからは pry を使ってブレークポイントを貼りながら進めようと思った。 Homebrew はシステムの Ruby を使っているようなので、 システムの rubygems で pry をインストールし調査を続けた。

binding.pry で調べたところ、 empty_installation?true を返しているようだった。このメソッドの中身は以下のようになっていた。

def empty_installation?
  Pathname.glob("#{path}/**/*") do |file|
    next if file.directory?
    basename = file.basename.to_s
    next if Metafiles.copy?(basename)
    next if %w[.DS_Store INSTALL_RECEIPT.json].include?(basename)
    return false
  end

  true
end

さらにここでイテレーションされている file を調べると formula でインストールした todoREADME 等のファイルが含まれていた。ここで何が原因か調べてみると、どうやら以下のように todo が README や LICENSE といったメタファイルのひとつとして扱われていて、ここで true が返っているようだった。

BASENAMES = Set.new %w[
  about authors changelog changes copying copyright history license licence
  news notes notice readme todo
].freeze

ということは、メタファイルではないものがひとつでも存在すれば true が返るということなので、以下のような formula を定義して適当なファイルを置くことで、この問題を回避することができた。

def install
  bin.install "todo"

  # Avoid "Empty installation" error which will be caused when the only
  # "todo" file is installed.
  bin.install "empty"
end

この問題は licensechangelog といった名前のパッケージを配布する場合でも起こる。ソースコードを読まないと原因が分からないので、同じ問題に直面した人は不運という感じがする。

CocoaPodsにコントリビュートした

開発中のiOSアプリでCocoaPodsでインストールしたライブラリのライセンス表示を実装する際に、とある理由でライセンスに表示したくない状況があった。いろいろ調べたところ、CocoaPodsが出力するPods-{ProjectName}-Acknowledgements.plistMITといったライセンスタイプが含まれていないことがわかった(ライセンスのテキストはあるけど、そこから抽出するのは大変)。podspecにはライセンスタイプを記載する必要があるため、内部表現としてライセンスタイプをもっているはずだと思った。そこで、それをplistファイルに出力するようにするPull requestを送って、そしてmergeされた。

Pull requestしてみた感想としては、RSpecのようなよく知らないテスティングフレームワークを使っており、自力ではどこをテストすればいいのか分からず困惑した。コミッターの方が修正してくれたようなのでよかった。

1.1.0.beta.1に含まれているので、今後はMITなどライセンスタイプを基に表示するライブラリをフィルタリングできたりできると思う。よかったら利用してください。

Railsにコントリビュートした

軽い気持ちでpull requestを送ってみたら数十分後になんとmergeされてしまった。間違ってmasterブランチに送ってしまったため、おそらくrails 5で公開されることになる。

追加したのはrake initializerという簡単なrakeタスクで、railsの起動時に実行されるinitializerを実行順に出力する。

% rake initializer
set_load_path
set_load_path
set_load_path
set_load_path
set_autoload_paths
set_autoload_paths
set_autoload_paths
set_autoload_paths
add_routing_paths
add_routing_paths
add_routing_paths
add_routing_paths
add_locales
add_locales
add_locales
add_locales
add_view_paths
add_view_paths
add_view_paths
add_view_paths
load_environment_config
load_environment_config
load_environment_config
load_environment_config
load_environment_hook
load_active_support
set_eager_load
initialize_logger
initialize_cache
initialize_dependency_mechanism
bootstrap_hook
active_support.deprecation_behavior
prepend_helpers_path
prepend_helpers_path
prepend_helpers_path
prepend_helpers_path
load_config_initializers
load_config_initializers
load_config_initializers
load_config_initializers
active_support.halt_callback_chains_on_return_false
active_support.initialize_time_zone
active_support.initialize_beginning_of_week
active_support.set_configs
action_dispatch.configure
active_model.secure_password
action_view.embed_authenticity_token_in_remote_forms
action_view.logger
action_view.set_configs
action_view.caching
action_view.collection_caching
action_view.setup_action_pack
action_controller.assets_config
action_controller.set_helpers_path
action_controller.parameters_config
action_controller.set_configs
action_controller.compile_config_methods
active_record.initialize_timezone
active_record.logger
active_record.migration_error
active_record.check_schema_cache_dump
active_record.set_configs
brancher.rename_database
active_record.initialize_database
active_record.log_runtime
active_record.set_reloader_hooks
active_record.add_watchable_files
global_id
active_job.logger
active_job.set_configs
action_mailer.logger
action_mailer.set_configs
action_mailer.compile_config_methods
setup_sass
setup_compression
jbuilder
web_console.initialize
web_console.insert_middleware
web_console.templates_path
web_console.whitelisted_ips
web_console.whiny_requests
web_console.acceptable_content_types
engines_blank_point
engines_blank_point
engines_blank_point
engines_blank_point
append_assets_path
append_assets_path
append_assets_path
turbolinks
append_assets_path
add_generator_templates
ensure_autoload_once_paths_as_subset
add_builtin_route
build_middleware_stack
define_main_app_helper
add_to_prepare_blocks
run_prepare_callbacks
eager_load!
finisher_hook
set_routes_reloader_hook
set_clear_dependencies_hook
disable_dependency_loading

もともとは、naoty/brancherというrubygemを作るときに、railsの初期化プロセスにコードを差し込みたくてinitializerが実行される順番を確認するデバッグ用のコードを書いていたのがきっかけだった。似たようなrakeタスクにrake middlewareというものがあり、これがあるならrake initializerもあっていいだろうという軽い気持ちでpull requestを送ってみた。

意外とすんなりmergeしてもらったので、railsへのコントリビュートに対して心理的なハードルがかなり低くなった。上の出力を見てもらえればわかるとおり同じようなinitializerが実行されており、起動が遅くなったり、initializerで定数を定義したときに大量のwarningが出たりする。前々から定数のwarningが大量に出る問題は不思議に思っていたが、これが原因なのかもしれない。またコントリビュートするとしたら、ここらへんを解決するところになりそう。

ブランチごとにDB切り替えるヤツ作った

Gitのブランチ名をもとにActiveRecordが接続するDBを切り替えるRubygemを作った。

使い方

group :development do
  gem "brancher"
end

Gemfileに書いてbundle installするだけ。あとは自動的にブランチごとに別々のDBが使われるようになる。

config/database.ymlでdevelopment環境のDB名をsample_app_devと指定していた場合、masterブランチならsample_app_dev_masterが使われるし、some_featureブランチならsample_app_dev_some_featureが使われる。

問題意識

複数のブランチを移りながら開発していると、migrationを実行したブランチとしてないブランチでDBのスキーマが不整合になってエラーをおこすことがよくある。そのたびにrake db:migrateだったりrake db:resetだったりrake db:schema:loadしたりするのが非常に面倒だった。そういった問題を解決するためにブランチごとにDBを分けられるツールを作った。

どう実現しているか

やっていることはconfig/database.ymlをロードしたオブジェクトをいじっているだけ。これをいじるタイミングは2つある。Railsアプリケーションの初期化時とdb:load_configタスクだ。

Railsアプリケーションを初期化する際、ActiveRecord::Baseがロードされたあとにestablish_connectionが実行される。このメソッドはconfig/database.ymlに基いてDBとのコネクションを接続するものなので、これが実行される前にDB名をブランチ名に従っていじってあげる必要がある。実際に実行されているコードは以下の通りだ。

# lib/active_record/railtie.rb

initializer "active_record.initialize_database" do |app|
  ActiveSupport.on_load(:active_record) do
    self.configurations = Rails.application.config.database_configuration

    begin
      establish_connection
      # ...
    end
  end
end

Rails.application.config.database_configurationはconfig/database.ymlの中身をERBで展開してYAMLとしてロードしたHashオブジェクトだ。これがself.configurationsにセットされてestablish_connectionが実行される(ここでのselfActiveRecord::Base)。よって、この初期化処理が実行される前にRails.application.config.database_configurationをいじればいい。

初期化処理の一連の流れに割り込むにはRails::Initializable.initializerメソッドのオプションを使う。そして、その中でRails.application.config.database_configurationの中身を上書きする。

# lib/brancher/railtie.rb

initializer "brancher.rename_database", before: "active_record.initialize_database" do
  Rails::Application::Configuration.send(:prepend, DatabaseConfigurationRenaming)
end

次に、db:load_configタスク内でもconfig/database.ymlをいじる必要がある。なぜかというと、rake db:createなどの一部のRakeタスクは上述の初期化処理が実行されないからだ。environmentタスクに依存しているタスクであれば、environmentタスク内で初期化処理が行われるため問題ない。一方、db:load_configタスクは(おそらく)すべてのDBに関連するRakeタスクが依存しているため、ここでDB名をいじってあげればいい。

# lib/brancher/railtie.rb

rake_tasks do
  namespace :db do
    task :load_config do
      DatabaseRenameService.rename!(ActiveRecord::Base.configurations)
    end
  end
end

Rakeタスクは通常のメソッドとは異なり、同名のタスクを定義しても上書きされることはない。先に定義された順に同名のタスクが実行される。

所感

上のような初期化処理の仕組みやRakeタスクの追加などは以前のエントリなどでRailsの内部を読み理解を深めることによって実現することができた。ブラックボックスの中身が見えてくると、こういったRails自体に関わる便利ツールを簡単に作ることができてしまう。引き続きRailsソースコードリーディングは続けていきたい。

`rails s`読んだ

rails sRailsサーバーが起動するまでに何が起きているのかを紐解くことでRailsとは何なのかを理解していきたい。今回読んでいくソースコードのコミットは2d9b9fb5b5f6015e66d3ad5cb96bc1ba117fd626だ。

TL;DR

  • bin/rails sがユーザーによって実行される。
    • Gemfileで管理されるrubygemをrequireする。
    • Rails::CommandsTasks#serverを実行する。
      • config/application.rbをrequireする。
        • Railsを構成する各rubygemのrailtieをrequireする。
          • 各rubygemのinitializerが登録される。
      • config.ruが実行される。
        • 登録されたinitializerが実行される。
        • RailsアプリケーションがRackアプリケーションとして起動する。

コマンドの実行

まずbin/railsを見る。bin/railsrails newを実行したときに生成されるのだが、このひな形はrailties/lib/rails/generators/rails/app/templates/bin/railsにある。

# bin/rails

#!/usr/bin/env ruby
APP_PATH = File.expand_path('../../config/application', __FILE__)
require_relative '../config/boot'
require 'rails/commands'

config/boot.rbとrails/commands.rbを見る。

# config/boot.rb

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

require 'bundler/setup' # Set up gems listed in the Gemfile.
  • config/boot.rbはGemfileにあるgemをrequireするようだ。
# railties/lib/rails/commands.rb

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

require 'rails/commands/commands_tasks'

Rails::CommandsTasks.new(ARGV).run_command!(command)
  • rails sと実行するとaliasesの中から"server"という文字列を取得してrails serverを実行することになる。

rails/commands/commands_tasksを見る。

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

module Rails
  class CommandsTasks
    # ...

    def initialize(argv)
      @argv = argv
    end

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

    # ...
  end
end
  • #parse_command--version--helpをそれぞれ"version""help"というコマンドに変換するもの。それ以外はそのまま返す。
  • COMMAND_WHITELISTに含まれていれば実行、そうでなければエラーを出力する。
  • 今回は"server"commandに入るのでsend("server")が実行され、#serverが実行されることになる。
# railsties/lib/rails/commands/commands_tasks.rb

module Rails
  class CommandsTasks
    # ...

    def server
      set_application_directory!
      require_command!("server")

      Rails::Server.new.tap do |server|
        require APP_PATH
        Dir.chdir(Rails.application.root)
        server.start
      end
    end

    # ...
  end
end
  • #set_application_directory!はconfig.ruがないディレクトリからでもrails sを実行できるようにするためのものらしい。
  • APP_PATHbin/railsの中で代入されたconfig/application.rbなので、require "config/application"server.startの前に実行している。

設定の読み込み

# config/application.rb

require File.expand_path('../boot', __FILE__)

require 'rails/all'

Bundler.require(*Rails.groups)

module SampleApp
  class Application < Rails::Application
    config.active_record.raise_in_transactional_callbacks = true
  end
end
  • 前述の通り、config/boot.rbはrequire "bundler/setup"を実行しておりGemfile.lockに記載されたバージョンのrubygemをrequireしている。

rails/all.rbを見る。

# railties/lib/rails/all.rb

require "rails"

%w(
  active_record
  action_controller
  action_view
  action_mailer
  active_job
  rails/test_unit
  sprockets
).each do |framework|
  begin
    require "#{framework}/railtie"
  rescue LoadError
  end
end
  • Railsを構成する各rubygemのrailtieをrequireしている。

rails.rbを見る。

# railsties/lib/rails.rb

module Rails
  # ...

  class << self
    # ...
  end
end
  • ここにはRails.application, Rails.configuration, Rails.envなどの重要なメソッドが定義されているため、登場次第また見ていくことにする。

rails/all.rbとrails.rbについて見たので、config/applicationに戻る。

# config/application.rb

# ...

Bundler.require(*Rails.groups)

module SampleApp
  class Application < Rails::Application
    config.active_record.raise_in_transactional_callbacks = true
  end
end
  • Rails.groupsは上述したrails.rbで定義されているのでさっそく見る。
# railties/lib/rails.rb

module Rails
  class << self
    # ...

    def env
      @_env ||= ActiveSupport::StringInquirer.new(ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development")
    end

    # ...

    def groups(*groups)
      hash = groups.extract_options!
      env = Rails.env
      groups.unshift(:default, env)
      groups.concat ENV["RAILS_GROUPS"].to_s.split(",")
      groups.concat hash.map { |k, v| k if v.map(&:to_s).include?(env) }
      groups.compact!
      groups.uniq!
      groups
    end

    # ...
  end
end
  • Rails.groupsRails.envの値に合わせてBundlerが読みこむべきgroupを返す。
  • Rails.env環境変数"RAILS_ENV"または"RACK_ENV"から実行環境を返す。

config/application.rbに戻る。

# config/application.rb

# ...

module SampleApp
  class Application < Rails::Application
    config.active_record.raise_in_transactional_callbacks = true
  end
end
  • SampleApp::ApplicationRails::Applicationを継承するとき、以下のような実装によってRails::Application.inheritedが呼ばれ、Rails.app_classSampleApp::Applicationとなる。
# railties/lib/rails/application.rb

module Rails
  class Application < Engine
    class << self
      def inherited(base)
        super
        Rails.app_class = base
        add_lib_to_load_path!(find_root(base.called_from))
      end
    end
  end
end

サーバーの起動

サーバー起動前にどういった設定を読み込んでいるか見たので、サーバーの起動について詳細に見ていく。

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

module Rails
  class CommandsTasks
    # ...

    def server
      set_application_directory!
      require_command!("server")

      Rails::Server.new.tap do |server|
        require APP_PATH
        Dir.chdir(Rails.application.root)
        server.start
      end
    end

    # ...
  end
end
  • #require_command!("server")require "rails/commands/server"をしている。

Rails::Serverrails/commands/server.rbで定義されているので見る。

# railsties/lib/rails/commands/server

module Rails
  class Server < ::Rack::Server
    # ...

    def initialize(*)
      super
      set_environment
    end

    # ...

    def start
      print_boot_information
      trap(:INT) { exit }
      create_tmp_directories
      log_to_stdout if options[:log_stdout]

      super
    ensure
      puts 'Exiting' unless @options && options[:daemonize]
    end

    # ...
  end
end
  • スーパークラス::Rack::Serverがサーバー起動において主な役割を果たしているようだ。
  • これ以降はRackのソースコードを追うことになるが本題から反れるので、結論だけ言うとconfig.ruが実行されることになる。

config.ruを見る。

# config.ru

require ::File.expand_path('../config/environment', __FILE__)
run Rails.application
  • config/environment.rbを読み込んでいる。
  • その後Rails.applicationをrackアプリケーションとして実行している。

とりあえずconfig/environment.rbを見る。

# config/environment.rb

require File.expand_path('../application', __FILE__)
Rails.application.initialize!
  • config/application.rbは既に読み込まれているはず。

Rails.application.initialize!について見ていくため、まずはRails.applicationの定義を見る。

# railties/lib/rails.rb

module Rails
  class << self
    def application
      @application ||= (app_class.instance if app_class)
    end
  end
end
  • app_classは、config/application.rbでRails::Applicationのサブクラスが定義されたときにそのサブクラスが代入される。
  • SampleApp::Application.instanceが呼ばれているが、これのメソッドRails::Applicationに定義されていると思われる。

Rails::Applicationを見る。

# railties/lib/rails/application.rb

module Rails
  class Application < Engine
    class << self
      def instance
        super.run_load_hooks!
      end
    end

    def run_load_hooks!
      return self if @ran_load_hooks
      @ran_load_hooks = true
      ActiveSupport.run_load_hooks(:before_configuration, self)

      @initial_variable_values.each do |variable_name, value|
        if INITIAL_VARIABLES.include?(variable_name)
          instance_variable_set("@#{variable_name}", value)
        end
      end

      instance_eval(&@block) if @block
      self
    end
  end
end
  • SampleApp::Application.instance内でsuper.run_load_hooks!が呼ばれている。このsuperスーパークラスで定義された.instanceを呼んでおり、スーパークラスをたどるとRails::Railtie.instanceが呼ばれていることがわかる。これはそのままnewを呼んでインスタンスを返すだけだ。なので、super.run_load_hooks!というのはSampleApp::Application#run_load_hooks!を指す。
  • SampleApp::Application#run_load_hooks!Rails::Applicationで上のように定義されており自分自身を返す。中でActiveSupport.run_load_hooks(:before_configuration, self)を呼んでおり、これによって:before_configurationをフックとして登録しておいた処理が実行される。
  • 結局、Rails.applicationSampleApp::Applicationインスタンスということになる。

initializerの実行

config/environment.rbに戻る。

# config/environment.rb

require File.expand_path('../application', __FILE__)
Rails.application.initialize!
  • Rails.application.initialize!はつまりSampleApp::Application#initialize!ということ。

Rails::Applicationを見る。

# railties/lib/rails/application.rb

module Rails
  class Application
    def initialize!(group=:default) #:nodoc:
      raise "Application has been already initialized." if @initialized
      run_initializers(group, self)
      @initialized = true
      self
    end
  end
end
  • #run_initializersRails::Initializableで以下のように定義されている。
# railties/lib/rails/initializable.rb

module Rails
  module Initializable
    def run_initializers(group=:default, *args)
      return if instance_variable_defined?(:@ran)
      initializers.tsort_each do |initializer|
        initializer.run(*args) if initializer.belongs_to?(group)
      end
      @ran = true
    end
  end
end
  • #initializersRails::Applicationで以下のように定義されている。
# railties/lib/rails/application.rb

module Rails
  class Application
    def initializers
      Bootstrap.initializers_for(self) +
      railties_initializers(super) +
      Finisher.initializers_for(self)
    end
  end
end
  • Bootstrap.initializers_forとかFinisher.initializers_forRails::Initializableモジュールで以下のように定義されている。
# railties/lib/rails/initializable.rb

def initializers_for(binding)
  Collection.new(initializers_chain.map { |i| i.bind(binding) })
end
  • Initializable::Collectionを初期化しているようだ。
# railties/lib/rails/initializable.rb

class Collection < Array
  include TSort

  alias :tsort_each_node :each
  def tsort_each_child(initializer, &block)
    select { |i| i.before == initializer.name || i.name == initializer.after }.each(&block)
  end

  def +(other)
    Collection.new(to_a + other.to_a)
  end
end
  • TSortモジュールはRubyの標準モジュールで、依存関係を解決する順番に並び替える(=トポロジカルソート)実装を提供する。#tsort_each_node#tsort_each_childの2つを実装する必要がある。
  • #tsort_each_nodeはすべての要素を走査するメソッド#tsort_each_childは子要素を走査するメソッド
  • Collection#tsort_each_childでは与えられたinitializerの前後のinitlaizerに対してブロックを実行する。

Initializable.initializers_forに戻る。

# railties/lib/rails/initializable.rb

def initializers_chain
  initializers = Collection.new
  ancestors.reverse_each do |klass|
    next unless klass.respond_to?(:initializers)
    initializers = initializers + klass.initializers
  end
  initializers
end

def initializers_for(binding)
  Collection.new(initializers_chain.map { |i| i.bind(binding) })
end
  • 続いて.initializers_chainを見る。
  • .ancestorsスーパークラスincludeしているモジュールを直接の親から順に並べて配列で返す。
  • .ancestors.reverse_eachなので、最も遠いクラスまたはモジュールから順にinitializersを取得して一つのCollectionに連結させている。

initializers_chainの要素はおそらくInitializerインスタンスだと思われるので、Initializer#bindを見る。

# railties/lib/rails/Initializable.rb

class Initializer
  def bind(context)
    return self if @context
    Initializer.new(@name, context, @options, &block)
  end
end
  • Initializer#initializeの第2引数は、Initializer#runinstance_execのレシーバとして利用される。今回の場合、これはRails::Applicationインスタンスとなる。

Rails::Application#initializersに戻る。

# railties/lib/rails/application.rb

module Rails
  class Application
    def initializers
      Bootstrap.initializers_for(self) +
      railties_initializers(super) +
      Finisher.initializers_for(self)
    end
  end
end

#railties_initializersがまだ残っているので見る。

# railties/lib/rails/application.rb

module Rails
  class Application
    def ordered_railties
      @ordered_railties ||= begin
        order = config.railties_order.map do |railtie|
          # ...
        end

        all = (railties - order)
        all.push(self)   unless (all + order).include?(self)
        order.push(:all) unless order.include?(:all)

        index = order.index(:all)
        order[index] = all
        order
      end
    end

    def railties_initializers(current)
      initializers = []
      ordered_railties.reverse.flatten.each do |r|
        if r == self
          initializers += current
        else
          initializers += r.initializers
        end
      end
      initializers
    end
  end
end
  • config.railties_orderはデフォルトでは[:all]を返すようになっているが、ここを変更することで実行するRailtieのinitializerの順番を変更することもできるようだ。
  • #ordered_railtiesが返すのはall = (railties - order)の部分なので、あとで詳しく#railtiesについて見る。
  • ある順番でソートされた各Railtieのinitializersを結合して返している。

#railtiesRails::Engineから継承されたメソッドなので見る。

# railties/lib/rails/engine.rb

def railties
  @railties ||= Railties.new
end

Rails::Engine::Railtiesを見る。

# railties/lib/rails/engine/railties.rb

def initialize
  @_all ||= ::Rails::Railtie.subclasses.map(&:instance) +
    ::Rails::Engine.subclasses.map(&:instance)
end
  • ::Rails::RailtieまたはRails::Engineのサブクラスをすべて返している(!)

railties_initializersに戻る。

# railties/lib/rails/application.rb

def railties_initializers(current)
  initializers = []
  ordered_railties.reverse.flatten.each do |r|
    if r == self
      initializers += current
    else
      initializers += r.initializers
    end
  end
  initializers
end
  • ordered_railties::Rails::Railtieまたは::Rails::Engineのサブクラスすべてだということがわかった。
  • よって、それらのinitializersをすべて連結したものを返していることになる。

Rails::Application#initializersについて見たので、#run_initializersに戻る。

# railties/lib/rails/initializable.rb

def run_initializers(group=:default, *args)
  return if instance_variable_defined?(:@ran)
  initializers.tsort_each do |initializer|
    initializer.run(*args) if initializer.belongs_to?(group)
  end
  @ran = true
end
  • initializersInitializable::Collectionインスタンスなので、#tsort_eachによって依存関係を解決する順番で#eachを行う。

Initializer#runを見る。

# railties/lib/rails/initializable.rb

class Initializer
  def run(*args)
    @context.instance_exec(*args, &block)
  end
end
  • @context#bindによってセットされる。
  • blockInitializerが初期化される際に渡されたブロックだ。このblock#bindによってセットされた@contextをレシーバとして実行される。
  • 今回の場合、@contextRails::Applicationインスタンスをレシーバとしてblockが実行されることになる。

続いて、実行されるInitializerがどのように初期化されて登録されているのか見ていく。これはRails::Initializable::ClassMethods.initializerによって行われる。

# railties/lib/rails/initializable.rb

def initializer(name, opts = {}, &blk)
  raise ArgumentError, "A block must be passed when defining an initializer" unless blk
  opts[:after] ||= initializers.last.name unless initializers.empty? || initializers.find { |i| i.name == opts[:before] }
  initializers << Initializer.new(name, nil, opts, &blk)
end
  • opts[:after]Initializerインスタンス間の依存関係の解決に利用される。initializerで特に指定しなければ既存のinitializersの最後の要素がinitializer#afterになる。

Railsアプリケーションの起動

initializersの実行について一通り眺めたのでこれでconfig/environment.rbを読んだことになる。config.ruに戻る。

# config.ru

require ::File.expand_path('../config/environment', __FILE__)
run Rails.application
  • ようやくrun Rails.applicationでアプリケーションを起動できる。
  • Rails.applicationSampleApp::Applicationインスタンスであることは上述の通り。

参考