Classic から Zeitwerk への移行

本ガイドでは、Railsアプリケーションをclassicモードからzeitwerkモードに移行する方法について解説します。

このガイドの内容:

  • classicモードとzeitwerkモードについて
  • classicからzeitwerkに切り替える理由
  • zeitwerkモードを有効にする
  • アプリケーションがzeitwerkモードで動いていることを検証する
  • プロジェクトが正しく読み込まれることをコマンドラインで検証する
  • プロジェクトが正しく読み込まれることをテストスイートで検証する
  • 想定されるエッジケースの対応方法
  • Zeitwerkで利用できる新機能

1 classicモードとzeitwerkモードについて

Railsは最初期からRails 5まで、Active Supportで実装されたオートローダーを用いていました。このオートローダーはclassicと呼ばれ、Rails 6.xでは引き続き利用可能です。classicオートローダーはRails 7で廃止されました。

Rails 6から、より優れた新しい自動読み込み方法がRailsに搭載されました。これはZeitwerkというgemに一任されています。これがzeitwerkモードです。デフォルトでは、Railsフレームワーク6.0および6.1の読み込みはzeitwerkモードで実行され、Rails 7で利用できるのはzeitwerkモードのみとなります。

2 classicからzeitwerkに切り替える理由

classicオートローダーは非常に便利でしたが、取り扱いに少々注意を要したり時に混乱を招いたりする問題が多数存在していました。Zeitwerkはこうした問題を解決するために開発されました(その他にもさまざまな動機があります)。

classicモードは非推奨化されたので、Railsを6.xにアップグレードする際にzeitwerkモードに移行することを強く推奨します。

この移行はRails 7で完了し、classicモードが含まれなくなりました。

3 「移行するのが怖いんですが」

大丈夫です。

Zeitwerkは従来のオートローダーとの互換性をできるだけ維持するように設計されています。現在のアプリケーションで自動読み込みが正しく行われていれば、切り替えは簡単です。大小さまざまなプロジェクトで、スムーズに切り替えられたことが報告されています。

本ガイドを読めば、安心してオートローダーを切り替えられます。

何らかの理由で解決方法が見当たらない状況に直面した場合は、お気軽にrails/railsリポジトリのissueをオープンして、@fxnにメンションしてください。

4 zeitwerkモードを有効にする

4.1 Rails 5.x以前のアプリケーションの場合

Rails 6.0より前のバージョンを実行するアプリケーションではzeitwerkモードを利用できません。Rails 6.0以上が必要です。

4.2 Rails 6.xアプリケーションの場合

Rails 6.xアプリケーションの場合は以下の2とおりのシナリオがあります。

アプリケーションがRails 6.0または6.1のフレームワークのデフォルトを読み込んでいて、かつclassicモードで実行されている場合は、classicモードを手動でオプトアウトしなければなりません。これは以下のような形で行う必要があります。

# config/application.rb
config.load_defaults 6.0
config.autoloader = :classic # この行を削除する

上のコメントにあるように、このオーバーライドを削除するとzeitwerkモードがデフォルトになります。

一方、アプリケーションが古いフレームワークのデフォルトを読み込んでいる場合は、以下のようにzeitwerkモードを明示的に有効にする必要があります。

# config/application.rb
config.load_defaults 5.2
config.autoloader = :zeitwerk

4.3 Rails 7アプリケーションの場合

Rails 7にはzeitwerkモードしかないので、このモードを有効にするために設定を変更する必要はありません。

Rails 7ではconfig.autoloader=セッターそのものがなくなりました。config/application.rbにこの記述がある場合は、その行を削除してください。

5 アプリケーションがzeitwerkモードで動いていることを検証する

アプリケーションがzeitwerkモードで動いていることを検証するには、以下を実行します。

$ bin/rails runner 'p Rails.autoloaders.zeitwerk_enabled?'

trueが出力されれば、zeitwerkモードが有効です。

6 アプリケーションがZeitwerkに沿っているかを確かめる

6.1 config.eager_load_paths

Zeitwerkに準拠しているかどうかのテストは、eager loadingされたファイルに対してのみ実行されます。そのため、Zeitwerkへの準拠を検証するには、すべての自動読み込みパスをeager loadパスに追加することが推奨されます。

これは既にデフォルトで行われるようになっていますが、自分のプロジェクトで自動読み込みパスを以下のようにカスタマイズしている場合は、eager loadingされないため検証されません。

config.autoload_paths << "#{Rails.root}/extras"

以下のようにすることで、手軽にeager loadingパスに追加できます。

config.autoload_paths << "#{Rails.root}/extras"
config.eager_load_paths << "#{Rails.root}/extras"

6.2 zeitwerk:check

zeitwerkモードを有効にし、eager loading設定をダブルチェックしたら、以下を実行します。

$ bin/rails zeitwerk:check

