SmartHRでRails顧問業をしていますwillnetです。最近はよくテストコードの治安維持活動をしています。
SmartHRでは複数のRailsアプリケーションが稼働していますが、その大半はRails6で動いています。先日、そのうちの一つのアプリケーションで「定義しているはずの定数がなぜか読み込めない」という奇妙な現象に遭遇したので原因を調査しました。
調査の結果、これはRails6を利用しているひとであればそれなりにハマる可能性がありそうだ、と感じたので今回ブログエントリにして周知しておこうと思った次第です。
定義しているはずの定数がなぜか読み込めない、とは
binding.pry
時にA::B
のようにネームスペース配下の定数を参照したときNameError: uninitialized constant A::B
のようになることがあるclass A
もclass A::B
もアプリケーションコード中で確かに定義しているし、パスもapp/models/a.rb
,app/models/a/b.rb
のようになっていて問題ないように見える
簡単にまとめるとこういうことです
zeitwerkで管理しているプロジェクトでbyebugやpry-byebugのブレークポイントを利用しているとき、ネームスペースを利用した定数の解決がうまくいきません。
もっと詳しく
Rails6では、デフォルトのコードローダーとしてzeitwerkというものを利用しています。コードローダーをざっくり説明すると「明示的にrequire
を書かずともいい感じに必要なファイルをrequire
する」という定数の自動読み込み機能を提供するものです。
Rails5以前は、const_missingを利用して定数の自動読み込みを実現していましたが、このやり方では期待通りに動かないケースがあり、かつconst_missingの仕組み上改善が難しい、という問題がありました。
zeitwerkはconst_missing
を使わず、autoloadやTracePointを利用することでRails5以前のやりかたで起きる問題を解決しています*1。
そんなzeitwerk(Rails6)の環境でapp/models/a.rb
とapp/models/a/b.rb
が存在しているときにA::B
とすると、次の手順で動作します。
- Aをrequireする(Aのautoloadは起動時に設定済み)
- class Aを読み込んだタイミングで、
app/models/a/
(app/controllers/a/
などがあればそれも)ディレクトリ配下にどのようなファイルが存在しているか調べ、それぞれのファイルに対応したautoloadを実行する A.autoload :B, '/path/to/railsapp/app/models/a/b.rb'
が実行されるA::B
がrequireされる
このとき2の「 class Aを読み込んだタイミング」を検知するのにTracePointが使われています。TracePointはRubyの様々なイベントに対してhookを仕掛けることのできる機能です。
ここで問題になるのが、次の2つの仕様です
- byebugがブレークポイントを実装するのにTracePointを利用している
- 現状のTracePointの仕様として「TracePointでhookした処理の中ではTracePointのhookが効かない」というものがある
- おそらく、これを許容してしまうと意図せず無限にhookが効いてしまうのでそうしているのではないかと思います。
というわけで、byebugのブレークポイント中ではTracePointが効かないため上記の2が動かず、結果としてA::B
を自動読み込みできません。
再現できるように簡単なサンプルアプリケーションを作ったので、気になる方は↓をcloneして動かしてみてください。ブレークポイント中ではA::B
がNameErrorになりますが、ブレークポイント外でA::B
とするとエラーにならない、というのが確認できます。
willnet/byebug-zeitwerk-sample
どうしたらいいの
現状では回避策はなさそうです。このような不具合があるというのを認識しつつ過ごすか、一旦byebugを使わないようにするか。byebugの中の人がIssueをたてているので、これがいい感じに着地するのを待ちましょう。
*1:zeitwerkの詳細についてはまた別の機会に書いたりするかもしれません