定数の自動読み込みと再読み込み (Zeitwerk)

本書ではZeitwerkモードでの自動読み込み(オートロード)および再読み込みの仕組みについて説明します。

このガイドの内容:

  • 自動読み込みのモード
  • 関連するRails設定
  • プロジェクトの構造
  • 自動読み込み、再読み込み、eager loading
  • STI(単一テーブル継承)
  • その他

1 はじめに

本ガイドでは、Rails 6.0で新たに導入されたZeitwerkモードの自動読み込みについて解説します。Rails 5.2 以前のClassicモードについては、定数の自動読み込みと再読み込み (Classic) を参照してください。

通常のRubyプログラムのクラスであれば、依存関係のあるプログラムを明示的に読み込む必要があります。たとえば、以下のコントローラではApplicationControllerクラスやPostクラスを用いており、通常、それらを呼び出すにはrequireする必要があります。

# Railsではこのように書かないこと
require "application_controller"
require "post"
# Railsではこのように書かないこと

class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

Railsアプリケーションでは上のようなことはしません。アプリケーションのクラスやモジュールはどこででも利用できます。

class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

通常のRailsアプリケーションでrequire呼び出しを行うのは、libディレクトリにあるものや、Ruby標準ライブラリ、Ruby gemなどを読み込むときだけです。そのため、これらのような自動読み込みパスに属さないものについてはすべて後述します。

Railsではこの機能を提供するため、いくつものZeitwerkローダーを開発者の代わりに管理しています。

2 プロジェクトの構造

Railsアプリケーションで使うファイル名は、そこで定義されている定数名と一致しなければなりません。ファイル名はディレクトリ名と合わせて名前空間として振る舞います。

たとえば、app/helpers/users_helper.rbファイルではUsersHelperを定義すべきですし、app/controllers/admin/payments_controller.rbではAdmin::PaymentsControllerを定義すべきです。

Railsは、デフォルトでファイル名をString#camelizeで活用するようZeitwerkを設定します。たとえば、app/controllers/users_controller.rbは以下のためにUsersControllerという定数を定義します。

"users_controller".camelize # => UsersController

このような活用形をカスタマイズする方法については、本ガイドの「活用形をカスタマイズする」で後述します。

詳しくはZeitwerkのドキュメントを参照してください。

3 config.autoload_paths

自動読み込みパス(autoload path)とは、その中身が自動読み込みの対象となるアプリケーションディレクトリ(app/modelsなど)のリストを指します。これらのディレクトリはルート名前空間Objectを表します。

Zeitwerkのドキュメントでは自動読み込みのパスをルートディレクトリと呼んでいますが、本ガイドでは「自動読み込みパス」と呼びます。

自動読み込みパスの下にあるファイル名は、Zeitwerkのドキュメントに記載されているとおりに定義された定数と一致しなければなりません。

デフォルトでは、あるアプリケーションの自動読み込みパスは次のもので構成されています。アプリケーションの起動時にappの下にあるすべてのサブディレクトリ(assetsjavascriptsviewsは除外)と、アプリケーションが依存する可能性のあるエンジンの自動読み込みパスです。

たとえば、app/helpers/users_helper.rbUsersHelperが実装されていれば、そのモジュールは以下のように自動読み込み可能になります。したがってrequire呼び出しは不要です(し、書くべきではありません)。

$ rails runner 'p UsersHelper'
UsersHelper

自動読み込みパスは、appの下のあらゆるカスタムディレクトリを自動的に扱います。たとえば、アプリケーションにapp/presentersがあると、自動読み込みの設定を変更しなくてもapp/presentersの下にあるものをすぐ利用できます。