チェックが成功すると以下のように出力されます。

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

アプリケーションの設定によってはこの他にも出力されることがありますが、末尾に"All is good!"があればOKです。

前節で説明したダブルチェックで、カスタムの自動読み込みパスがeager loadingパスの外にあると判断されると、タスクはそれを検出して警告を発します。しかし、テストスイートがそれらのファイルの読み込みに成功していれば、問題ありません。

Zeitwerkで期待される定数が定義されていないファイルがあると、上のタスクで通知されます。このタスクは1ファイルごとに実行されます。問題が生じたときにタスクが先に進むと、あるファイルの読み込み失敗が他の無関係な失敗に連鎖してエラー出力が読みにくくなるためです。

定数に1つでも問題があれば、その問題を解決し、"All is good!"が出力されるまでタスクを再実行してください。

たとえば以下のように出力されたとします。

$ bin/rails zeitwerk:check
Hold on, I am eager loading the application.
expected file app/models/vat.rb to define constant Vat

VATはヨーロッパの税制のことです。app/models/vat.rbではVATが定義済みですが、オートローダーはVatを期待しています。どんな理由でこうなったのでしょうか。

6.3 略語の扱い

これはZeitwerkで最もありがちな問題で、略語が関係しています。このエラーメッセージが生じた理由を考えてみましょう。

classicオートローダーは、すべて大文字のVATを自動読み込みできます。その理由は、オートローダーの入力にconst_missingの定数名が使われるからです。VATという定数に対してunderscoreが呼び出されてvatが生成され、これを元にvat.rbというファイルを検索することで、ファイルが正常に見つかります。

新しいZeitwerkオートローダーの入力はファイルシステムです。vat.rbというファイルがあると、Zeitwerkはvatに対してcamelizeを呼び出し、冒頭のみが大文字のVatが生成されます。これにより、Vatという定数名が定義されていることが期待されます。以上がエラーメッセージの内容です。

これは、以下のようにActiveSupport::Inflectorの語尾活用機能を用いて略語を指定するだけで簡単に修正できます。

# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "VAT"
end

上の方法は、Active Supportの語尾活用機能をグローバルに変更します。これで問題ない場合もありますが、オートローダーで用いられる語尾活用機能にオーバーライドを渡したい場合は、以下のようにします。

# config/initializers/zeitwerk.rb
Rails.autoloaders.main.inflector.inflect("vat" => "VAT")

このオプションを使うと、vat.rbというファイル名、またはvatというディレクトリ名のみがVATとして認識されるので、より細かく制御できます。vat_rules.rbという名前のファイルはその影響を受けないので、VatRulesを正しく定義できるようになります。このような名前の不一致があるプロジェクトで役に立つでしょう。

以上が終われば、チェックはパスします。

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

すべて問題なく動くようになったら、テストスイートで今後もプロジェクトを検証し続けることをおすすめします。これについて詳しくはZeitwerk準拠をテストスイートでチェックするで後述します。

6.4 concernsについて

以下のように、concernsサブディレクトリを持つ標準的な構造からの自動読み込みやeager loadingを行えます。

app/models
app/models/concerns

app/models/concernsディレクトリはデフォルトでは自動読み込みパスに属しているので、これがrootディレクトリと見なされます。そのため、デフォルトではapp/models/concerns/foo.rbファイルで定義されるのはConcerns::FooではなくFooになります。

アプリケーションでConcernsが名前空間として使われている場合は、以下の2つの方法があります。

  1. これらのクラスやモジュールからConcerns名前空間を削除してクライアントコードを更新する。
  2. 自動読み込みパスからapp/models/concernsを除外することで現状のままにする。
# config/initializers/zeitwerk.rb
ActiveSupport::Dependencies.
  autoload_paths.
  delete("#{Rails.root}/app/models/concerns")

6.5 自動読み込みパスにappを追加する

プロジェクトによっては、たとえばAPI::Baseを定義するapp/api/base.rbを置き、自動読み込みパスにappを追加することで利用したい場合もあります。

Railsは自動的にappのすべてのサブディレクトリ(アセットのディレクトリなどは除く)も自動読み込みパスに追加するので、app/models/concernsで起きたのと似たようなネステッドrootディレクトリの問題がここでも起きます。今後、この設定はこのままでは機能しません。

ただし、以下のようにイニシャライザで自動読み込みパスからapp/apiを削除すれば現状の構造を維持できます。

# config/initializers/zeitwerk.rb
ActiveSupport::Dependencies.
  autoload_paths.
  delete("#{Rails.root}/app/api")

自動読み込みまたはeager loadingするファイルが存在しないサブディレクトリについては注意が必要です。たとえば、ActiveAdminで使うリソースを保存するapp/adminディレクトリがアプリケーションにある場合、以下のようにそれらのリソースを無視する必要があります。assetsディレクトリなどについても同様の注意が必要です。

