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

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

このガイドの内容:

1 はじめに

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

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

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

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などを読み込むときだけです。そのため、これらのような自動読み込みパスに属さないものについてはすべて後述します。

2 Zeitwerkモードを有効にする

自動読み込みのzeitwerkモードは、CRuby上で実行されるRails 6アプリケーションではデフォルトで有効になります。

# config/application.rb
config.load_defaults "6.0" # CRubyでzeitwerkモードが有効になる

zeitwerkモードのRailsは、内部で自動読み込み、再読み込み、eager loadingにZeitwerkを用います。Railsは、プロジェクトを管理する専用のZeitwerkインスタンスのインスタンス化や設定を行います。

RailsアプリケーションのZeitwerkは手動で設定しないでください。代わりに、本ガイドで後述する移植可能な設定ポイントを用いてアプリケーションを設定してください。代わりにRailsが設定をZeitwerk向けに変換します。

3 プロジェクトの構造

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

このような活用形をカスタマイズする必要が生じた場合(略語を追加するなど)は、config/initializers/inflections.rbをチェックしてみてください。

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

4 自動読み込みのパス

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

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

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

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

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

$ rails runner 'p UsersHelper'
UsersHelper

自動読み込みパスは、appの下のあらゆるカスタムディレクトリを自動的に扱います。たとえば、アプリケーションにapp/presentersapp/servicesがあれば、自動読み込みパスに追加されます。

自動読み込みパスの配列は、config/application.rbconfig.autoload_pathsを書き換えることで拡張可能ではありますが、やめておきましょう。

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

5 $LOAD_PATH

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

config.add_autoload_paths_to_load_path = false

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

6 再読み込み

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

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

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

デフォルトのRailsは、変更されたファイルをイベンテッドファイルモニタで検出します。あるいは、config.file_watcherに応じて自動読み込みパスを探索します。

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

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

$ bin/rails c
Loading development environment (Rails 6.0.0)
irb(main):001:0> User.object_id
=> 70136277390120
irb(main):002:0> reload!
Reloading...
=> true
irb(main):003:0> User.object_id
=> 70136284426020

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

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

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

つまり、再読み込み可能なクラスやモジュールのオブジェクトが、再読み込みできない場所に保存されると、それらの値はいずれ古くなります(stale)。

たとえば、あるイニシャライザが、特定のクラスオブジェクトを1つ保存してキャッシュするとします。

# config/initializers/configure_payment_gateway.rb
# 実際にはこのように書かないこと
$PAYMENT_GATEWAY = Rails.env.production? ? RealGateway : MockedGateway
# 実際にはこのように書かないこと

MockedGatewayが再読み込みされると、MockedGatewayクラスオブジェクトはイニシャライザが実行されたときの状態で引き続き$PAYMENT_GATEWAYに保管されていると評価されます。$PAYMENT_GATEWAYに保存されているクラスオブジェクトは、再読み込みで変更されません。

同様に、Railsコンソールでuserインスタンスを作って再読み込みするとします。

> user = User.new
> reload!

このuserオブジェクトは、古くなったクラスオブジェクトのインスタンスです。Userを再度評価すればRubyが新しいクラスを渡しますが、そのインスタンスのUserクラスは更新されません。

別のユースケースで注意点を示します。再読み込み可能なクラスを、再読み込みできない場所でサブクラス化するとします。

# lib/vip_user.rb
class VipUser < User
end

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

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

7 eager loading

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

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

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

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

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

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

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

もちろん、アプリケーションが起動時にeager loadするのであれば目的は既に達成されます。そうでない場合、実際にはデータベース内の既存の型をインスタンス化すれば十分です。developmentモードやtestモードであれば普通はこれで問題ありません。これを行う方法のひとつは、このモジュールを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.
            select(inheritance_column).
            distinct.
            pluck(inheritance_column).
            compact.
            each(&:constantize)

        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

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

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

最も簡単な方法は、フレームワークのデフォルトが読み込まれた後で以下を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

10 Rails.autoloaders

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

Rails.autoloaders.main
Rails.autoloaders.once

最初のものがメインで、次のものは主に後方互換性上の理由で存在しています。たとえばアプリケーションがconfig.autoload_once_pathsで何かを行う場合などです(これは現在おすすめしません)。

zeitwerkモードが有効かどうかは以下の設定で確認できます。

Rails.autoloaders.zeitwerk_enabled?

11 Zeitwerkを使わない場合

次のようにすることで、アプリケーションがRails 6のデフォルトを読み込みながらclassicオートローダーを引き続き使えます。

# config/application.rb
config.load_defaults "6.0"
config.autoloader = :classic

これはRails 6をいくつかのフェーズに分けてアップグレードする場合に便利ですが、classicモードは新しいアプリケーションではおすすめしません。

zeitwerkモードは、Rails 6.0より前のバージョンでは利用できません。

支援・協賛

Railsガイドは下記のサポーターから継続的な支援を受けています。