Railsのネストしたネームスペースのautoload

こんな感じのコードがあったとして

# app/models/user.rb
class User
end

# app/models/admin/user.rb
module Admin
  class User
  end
end

# app/controllers/admin/first_controller.rb
class Admin::FirstController
  def index
    p User
  end
end

# app/controllers/admin/second_controller.rb
module Admin
  class SecondController
    def index
      p User
    end
  end
end

FirstControllerSecondControllerの違いはモジュールのネストのシンタックスの違いだけ。このときRubyUser定数の探索を次の順におこなう。

  • FirstControllerの場合
    1. Admin::FirstController::User
    2. User
  • SecondController
    1. Admin::FirstController::User
    2. Admin::User
    3. User

したがってFirstControllerはトップレベルのUserSecondControllerAdmin::Userを参照する。

$ ruby -r './app/models/user' -r './app/models/admin/user' -r './app/controllers/admin/first_controller' \
  -e 'Admin::FirstController.new.index'
User

$ ruby -r './app/models/user' -r './app/models/admin/user' -r './app/controllers/admin/second_controller' \
  -e 'Admin::SecondController.new.index'
Admin::User

これがRubyの定数探索の挙動。

一方Railsconst_missingによる探索の挙動は異なり、どちらの場合も以下の順で探索する。

  1. Admin::FirstController::User
  2. Admin::User
  3. User
$ rails r 'Admin::FirstController.new.index'
Admin::User

$ rails r 'Admin::SecondController.new.index'
Admin::User

どちらもAdmin::Userを指す。Rails Guidesのドキュメントに以下のように書いてある。

Unfortunately Rails autoloading does not know the nesting in the spot where the constant was missing and so it is not able to act as Ruby would. In particular, Admin::User will get autoloaded in either case.

https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#nesting-and-qualified-constants

また、UserAdmin::Userが事前に読み込まれているかどうかで、Rubyによる解決で済むかRailsconst_missingの解決になるかが異なるので、場合によって挙動が違うようになる。これによりテストを実行したときに、単体のテストは通るけど全テストを実行したときに読み込み順によって落ちる、みたいなつらいことになりがち。

Userが読み込まれている場合はどちらの場合もRubyの探索でトップレベルのUserに行き着くのでUserを返す。これはわかりやすい。

$ rails r 'User; Admin::FirstController.new.index'
User

$ rails r 'User; Admin::SecondController.new.index'
User

Admin::Userが読み込まれている場合はFirstControllerでエラーになる。Admin::Userを返してくれることを期待していたが、予想と異なる挙動になった。

$ rails r 'Admin::User; Admin::FirstController.new.index'
uninitialized constant Admin::FirstController::User

$ rails r 'Admin::User; Admin::SecondController.new.index'
Admin::User

ドキュメントにはこのように書かれている。

If none of the parent namespaces of the class or module has the missing constant then Rails assumes the reference is relative. Otherwise qualified.

https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#autoloading-algorithms-qualified-references

つまり、Admin::FirstControllerから未定義のUserを呼び出したとき、RailsAdmin::FirstController::Userとして呼び出されたか、class Admin::FirstController; User; endとして呼び出されたかの判断ができない。したがって、このケースで、Admin::Userを返したら、Admin::FirstController::Userという呼び出しに対してAdmin::Userを返す可能性があってこれは非常にわかりづらい挙動になる。そのため、安全側に倒して親のネームスペースに探索対象の定数が定義されていたら親からは探さないようにする、ということらしい。

まとめると、思ったこととしてはこんな感じ。

  • Railsでネストしたネームスペースの名前がかぶるような命名は基本的には避けるべき
  • どうしてもネームスペースが重複する場合はその定数の参照はコンテキストに依存しない絶対的な参照に変更したほうがいい。::UserAdmin::Userのように
  • class Foo::Bar; endのような表記はRubyの定数探索と挙動が異なるのでmodule Foo; class Bar; end; endのようなネストで書いたほうがよさそう気がするけど他にも想定してないケースがありそうなので本当にそれがいいかはあんまり自信ない

Rails難しい。