# config/initializers/zeitwerk.rb
Rails.autoloaders.main.ignore(
  "app/admin",
  "app/assets",
  "app/javascripts",
  "app/views"
)

上の設定がないと、アプリケーションがこれらのツリーをeager loadingします。読み込まれたファイルは定数を定義しないので、app/adminでエラーが発生し、さらに副作用として不要なViewモジュールも生成されてしまいます。

このように、自動読み込みパスにappディレクトリを含めることは一応可能ですが、ややトリッキーになります。

6.6 自動読み込みした定数と明示的な名前空間

ファイル内で、たとえば以下のようにHotelという名前空間が定義されているとします。

app/models/hotel.rb         # Hotelを定義する
app/models/hotel/pricing.rb # Hotel::Pricingを定義する

このHotel定数は、以下のようにclassまたはmoduleキーワードで設定しなければなりません。

class Hotel
end

上は問題ありません。

ただし、以下の2つは無効です。

Hotel = Class.new

または

Hotel = Struct.new

これらはHotel::Pricingなどの子オブジェクトを探索できません。

これらの制約は、明示的な名前空間にのみ適用されます。名前空間を定義しないクラスやモジュールであれば上述の記法でも定義できます。

6.7 1ファイルにつきトップレベル定数は1個

classicモードでは、以下のように同一トップレベルに複数の定数を定義して、それらすべてを再読み込みすることも技術的に可能でした。以下のコード例で考えます。

# app/models/foo.rb

class Foo
end

class Bar
end

上のBarは本来、自動読み込みできないにもかかわらず、Fooを自動読み込みするとBarも自動読み込み済みとマーキングされました。

これはzeitwerkモードでは利用できません。Barは専用のbar.rbファイルに移動する必要があります。これが「1ファイルにつきトップレベル定数は1個」という規則です。

これが影響するのは、上のコード例のように同一トップレベルに置かれた定数だけです。以下のようなネストしたクラスやモジュールには影響しません。

# app/models/foo.rb

class Foo
  class InnerClass
  end
end

アプリケーションがFooを再読み込みすると、Foo::InnerClassも再読み込みされます。

6.8 config.autoload_pathsについて

以下のようにワイルドカードを含む設定では注意が必要です。

config.autoload_paths += Dir["#{config.root}/extras/**/"]

config.autoload_pathsのどの要素もトップレベルの名前空間(Object)を表さなければならないので、上の設定は無効です。

これは、以下のようにワイルドカードを削除するだけで修正できます。

config.autoload_paths << "#{config.root}/extras"

6.9 エンジンからのクラスやモジュールをdecorateする

アプリケーションがエンジンからクラスやモジュールをdecorateしているのであれば、どこかで以下のようなことをやっている可能性があります。

config.to_prepare do
  Dir.glob("#{Rails.root}/app/overrides/**/*_override.rb").sort.each do |override|
    require_dependency override
  end
end

これは更新しなければなりません。以下のようにmainオートローダーでオーバーライドを含むディレクトリを無視するように指示し、代わりにloadでそれらを読み込む必要があります。

overrides = "#{Rails.root}/app/overrides"
Rails.autoloaders.main.ignore(overrides)
config.to_prepare do
  Dir.glob("#{overrides}/**/*_override.rb").sort.each do |override|
    load override
  end
end

6.10 before_remove_const

Rails 3.1ではbefore_remove_constというコールバックがサポートされ、このメソッドに応答するクラスやモジュールが再読み込みされるときに呼び出されるようになりました。このコールバックはドキュメント化されていないため、おそらくアプリケーションコードで使われていることはないでしょう。

しかしこのコールバックを利用している場合は、以下のように書き換えられます。

class Country < ActiveRecord::Base
  def self.before_remove_const
    expire_redis_cache
  end
end

上を以下のように書き換えます。

# config/initializers/country.rb
if Rails.application.config.reloading_enabled?
  Rails.autoloaders.main.on_unload("Country") do |klass, _abspath|
    klass.expire_redis_cache
  end
end

6.11 spring gemとtest環境

spring gemは、アプリケーションコードが変更されると再読み込みします。test環境でこの再読み込みを有効にするには、以下の設定が必要です。

# config/environments/test.rb
config.cache_classes = false

Rails 7.1以降は以下の設定が必要です。

# config/environments/test.rb
config.enable_reloading = true

そうしないと、以下のいずれかのエラーが発生します。

reloading is disabled because config.cache_classes is true
reloading is disabled because config.enable_reloading is false

なお、この設定でパフォーマンスは低下しません。

6.12 bootsnap gem

少なくともbootsnap 1.4.4以上に依存する必要があります。

7 Zeitwerk準拠をテストスイートでチェックする

