こんにちは。エンジニアの藤井(@yutafujii)です。
今日は、RailsのAutoloadingとReloadingについて解説しつつ、これにまつわる設定ミスでdevelopment環境においてエラーに悩まされたというエピソードをご紹介します。
AutoloadingとReloadingって?
RailsのAutoloadingとReloadingという言葉を、より実務上のありがたみとしてイメージできるように素朴な疑問から考えてみたいと思います。
なぜRailsではrequireを書かなくてもよいのか?
Rubyでは他のファイルを読み込む時には当該ファイルを明示的にrequireしておく必要があります。ところがRailsではモデルでもコントローラーでも、requireを書かずに多くの処理がうまく動きます。
これは、RailsがRubyのメソッドをオーバーライトしているためです。具体的には、Moduleクラスのconst_missingというメソッドを上書きしています。このメソッドはメモリ上にロードされてない未知の定数を参照したときに発火するのですが、RubyではNameErrorが出るのに対して、上書きされたRailsのconst_missingではエラーを出す前にその定数が定義されていそうなファイルを推測して自動で探すようになっています。だからAutoloadingと呼ばれています。なお、自動で探す範囲はautoload_pathという変数で管理されています。
そしてもうひとつの疑問。
なぜRailsで開発しているときにファイルに加えた変更がすぐ反映されるのか?
例えばの話ですがproduction環境で稼働しているサーバーに入り、Railsのコントローラーのファイルを書き換えたとしても、その変更はサーバーを再起動しない限り反映されません。しかしdevelopment環境だとファイルを修正すると画面をリロードするだけでその変更が反映されます。Railsがこのような開発体験の良さを実現しているのは、development環境ではファイルの変更履歴をウォッチして、変更を検知したらサーバーが次のリクエストを受理したときに当該ファイルを読み直せるようにしているからです。これをReloadingと呼んでいます。
RailsがAutoloadingとReloadingのためにしていること
概略は説明した通りですが、コードベースでも該当箇所を紹介しておきます。
Autoloadingで説明したconst_missingメソッドのオーバーライトはActiveSupport::Dependenciesというモジュールに記載されています。
また、Reloadingで説明したファイルをウォッチしているというクラスはActiveSupport::FileUpdateCheckerというもので、そのexecuteメソッド(端的に言えばここで変更が生じているファイルをメモリからアンロードする)をコールしているのがRails::Application::Finisherというモジュールです。
Reloadingについて説明を加えると、このFileUpdateCheckerがautoloadされた定数を一旦全てアンロードしますので、次のサーバーリクエストの処理において変更を加えたコントローラーやモデルが参照されると、const_missingが発火してAutoloadingされ、結果として修正後の内容がロードされるという仕組みです。
設定を間違えたらdevelopment環境で見知らぬエラーが
正直に言って、こんなRailsの仕組みを理解したうえで実務の世界に入ったわけはなく、エラーに遭遇して初めてちゃんと調べただけです。
ここからはそのバグについてご紹介します。
私が入社した頃はRailsをフルスタックのフレームワークとして利用していたのですが、途中からVueやNuxtをフロントにしてAPIサーバーとしての機能に集約してきました。そうした開発を進めていくなかで、development環境において次のようなエラーが出るようになりました。
A copy of Api::One has been removed from the module tree but is still active!
「Api::Oneのコピーはモジュールツリーから削除されたけどまだ利用されています。」とでも訳すのかもしれないですがエラーメッセージの言っていることがイマイチよくわからず、backtraceをみたのですがアプリケーションのコードに到達する前のRailsのコードでエラーになっていたので、これは少し根が深そうだと思ってGoogle検索を頼りました。
同様のエラーに関する記事はいくつか見つかったのですが、実際に効果があったのはdevelopment環境のconfigを変更するという対処でした。
config/environments/development.rbにおいて「クラスをキャッシュしておくか」という設定(config.cache_classes・config.action_controller.perform_caching)をtrueにすることで確かにエラーは出なくなったのですが、これは一度ロードしたクラスをキャッシュし続けるという設定なのでReloadingが効かなくなり、Rails部分のソースコードは(より厳密にはautoload_pathに含まれるファイルは)変更するたびにアプリケーションサーバーを再起動しないと内容が反映されなくなります。
これは開発体験が非常に悪いので、Railsの仕組みを調べながら、根本原因を探していきました。
結論として、エラーの直接的な原因はdevelopment.rbの別の設定にありました。
「クラスのリロードを変更があった場合に限定する」という設定(config.reload_classes_only_on_change)がfalseになっていたために、ソースコードを変更しなくてもリクエストの都度autoloadされた定数を全てアンロードしていました。
この設定そのものが問題ではないのですが、フロントエンドをコンポーネント化してきたことと複合してエラーを生じさせていました。
すなわち、コンポーネント化されたページを開くとページロード直後にJavaScriptが複数のリクエストをほぼ同時にAPIサーバーへ送る状況が生まれたところ、前述のRailsの設定が理由でAPIサーバー側ではリクエストの処理前にautoloadされた定数が全削除され、その結果全く同じモジュールのAutoloadingが2本同時に走るRace condition(競争状態)が発生していました。同一モジュールのRubyオブジェクトが2つできてしまったことで、処理途中のequal?評価(RubyではオブジェクトIDの一致を確認するメソッド)がfalseになり、くだんのエラー
「Api::Oneのコピーはモジュールツリーから削除されたけどまだ利用されています。」
が表示された、というわけです。
ちなみに、実際にエラーを起こしたのはルーティングからコントローラーを取得する処理action_dispatch/http/request.rbのcontroller_class_forという部分でした。コントローラー名の文字列から定数を取得するRailsのconstantizeメソッドでコントローラーを示す定数(例えるならApi::Parent::ChildController)をAutoloadingする時にエラーになっていました。
なぜこのような設定になっていたのか
ところで、問題の一因となったconfig.reload_classes_only_on_changeの設定はRailsプロジェクトの初期値がtrueなので、なぜこれがfalseに変更されたのか気になりました。
この変更は3年前に行われており。当時のプルリクにも多くの情報はなかったので推測ではありますが、事の発端はApplicationというモデルを作成したことだったと思われます。
当社のシステムは人材紹介業に関連するものであるために、”応募”の英訳にあたるApplicationという単語をモデル名で利用していました。しかし想像がつくようにApplicationというクラスはRailsプロジェクトそのものにも存在し(config/application.rb)、何らかの機構でApplicationモデルのAutoloadingが上手くできなかったようです。そこでRailsのイニシャライズ直後にapp/models/application.rbをrequireしておくような設定がconfigに書かれていました。
悲しいかなRailsではrequireしたファイルは通常のReloading時にはアンロードされないという性質があるために、今度はApplicationモデルのReloadingができない悩みを抱えていたと思われます、だから強制的に都度定数削除をするconfig.reload_classes_only_on_changeをfalseにしたのではないかと考えています。
今回のバグ修正においてこの部分も見直し、結果的にrequire_dependencyを利用しました。一応ですがRailsガイドではrequire_dependencyはラストリゾートであり最初に検討すべき手段ではないと書かれているのでご注意ください。なお後述するZeitwerkモードの導入でこの対応も不要になりました。
Autoloadingに関するRailsの設計上の疑問と直近の動向
もう少しだけこの定数のAutoloadingについて触れておきましょう。
紹介したエピソードではdevelopment.rbの設定ミスとVueを用いたフロントエンドの分離が競争状態を生んだエラーの理由だと説明しましたが、このエラーはどのRailsプロジェクトでも一般的に再現性があります。
エラーが起きる条件は「2本以上の同時リクエストを受けとりReloading & Autoloadingが2本同時に走ること」ですが、通常の開発のなかでファイルを修正した場合この条件を満たしてしまいます。
実際、フロントのコンポーネントにおけるcreatedフックなどで2本以上のAPIが同時に呼び出されるページでは、Rails API側のファイルを修正すると直後の画面リロード時だけはこのエラーが出ます(出ない時もあります)。ReloadingとAutoloadingが2本走って競争状態が生まれるためです。そのままもう1度画面リロードするとエラーは出なくなりますが、これはReloadingもAutoloadingも走らないからです。
このエラー再現性についてはReproduce用の個人プロジェクトも作って確認しました。
「これ、フロント分離しているプロジェクトだと悩む人多いんじゃないか?」と思ってRailsのイシューが既にあるか見てみると、確かに1件「LoadError when multiple threads try to load the same namespaced class」というイシューで修正の議論もなされていたようですが、Rubyの改修も必要な内容になっており、最終的には修正は行われていない様子でした。
その代わりだったのかはわかりませんが、Autoloadingの新しいやり方がRails 6.0から導入されています。
Zeitwerk(ツァイトヴェルク)というgemが正式に導入され、そもそものconst_missingに依拠したAutoloadingが見直されました。
なので、Zeitwerkモードを利用していれば、今日紹介したエラーに悩まされる心配はありません。Rails 6.0以前のAutoloadingの方法はClassicモードと呼ばれていますが、これはRailsガイドでdeprecatedとされているので早めに移行しておきましょう。
config/application.rbに1行追加するとZeitwerkモードに移行できます。
config.load_defaults "6.0" # Zeitwerk
config.autoloader = :classic # Classic
私の所属するプロダクトではRailsのバージョンこそ6に上げていたものの、こうした周辺機能のマイグレーションに気づけていない部分もあったので、次回以降気をつけていきたいと思います。
参考リンク
RAILS GUIDES
https://guides.rubyonrails.org/autoloading_and_reloading_constants_classic_mode.html
https://guides.rubyonrails.org/autoloading_and_reloading_constants.html
https://github.com/rails/rails/blob/main/activesupport/lib/active_support/dependencies.rb
https://github.com/rails/rails/blob/main/activesupport/lib/active_support/inflector/methods.rb
Rails Issue
https://github.com/rails/rails/issues/33209
Zeitwerk GitHub