RailsのReloaderの仕組み
Resqueのworker上で実行されるコードが古いまま更新されないというような問題があって、意味もわからず書いたコードでなんとかその場を収めたんだけど、気持ち悪いのでRailsでいかにしてコードが更新されるのか調べてみることにした。
TL;DR
未定義の定数が参照されると、ActiveSupport::ModuleConstMissingによって拡張されたModule#const_missingが呼ばれる。命名規則に基いて定数名からファイル名が推測され、autoload_pathsに存在するファイルを見つける。そのファイルがロード済みであればそこで終了。ロード済みでなければKernel.loadでロードし、ロードされた定数は配列で管理される。
development環境では、Railsのmiddleware stackにActionDispatch::Reloaderというmiddlewareがあり、リクエストごとにファイルの最終更新日時を確認し、変更されていればActiveSupport::Dependencies.clearを呼ぶ。これによって、ロード済みのファイルが空っぽになり、ロードされた定数もすべて削除される(=未定義状態に戻る)。なので、ファイルが変更されるたびにconst_missingから始まる一連のフローが起こり、Kernel.loadによって最新のコードがロードされるようになっている。
以下は、上述の結論に至るまでのソースコードリーディングのメモです。分かりにくいかも。
ActionDispatch::Reloader
ActionDispatch::ReloaderはRackミドルウェアなので、#callの中で初期化時に受け取った他のRackミドルウェアの#callを呼んでいる。その前後でリロードに関する処理を実行しているはずだ。
def call(env)
@validated = @condition.call
prepare!
response = @app.call(env)
response[2] = ::Rack::BodyProxy.new(response[2]) { cleanup! }
response
rescue Exception
cleanup!
raise
end
def prepare!
run_callbacks :prepare if validated?
end
def cleanup!
run_callbacks :cleanup if validated?
ensure
@validated = true
end
次のRackミドルウェアが処理を行う前後でprepare!とcleanup!を呼んでいる。その中身はrun_callbacksを呼んでいる。これはActiveSupport::Callbacksで定義されているメソッドで、set_callbacksで登録されたcallbackを実行する。なので、:prepareと:cleanupというイベントに対してどこかで登録されたcallbackがされている。このcallbackの登録を行うメソッドもActionDispatch::Reloaderに含まれている。
def self.to_prepare(*args, &block)
# ...
set_callbacks(:prepare, *args, &block)
end
def self.to_cleanup(*args, &block)
# ...
set_callbacks(:cleanup, *args, &block)
end
この2つのメソッドを使ってcallbackの登録が行われている。これらを呼び出している箇所を探すと、Rails::Application::Finisherで呼ばれていることがわかる。
Rails::Application::Finisher
initializer :set_clear_dependencies_hook, group: :all do
callback = lambda do
ActiveSupport::DescendantsTracker.clear
ActiveSupport::Dependencies.clear
end
if config.reload_classes_only_on_change
reloader = config.file_watcher.new(*watchable_args, &callback)
self.reloaders << reloader
ActionDispatch::Reloader.to_prepare(prepend: true) do
reloader.execute
end
else
ActionDispatch::Reloader.to_cleanup(&callback)
end
end
initializerはRails::Initializableで定義されているメソッドでRailsの初期化時に実行される処理を登録することができる。つまり、Railsの初期化時に:prepareまたは:cleanupのcallbackを登録しているということになる。
reload_classes_only_on_changeという設定はデフォルトでtrueになっていて、依存するファイルが変更されたときだけクラスを再読み込みするかどうかを制御する。file_watcherはデフォルトではActiveSupport::FileUpdateCheckerを指している。つまり、デフォルトでは、:prepareのときにActiveSupport::FileUpdateChecker#executeが実行されるように設定されていることになる。
ActiveSupport::FileUpdateCheckerは初期化時に渡されたファイルを配列として受け取り、またそれらが更新されたときに実行されるブロックを受け取る。#executeはファイルが更新されているかどうかに関わらずブロックを実行する。ここで実行されるのは、以下のブロックとなる。
callback = lambda do
ActiveSupport::DescendantsTracker.clear
ActiveSupport::Dependencies.clear
end
ここまでをまとめると、リクエストを受けるごとに上の2つのメソッドが実行されコードのリロードが行われるということになる。
ActiveSupport::DescendantsTracker
def clear
if defined? ActiveSupport::Dependencies
@@direct_descendants.each do |klass, descendants|
if ActiveSupport::Dependencies.autoloaded?(klass)
@@direct_descendants.delete(klass)
else
descendants.reject! { |v| ActiveSupport::Dependencies.autoloaded?(v) }
end
end
else
@@direct_descendants.clear
end
end
@@direct_descendantsの中身を消去しているようだ。これはHashであり、中身がキーがクラスで、値がそのクラスを継承したサブクラスの配列となっている。Class#inheritedをoverrideしており、ActiveSupport::DescendantsTrackerをextendしているクラスを継承したタイミングで@@direct_descendantsに追加される。ActiveSupport::DescendantsTrackerは例えばActiveRecord::Baseでextendされているため、ActiveRecord::Baseのサブクラス、つまり通常のModelクラスはActiveRecord::Base.descendantsから取得できる。これを利用しているのが先述したActiveSupport::Callbacksで、callbackをサブクラスから親クラスへ辿っていくときに利用されている。
ActiveSupport::Dependencies
def clear
log_call
loaded.clear
loading.clear
remove_unloadable_constants!
end
loadedとloadingはクラス変数@@loadedおよび@@loadingへのアクセサでmattr_accessorによって定義されている。そして、これらのクラス変数の実体はSetオブジェクトだ。
次にloadedとloadingにいつ何が追加されるのか調べると、ActiveSupport::Dependencies.require_or_loadというメソッドで呼ばれている。
def require_or_load(file_name, const_path = nil)
# ...
file_name = $` if file_name =~ /\.rb\z/
expanded = File.expanded_path(file_name)
return if loaded.include?(expanded)
loaded << expanded
loading << expanded
begin
if load?
# ...
load_args = ["#{file_name}.rb"]
load_args << const_path unless const_path.nil?
if !warnings_on_first_load or history.include?(expanded)
result = load_file(*load_args)
else
enable_warnings { result = load_file(*load_args) }
end
else
# ...
end
rescue Exception
loaded.delete expanded
raise
ensure
loading.pop
end
history << expanded
result
end
loadedとloadingに追加されているのはおそらくロードするファイルの絶対パスと思われる。そして、一連のロードが完了したらloadingからは削除されるようだ。loadingは読み込み中の再読み込みを防ぐために一時的に利用される変数らしい。一方、loadedは既にロード済みかどうかをチェックして、ロード済みであればrequire_or_loadを中断させるために使われているようだ。実際のロードの処理はload_fileで行われるようだ。ActiveSupport::Dependencies.clearによってloadedが空になると、require_or_load内で再度load_fileを実行することになる。
def load_file(path, const_paths = loadable_constants_for_path(path))
# ...
const_paths = [const_paths].compact unless const_paths.is_a? Array
parent_paths = const_paths.collect { |const_path| const_path[/.*(?=::)/] || ::Object }
result = nil
newly_defined_paths = new_constants_in(*parent_paths) do
result = Kernel.load path
end
autoloaded_constants.concat newly_defined_paths unless load_once_path?(path)
autoloaded_constants.uniq!
# ...
result
end
const_pathsはapp/models/user.rbとapp/controllers/users_controller.rbいうファイルがあれば"User"と"UsersController"という表す文字列を含む配列となる。parent_pathsはconst_pathsの中で"Admin::UsersController"のようなネストするものと"::Object"を抽出した配列となる。new_constants_inは渡したブロックを実行し、その中で新たにロードされた定数を返す。なので、Kernel.loadがとりあえず実行されるようだ。
話を少し戻してrequire_or_loadはどこで呼ばれているかを調べると、load_missing_constantで呼ばれており、さらにこのメソッドはActiveSupport::ModuleConstMissing#const_missingで呼ばれている。
module ModuleConstMissing
# ...
def const_missing(const_name)
from_mod = anonymous ? guess_for_anonymous(const_name) : self
Dependencies.load_missing_constant(from_mod, const_name)
end
end
そしてこのmoduleは以下のようにしてModuleクラスにincludeされるため、デフォルトのconst_missingの挙動をoverrideすることになる。
module ActiveSupport
module Dependencies
# ...
def hook!
Object.class_eval { include Loadable }
Module.class_eval { include ModuleConstMissing }
Exception.class_eval { include Blamable }
end
# ...
end
end
ActiveSupport::Dependencies.hook!
つまり、定義されていない定数を参照する→const_missing→load_missing_constant→require_or_load→load_fileという順番で呼ばれることになる。
ここで、ActiveSupport::Dependencies.clearの定義に戻る。
def clear
log_call
loaded.clear
loading.clear
remove_unloadable_constants!
end
見ていなかったremove_unloadable_constants!について見ていく。
def remove_unloadable_constants!
autoloaded_constants.each { |const| remove_constant const }
autoloaded_constants.clear
Reference.clear!
explicitly_unloadable_constants.each { |const| remove_constant const }
end
autoloaded_constantsは上述のload_fileでロードされた定数を含む配列だ。remove_constantはその名の通り定数を削除するメソッドで内部でModule#remove_constを呼んでいる。