Zeitwerkに移行するときは、zeitwerk:checkタスクを使うと便利です。プロジェクトがZeitwerkに準拠したら、このチェックを自動化することをおすすめします。これを行うには、アプリケーションをeager loadingするだけで十分です(実際zeitwerk:checkが行うのはそれだけです)。

7.1 CI環境の場合

プロジェクトにCI(Continuous Integration: 継続的インテグレーション)環境がある場合は、テストスイートを実行するときにアプリケーションをeager loadingするとよいでしょう。アプリケーションが何らかの理由でeager loadingできなくなっていることにproduction環境で気づくより、CI環境で知りたいものですよね。

CIでは、テストスイートが実行中であることを示すのに何らかの環境変数を設定することがよくあります。環境変数がCIの場合は、以下のように指定できます。

# config/environments/test.rb
config.eager_load = ENV["CI"].present?

Rails 7以降で新規生成したアプリケーションでは、デフォルトで上の設定が有効になります。

7.2 テストスイートを直接実行する場合

プロジェクトにCI環境がない場合は、テストスイートでRails.application.eager_load!を呼ぶことでeager loadingできます。

7.2.1 minitestの場合
require "test_helper"

class ZeitwerkComplianceTest < ActiveSupport::TestCase
  test "eager loads all files without errors" do
    assert_nothing_raised { Rails.application.eager_load! }
  end
end
7.2.2 RSpecの場合
require "rails_helper"

RSpec.describe "Zeitwerk compliance" do
  it "eager loads all files without errors" do
    expect { Rails.application.eager_load! }.not_to raise_error
  end
end

8 不要なrequire呼び出しはすべて削除すること

私の経験では、プロジェクトでのrequire呼び出しは一般に不要です。しかしrequireを呼び出しているプロジェクトをいくつか実際に見かけたことがあり、他にもそうした噂をいくつか耳にしています。

Railsアプリケーションでは、requireは「libのコード」「gemなどのサードパーティ依存関係」「標準ライブラリ」の読み込みにしか使いません。アプリケーションの自動読み込み可能なコードは決してrequireしないでください。この方法がよくない理由については、Classicモードの解説で説明しました。

require "nokogiri" # よい
require "net/http" # よい
require "user"     # 絶対ダメ、削除せよ(app/models/user.rbがある場合)

そのようなrequire呼び出しはすべて削除してください。

9 Zeitwerkで利用できる新機能

9.1 require_dependency呼び出しの削除

Zeitwerkによって、require_dependencyの既知のユースケースはすべて削除されました。プロジェクトをgrepしてrequire_dependencyをすべて削除してください。

アプリケーションでSTIを利用している場合は、『定数の自動読み込みと再読み込み(Zeitwerk)』ガイドの『STI(単一テーブル継承)』を参照してください。

9.2 クラスやモジュールの定義内で定数名を修飾可能になった

クラスやモジュールの定義で、以下のような定数パスを安定して利用できるようになりました。

# このクラスの本体での自動読み込みがRubyのセマンティクスと一致するようになった
class Admin::UsersController < ApplicationController
  # ...
end

1つ注意すべき点があります。実行順序によってはclassicオートローダーで以下のFoo::Wadusを自動読み込みできてしまう場合がありました。

class Foo::Bar
  Wadus
end

このFooはネストの中に存在しないので、上はRubyのセマンティクスと一致しません。そのため、これはzeitwerkモードではまったく動作しません。このようなエッジケースに遭遇した場合は、以下のようにFoo::Wadusという修飾名を利用できます。

class Foo::Bar
  Foo::Wadus
end

あるいは、以下のようにFooをネストに追加します。

module Foo
  class Bar
    Wadus
  end
end

9.3 あらゆる場所でスレッドセーフになる

RailsにはWebリクエストをスレッドセーフにするロックが用意されているにもかかわらず、classicモードの定数自動読み込みはスレッドセーフではありませんでした。

zeitwerkモードの定数自動読み込みはスレッドセーフです。たとえば、runnerコマンドで実行されるマルチスレッドのスクリプトを自動読み込みできるようになりました。

9.4 eager loadingと自動読み込みが一貫するようになった

classicモードでは、app/models/foo.rbファイルでBarが定義されていると、このファイルを自動読み込みできません。しかしclassicモードはファイルの読み込みを機械的に再帰するので、このファイルのeager loadingは可能です。テストを最初にeager loadingする形で実行すると、その後、自動読み込みが発生したときに実行が失敗する可能性があります。

zeitwerkモードではどちらの読み込みモードも一貫しているので、テストの失敗やエラーは同じファイル内で発生します。

10 参考資料(日本語)

フィードバックについて

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

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

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

Railsガイド運営チーム (@RailsGuidesJP)

支援・協賛

Railsガイドは下記の協賛企業から継続的な支援を受けています。支援・協賛にご興味あれば協賛プランからお問い合わせいただけると嬉しいです。

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