デフォルトの自動読み込みパスの配列は、config/application.rbまたはconfig/environments/*.rbで以下のようにconfig.autoload_pathsに追加することで拡張可能です。

module MyApplication
  class Application < Rails::Application
    config.autoload_paths << "#{root}/extras"
  end
end

また、Railsエンジンはエンジンクラスの本体に独自のconfig/environments/*.rbを追加することも可能です。

ActiveSupport::Dependencies.autoload_pathsはくれぐれも改変しないでください。自動読み込みパスを変更するpublicなインターフェイスはconfig.autoload_pathsの方です。

アプリケーションの起動中は、自動読み込みパス内のコードは自動で読み込まれません(特にconfig/initializers/*.rbの中)。正しい方法については、後述のアプリケーション起動時の自動読み込みを参照してください。

自動読み込みパスは、Rails.autoloaders.mainオートローダーによって管理されます。

4 $LOAD_PATH

自動読み込みパスはデフォルトで$LOAD_PATHに追加されます。ただし、Zeitwerkの内部では絶対ファイル名が使われますし、アプリケーションで自動読み込み可能なファイルをrequireすべきではありませんので、$LOAD_PATHに追加されたこれらのディレクトリは実際には不要です。この動作は以下のフラグで無効にできます。

config.add_autoload_paths_to_load_path = false

こうすることで探索量が削減されて、正しいrequire呼び出しがわずかに高速化される可能性があります。また、アプリケーションでBootsnapを使っている場合も、ライブラリの不要なインデックス構築や、必要なメモリ量が節約されます。

5 config.autoload_once_paths

クラスやモジュールを再読み込みせずに自動読み込みできるようにしたい場合があります。autoload_once_pathsには、自動読み込みは可能だが再読み込みされないコードを保存します。

このコレクションはデフォルトでは空ですが、config.autoload_once_pathsに追加する形で拡張可能です。たとえば以下はconfig/application.rbまたはconfig/environments/*.rbで実行できます。

module MyApplication
  class Application < Rails::Application
    config.autoload_once_paths << "#{root}/app/serializers"
  end
end

また、Railsエンジンはエンジンクラスの本体に独自のconfig/environments/*.rbを追加することも可能です。

app/serializersconfig.autoload_once_pathsに追加すると、app/の下のカスタムディレクトリであってもRailsはこれを自動読み込みパスとみなさなくなります。この設定を行うと、このルールが上書きされます。

これは、Railsフレームワーク自体のような、再読み込みが可能な場所でキャッシュされるクラスやモジュールで重要になります。

たとえば、Active JobシリアライザをActive Jobの中に保存するとします。

# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer

再読み込みが発生しても、Active Jobそのものは再読み込みされず、自動読み込みパスにあるアプリケーションコードとエンジンコードのみが再読み込みされます。

このMoneySerializerを再読み込み可能にすると、改変バージョンを再読み込みしてもActive Job内に保存されるそのクラスのオブジェクトに反映されないので、混乱が生じる可能性があります。実際にMoneySerializerを再読み込み可能にすると、Rails 7以降ではそのようなイニシャライザでNameErrorが発生します。

別のユースケースは、以下のようにフレームワークのクラスをdecorateするエンジンの場合です。

initializer "decorate ActionController::Base" do
  ActiveSupport.on_load(:action_controller_base) do
    include MyDecoration
  end
end

この場合、イニシャライザが実行される時点ではMyDecorationに保存されているモジュールオブジェクトがActionController::Baseの先祖となるので、MyDecorationを再読み込みしても先祖への継承チェイン(ancestor chain)に反映されず、無意味です。

autoload_once_pathsのクラスやモジュールはconfig/initializersで自動読み込み可能です。すなわち、以下の設定を行うことで動くようになります。

# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer

技術的には、onceオートローダーによって管理されるクラスやモジュールは、:bootstrap_hookより後に実行される任意のイニシャライザで自動読み込みが可能です。

autoload_once_pathsは、Rails.autoloaders.onceで管理されます。

6 $LOAD_PATH{#load_path}

自動読み込みパスは、デフォルトで$LOAD_PATHに追加されます。ただしZeitwerkは内部で絶対ファイル名を用いており、アプリケーション内では自動読み込み可能なファイルをrequire呼び出しすべきではないため、それらのディレクトリは実際には不要です。以下のフラグを用いることで、$LOAD_PATHに自動読み込みパスを追加しないようにできます。

config.add_autoload_paths_to_load_path = false

こうすることで探索が削減され、正当なrequireが少し速くなる可能性もあります。また、アプリケーションでBootsnapを使っている場合は、このライブラリが不要なインデックスを構築しなくても済むため、必要なRAM容量を節約できます。

7 再読み込み

Railsアプリケーションのファイルが変更されると、クラスやモジュールを自動的に再読み込みします。

正確に言うと、Webサーバーが実行中の状態でアプリケーションのファイルが変更されると、Railsは次のリクエストが処理される直前に、mainオートローダが管理しているすべての定数をアンロードします。これによって、アプリケーションでリクエスト継続中に使われるクラスやモジュールが自動読み込みされるようになり、続いてファイルシステム上の現在の実装が反映されます。

再読み込みは有効にも無効にもできます。この振る舞いを制御するのはconfig.cache_classes設定です。これはdevelopmentモードではデフォルトでfalse(再読み込みが有効)、productionモードではtrue(再読み込みが無効)になります。

デフォルトのRailsは、変更されたファイルをイベンテッドファイルモニタで検出しますが、自動読み込みパスを調べてファイル変更を検出することも可能です。これは、config.file_watcherの設定で制御されます。

Railsコンソールでは、 config.cache_classesの値にかかわらずファイルウォッチャーは動作しません。通常、コンソールセッションの最中に再読み込みが行われると混乱を招く可能性があります。一般にコンソールセッションは、 個別のリクエストと同様に変化しない、一貫したアプリケーションクラスとモジュールのセットによって提供されることが望まれます。

ただし、コンソールでreload!を実行することで強制的に再読み込みできます。

irb(main):001:0> User.object_id
=> 70136277390120
irb(main):002:0> reload!
Reloading...
=> true
irb(main):003:0> User.object_id
=> 70136284426020

上のように、User定数に保存されているクラスオブジェクトは、再読み込み後に変化します。

7.1 古くなったオブジェクトの再読み込み

Rubyには、メモリ上のクラスやモジュールを真の意味で再読み込みする手段もなければ、既に利用されているすべてのクラスやモジュールに再読み込みを反映する手段もないことを理解しておくことが、きわめて重要です。技術的には、Userクラスを「アンロード」することは、Object.send(:remove_const, "User")User定数を削除するということです。

たとえば、Railsコンソールセッションで以下をチェックしてみます。

irb> joe = User.new
irb> reload!
irb> alice = User.new
irb> joe.class == alice.class
=> false

joeは元のUserクラスのインスタンスです。再読み込みが発生すると、このUser定数はそれまでと異なる、再読み込みされたクラスとして評価されます。aliceは新たに読み込んだUserクラスのインスタンスですが、joeのクラスはそうではなく、クラスが古くなっています(stale)。この場合はreload!を再度呼び出す代わりに、joeを再度定義するか、IRBサブセッションを起動するか、単に新しいコンソールセッションを起動することでも解決します。

また、再読み込み可能なクラスを、再読み込みされない場所でサブクラス化している場合にもこの問題が発生する可能性があります。

# lib/vip_user.rb
class VipUser < User
end

Userが再読み込みされてもVipUserは再読み込みされないので、VipUserのスーパークラスは元の古いクラスのオブジェクトになります。

結論: 再読み込み可能なクラスやモジュールをキャッシュしてはいけません

8 アプリケーション起動時の自動読み込み

起動中のアプリケーションは、onceオートローダが管理するautoload_once_pathsからの自動読み込みが可能です(詳しくは前述のconfig.autoload_once_pathsを参照)。

ただし、mainオートローダが管理している自動読み込みパスからの自動読み込みはできません。これは、config/initializersにあるコードや、アプリケーションやエンジンのイニシャライズについても同様です。

その理由は、イニシャライザはアプリケーション起動時に1度しか実行されないためです。サーバーを再起動すれば、新しいプロセスで再度実行されますが、再読み込みはサーバーを再起動しないので、イニシャライザは実行されません。主な2つのユースケースを見てみましょう。

8.1 ユースケース1: 起動中に、再読み込み可能なコードを読み込む

mainオートローダーが管理しているapp/servicesApiGatewayという再読み込み可能なクラスがあるとします。そしてアプリケーション起動時にエンドポイントを設定する必要が生じたとします。

# config/initializers/api_gateway_setup.rb
ApiGateway.endpoint = "https://example.com" # やってはいけない

上のコードは再実行されないので、再読み込みされたApiGatewayのエンドポイントはnilになるでしょう。

起動時にセットアップを行うことは引き続き可能ですが、以下のようにそれらをto_prepareブロックでラップする必要があります。この部分は起動時にも再読み込みのたびにも読み込まれます。

# config/initializers/api_gateway_setup.rb
Rails.application.config.to_prepare do
  ApiGateway.endpoint = "https://example.com" # 正しい
end

歴史的な理由により、このコールバックは2回実行される可能性があります。すなわち、ここで実行するコードは冪等でなければなりません。

8.2 ユースケース2: 起動中に、キャッシュされたままのコードを読み込む

設定によっては、何らかのクラスやモジュールのオブジェクトを受け取って、それを再読み込みされない場所に保存することがあります。

そうした例の1つがミドルウェアです。

config.middleware.use MyApp::Middleware::Foo

再読み込みを行っても、このミドルウェアスタックには反映されません。すなわち、起動時にMyApp::Middleware::Fooにどんなオブジェクトを保存しても、変更されずに古びてしまいます。

別の例は、Active Jobシリアライザです。

# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer

初期化時のMoneySerializerの評価値がどんなものであっても、このカスタムシリアライザにプッシュされます。これが再読み込み可能な場合、最初のオブジェクトはActive Job内に残り続け、変更は反映されません。

さらに別の例は、Railtieやエンジンがモジュールをincludeしてフレームワークのクラスをdecorateする場合です。たとえば、turbo-railsActiveRecord::Baseを以下のようにdecorateします。

initializer "turbo.broadcastable" do
  ActiveSupport.on_load(:active_record) do
    include Turbo::Broadcastable
  end
end

これにより、ActiveRecord::Baseの先祖への継承チェインにモジュールオブジェクトが追加されます。しかし再読み込みが発生してもTurbo::Broadcastableの変更は反映されないので、先祖への継承チェインには元のオブジェクトが引き続き残ります。

結論: こうしたクラスやモジュールは再読み込み可能にできません

そのようなクラスやモジュールを起動時に参照する最も手軽な方法は、自動読み込みパスに属さないディレクトリでそれらを定義することです。たとえばlib/に置くのが妥当でしょう。lib/はデフォルトでは自動読み込みパスに属しませんが、$LOAD_PATHには属しているので、requireするだけで読み込めます。

別の方法は、上述のように、それらをautoload_once_pathsディレクトリで定義して自動読み込みすることです(詳しくは前述のconfig.autoload_once_pathsを参照)。

9 eager loading

一般的にproduction的な環境では、アプリケーションの起動時にアプリケーションコードをすべて読み込んでおく方が望ましいと言えます。eager loading(一括読み込み)はすべてをメモリ上に読み込むことでリクエストに即座に対応できるように備え、CoW(コピーオンライト)との相性にも優れています。

eager loadingはconfig.eager_loadフラグで制御します。これはproductionモードではデフォルトで有効です。

ファイルがeager loadingされる順序は未定義です。

たとえばZeitwerkという定数を定義すると、Railsはアプリケーションの自動読み込みモードにかかわらずZeitwerk::Loader.eager_load_allを呼び出します。Zeitwerkが管理する依存はこのようにしてeager loadされます。

10 STI(単一テーブル継承)

単一テーブル継承機能は、lazy loadingとの相性があまりよくありません。一般に単一テーブル継承のAPIが正しく動作するには、STI階層を正しく列挙できる必要があるためです。lazy loadingでは、クラスが参照されるまでクラス読み込みは遅延されます。まだ参照されていないものは列挙できないのです。

ある意味、アプリケーションは読み込みモードにかかわらずSTI階層をeager loadする必要があります。

もちろん、アプリケーションが起動時にeager loadするのであれば目的は既に達成されます。そうでない場合、実際にはデータベース内の既存の型をインスタンス化すれば十分です。developmentモードやtestモードであれば普通はこれで問題ありません。これを行う方法の1つは、このモジュールをlibディレクトリに配置することです。

module StiPreload
  unless Rails.application.config.eager_load
    extend ActiveSupport::Concern

    included do
      cattr_accessor :preloaded, instance_accessor: false
    end

    class_methods do
      def descendants
        preload_sti unless preloaded
        super
      end

      # データベース内にあるすべての型を定数化する。
      # その分ディスク容量が余分に必要だが、
      # STIのAPIに配慮されていれば実際には問題ではない。
      #
      # store_full_sti_classがtrue(デフォルト)であることが前提
      def preload_sti
        types_in_db = \
          base_class.
            unscoped.
            select(inheritance_column).
            distinct.
            pluck(inheritance_column).
            compact

        types_in_db.each do |type|
          logger.debug("Preloading STI type #{type}")
          type.constantize
        end

        self.preloaded = true
      end
    end
  end
end

続いて、プロジェクトのSTIルートクラスでincludeします。

# app/models/shape.rb
require "sti_preload"

class Shape < ApplicationRecord
  include StiPreload # Only in the root class.
end
# app/models/polygon.rb
class Polygon < Shape
end
# app/models/triangle.rb
class Triangle < Polygon
end

11 活用形をカスタマイズする

デフォルトのRailsは、指定のファイル名やディレクトリ名がどんな定数を定義すべきかを知るのにString#camelizeを利用します。たとえば、posts_controller.rbというファイル名の場合はPostsControllerが定義されていると認識しますが、これは"posts_controller".camelizePostsControllerを返すからです。

場合によっては、特定のファイル名やディレクトリ名が期待どおりに活用されないことがあります。たとえばhtml_parser.rbはデフォルトではHtmlParserを定義すると予測できます。しかしクラス名をHTMLParserにするとどうなるでしょう。この場合のカスタマイズ方法はいくつか考えられます。

最も手軽な方法は、config/initializers/inflections.rbに略語を定義することです。

ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "HTML"
  inflect.acronym "SSL"
end

これによって、Active Supportによる活用方法がグローバルに反映されます。これで問題のないアプリケーションもありますが、以下のようにデフォルトのインフレクタに上書き用のコレクションを渡して、Active Supportの個別の基本語形をキャメルケース化する方法をカスタマイズすることも可能です。

# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
  autoloader.inflector.inflect(
    "html_parser" => "HTMLParser",
    "ssl_error"   => "SSLError"
  )
end

しかしデフォルトのインフレクタはString#camelizeをフォールバック先として使っているので、この手法は依然としてString#camelizeに依存しています。Active Supportの活用形機能に一切依存せずに活用形を絶対的に制御したい場合は、以下のようにZeitwerk::Inflectorのインスタンスをインフレクタとして設定します。

# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
  autoloader.inflector = Zeitwerk::Inflector.new
  autoloader.inflector.inflect(
    "html_parser" => "HTMLParser",
    "ssl_error"   => "SSLError"
  )
end

この場合は、インスタンスに影響するようなグローバル設定が存在しないので、活用形は1つに決定されます。

カスタムインフレクタを定義して柔軟性を高めることも可能です。詳しくはZeitwerkドキュメントを参照してください。

12 自動読み込みとRailsエンジン

Railsエンジンは、親アプリケーションのコンテキストで動作し、エンジンのコードの自動読み込み、再読み込み、eager loadingは親アプリケーションによって行われます。アプリケーションをzeitwerkモードで実行する場合は、エンジンのコードもzeitwerkモードで読み込まれます。アプリケーションをclassicモードで実行する場合は、エンジンのコードもclassicモードで読み込まれます。

Railsが起動すると、エンジンのディレクトリが自動読み込みパスに追加されますが、自動読み込みという観点からは何の違いもありません。自動読み込みの主な入力は自動読み込みパスであり、そのパスがアプリケーションのソースツリーであるか、エンジンのソースツリーであるかは無関係です。

たとえば、以下のアプリケーションはDeviseを使っています。

% bin/rails runner 'pp ActiveSupport::Dependencies.autoload_paths'
[".../app/controllers",
 ".../app/controllers/concerns",
 ".../app/helpers",
 ".../app/models",
 ".../app/models/concerns",
 ".../gems/devise-4.8.0/app/controllers",
 ".../gems/devise-4.8.0/app/helpers",
 ".../gems/devise-4.8.0/app/mailers"]

このエンジンが親アプリケーションの自動読み込みを制御するのであれば、これまでどおりにエンジンを書けます。

しかし、エンジンがRails 6や6.1以降をサポートしており、親アプリケーションの自動読み込みを制御しないのであれば、classicモードとzeitwerkモードのどちらの場合でも実行可能になるように書かなければなりません。そのためには、以下を考慮する必要があります。

  1. classicモードで特定の箇所で何らかの定数を確実に読み込ませるためにrequire_dependency呼び出しが必要な場合は、require_dependency呼び出しを書く。これはzeitwerkモードでは不要ですが、zeitwerkモードでも問題なく動作します。

  2. classicモードは定数名をアンダースコア化してファイル名を求め("User" -> "user.rb")、逆にzeitwerkモードではファイル名をcamelizeして定数名を求める("user.rb" -> "User")。両者はほとんどの場合一致しますが、HTMLParserのように大文字が連続すると一致しなくなります。互換性を保つ最も手軽な方法は、大文字が連続する名前を避けることです(この場合は"HtmlParser"にします)。

  3. classicモードでは、app/model/concerns/foo.rbファイルにFooConcerns::Fooを両方定義することを容認するが、zeitwerkモードではFooのみの定義しか許さない。互換性のためにはFooを定義してください。

13 テスト

13.1 手動テスト

zeitwerk:checkタスクを使うと、プロジェクトツリーが上述の命名規則に沿っているかを手軽に手動チェックできます。たとえば、classicモードからzeitwerkモードに移行するときや、何かを修正するときにこのタスクを使うと便利です。

% bin/rails zeitwerk:check
Hold on, I am eager loading the application.
All is good!

アプリケーションの設定次第では他にも出力される可能性がありますが、末尾に"All is good!"が表示されるかどうかをチェックすれば十分です。

13.2 自動テスト

プロジェクトが正しくeager loadingされているかどうかをテストスイートで検証するのはよい方法です。

Zeitwerkの命名規則やその他に発生しうるエラー条件をカバーできます。詳しくはRailsテスティングガイドeager loadingをテストするを参照してください。

14 トラブルシューティング

ローダーの振る舞いを追跡するベストの方法は、ローダーの活動を調べることです。

最も手軽な方法は、フレームワークのデフォルトが読み込まれた後で以下をconfig/application.rbに設定することです。

Rails.autoloaders.log!

これにより、標準出力にトレースが出力されます。

ログをファイルに出力したい場合は、上の代わりに以下を設定します。

Rails.autoloaders.logger = Logger.new("#{Rails.root}/log/autoloading.log")

Railsロガーはconfig/application.rbには設定されていませんが、以下のようにイニシャライザで設定されています。

# config/initializers/log_autoloaders.rb
Rails.autoloaders.logger = Rails.logger

15 Rails.autoloaders

アプリを管理するZeitwerkのインスタンスは以下で利用できます。

Rails.autoloaders.main
Rails.autoloaders.once

以下の述語メソッドは引き続きRails 7でも利用可能で、trueを返します。

Rails.autoloaders.zeitwerk_enabled?

フィードバックについて

Railsガイドは GitHub の yasslab/railsguides.jp で管理・公開されております。本ガイドを読んで気になる文章や間違ったコードを見かけたら、気軽に Pull Request を出して頂けると嬉しいです。Pull Request の送り方については GitHub の README をご参照ください。

原著における間違いを見つけたら『Rails のドキュメントに貢献する』を参考にしながらぜひ Rails コミュニティに貢献してみてください 🛠💨✨

本ガイドの品質向上に向けて、皆さまのご協力が得られれば嬉しいです。よろしくお願いします。

YassLab 株式会社
https://yasslab.jp/

支援・協賛

Railsガイドは下記のサポーターから継続的な支援を受けています。Railsガイドへの支援・協賛にご興味あれば info@yasslab.jp までお問い合わせください。

  1. Star
  2. このエントリーをはてなブックマークに追加