Help us understand the problem. What is going on with this article?

Railsでモデルクラスの名前をネームスペースとして流用してはいけません(warning: toplevel constantが発生します)

2019.8.21追記:Ruby 2.5からはこの問題が発生しなくなりました

Ruby 2.5からは定数探索のルールが変わったため、この記事で書いているような問題は発生しなくなりました。

参考:サンプルコードでわかる!Ruby 2.5の主な新機能と変更点 Part 1 - Qiita

Ruby 2.5環境だと、この記事で使用したテストケースはどちらも正常に実行されます。

# Ruby 2.5環境 + Rails 5.2で全specを実行した場合
$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded

Randomized with seed 46529
....

Finished in 0.09461 seconds (files took 3.17 seconds to load)
4 examples, 0 failures

Randomized with seed 46529
# Ruby 2.5環境 + Rails 5.2で個別にspecを実行した場合
$ bundle exec rspec ./spec/controllers/staff/blogs_controller_spec.rb  
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded

Randomized with seed 16532
..

Finished in 0.05565 seconds (files took 3.16 seconds to load)
2 examples, 0 failures

Randomized with seed 16532

参考用として、Ruby 2.5 + Rails 5.2で実行可能なサンプルコードを以下にアップしています。

ついでに書いておくと、Rails 6はRuby 2.5以上が必須になるので、自ずとこの問題は発生しなくなります。
以下はRails 6.0版のサンプルコードです。

(さらに言うと、Rails 6ではZeitwerkという新しいオートロードシステムが導入されたので、本記事の説明もRails 6では当てはまらない部分があります。Zeitwerk版のオートロードシステムに関する詳細はこちらのRailsガイドを参照してください)

というわけで、以下の本文は Ruby 2.4以前の環境で発生する事象 として読んでください。

TL;DR(最初に結論)

  • モデルクラスの名前(Admin など)をネームスペースに流用する(たとえば Admin::BlogsControllerのようなクラスを作る)と、"warning: toplevel constant"という警告が発生し、エラーが起きる原因になります。
  • もう少し厳密に言うと、 BlogsController のような 同名のクラスがトップレベル(ネームスペース無し)に存在している場合にこの警告が出ます。
  • 無用なトラブルの発生を防ぐため、ネームスペースは極力モデルクラスとかぶらない名前を付けてください(Admins::BlogsController など)

トラブルが発生するケース

ここではサンプルアプリケーションとして架空のブログアプリケーションを考えます。
仕様は以下の通りです。

  • ブログは下書きとして保存することができる
  • 一般ユーザーは下書きのブログが見えない
  • スタッフは下書きのブログも見える
  • 一般ユーザーは http://localhost:3000/blogs のようなURLにアクセスする
  • スタッフは http://localhost:3000/staff/blogs のようなURLにアクセスする

以下はこのアプリケーションのソースコードです。
(デバッグ用の puts を最初から仕込んであります)

モデル

app/models/blog.rb
class Blog < ActiveRecord::Base
  scope :without_draft, -> { where(draft: false) }
end
app/models/staff.rb
class Staff < ActiveRecord::Base
  puts "Staff model loaded"
end

ルーティング

config/routes.rb
Rails.application.routes.draw do
  resources :blogs
  namespace :staff do
    resources :blogs
  end
  root to: 'blogs#index'
end

コントローラ

ここではindexとshowのみを記載します。

app/controllers/blogs_controller.rb
class BlogsController < ApplicationController
  puts "BlogsController loaded"

  before_action :set_blog, only: [:show, :edit, :update, :destroy]

  def index
    @blogs = Blog.without_draft.all
  end

  def show
  end

  private
    def set_blog
      @blog = Blog.without_draft.find(params[:id])
    end
end
app/controllers/staff/blogs_controller.rb
class Staff::BlogsController < ApplicationController
  puts "Staff::BlogsController loaded"

  before_action :set_blog, only: [:show]

  def index
    @blogs = Blog.all
  end

  def show
  end

  private
    def set_blog
      @blog = Blog.find(params[:id])
    end
end

テストコード(コントローラスペック)

前述の仕様を検証するため、次のようなテストコードを書きました。

spec/factories/blogs.rb
FactoryGirl.define do
  factory :blog do
    title "MyString"
    content "MyText"
    draft false
    trait :draft do
      draft true
    end
  end
end
spec/controllers/blogs_controller_spec.rb
require 'rails_helper'

puts "blogs_controller_spec.rb loaded"

RSpec.describe BlogsController, type: :controller do
  let!(:blog) { create :blog, :draft }
  describe 'GET #index' do
    it '下書きのブログは含まれない' do
      get :index
      expect(assigns(:blogs)).to eq []
    end
  end
  describe 'GET #show' do
    it '下書きのブログは表示できない' do
      expect { get :show, id: blog }.to raise_error ActiveRecord::RecordNotFound
    end
  end
end
spec/controllers/staff/blogs_controller_spec.rb
require 'rails_helper'

puts "staff/blogs_controller_spec.rb loaded"

RSpec.describe Staff::BlogsController, type: :controller do
  let!(:blog) { create :blog, :draft }
  describe 'GET #index' do
    it '下書きのブログも含まれる' do
      get :index
      expect(assigns(:blogs)).to eq [blog]
    end
  end
  describe 'GET #show' do
    it '下書きのブログも表示できる' do
      get :show, id: blog
      expect(assigns(:blog)).to eq blog
    end
  end
end

テストコードの実行結果(失敗)

上記のテストコードは問題なくパスするはずですが、なぜか以下のようなエラーが出て失敗しました。

$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
/(your-app)/spec/controllers/staff/blogs_controller_spec.rb:4: warning: toplevel constant BlogsController referenced by Staff::BlogsController

Randomized with seed 38796
FF..

Failures:

  1) BlogsController GET #index 下書きのブログも含まれる
     Failure/Error: expect(assigns(:blogs)).to eq [blog]

       expected: [#<Blog id: 1, title: "MyString", content: "MyText", created_at: "2016-02-11 00:14:31", updated_at: "2016-02-11 00:14:31", draft: true>]
            got: #<ActiveRecord::Relation []>

       (compared using ==)

       Diff:
       @@ -1,2 +1,2 @@
       -[#<Blog id: 1, title: "MyString", content: "MyText", created_at: "2016-02-11 00:14:31", updated_at: "2016-02-11 00:14:31", draft: true>]
       +[]

     # ./spec/controllers/staff/blogs_controller_spec.rb:9:in `block (3 levels) in <top (required)>'

  2) BlogsController GET #show 下書きのブログも表示できる
     Failure/Error: @blog = Blog.without_draft.find(params[:id])

     ActiveRecord::RecordNotFound:
       Couldn't find Blog with 'id'=1 [WHERE "blogs"."draft" = ?]
     # ./app/controllers/blogs_controller.rb:68:in `set_blog'
     # ./spec/controllers/staff/blogs_controller_spec.rb:14:in `block (3 levels) in <top (required)>'

Finished in 0.0915 seconds (files took 1.33 seconds to load)
4 examples, 2 failures

Failed examples:

rspec ./spec/controllers/staff/blogs_controller_spec.rb:7 # BlogsController GET #index 下書きのブログも含まれる
rspec ./spec/controllers/staff/blogs_controller_spec.rb:13 # BlogsController GET #show 下書きのブログも表示できる

Randomized with seed 38796  

実行結果をよく見てみると、どちらも staff/blogs_controller_spec.rb でエラーが発生しています。
そこで、このテストコードだけを単体で動かしてみることにしました。

$ bundle exec rspec ./spec/controllers/staff/blogs_controller_spec.rb  
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded

Randomized with seed 38943
..

Finished in 0.07659 seconds (files took 1.43 seconds to load)
2 examples, 0 failures

Randomized with seed 38943

あれあれ?今度はなぜかパスしてしまいました。
これは一体なぜなんでしょうか??

全体実行時にエラーが発生する原因

端的に言うと、スタッフ用のテストコード内で、Staff::BlogsController ではなく、 BlogsController (一般ユーザー用)が実行されてしまうためです。

ではなぜ、Staff::BlogsControllerが呼び出されないのかというと、クラスが呼び出される順番と、Rubyの定数(=クラス名)探索の仕様を理解する必要があります。

まずデバッグ用に仕込んだ puts の出力結果を載せます。

blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
/(your-app)/spec/controllers/staff/blogs_controller_spec.rb:4: warning: toplevel constant BlogsController referenced by Staff::BlogsController

上の出力結果を参考にしながら、RSpecの全体実行時に何が起きているのかを順に表すとこうなります。

  1. RSpecがspecディレクトリ内のファイルを順に読み込む。今回であれば blogs_controller_spec.rbstaff/blogs_controller_spec.rb の順に読み込む。
  2. blogs_controller_spec.rb 内には RSpec.describe BlogsController, type: :controller do の記述がある。このタイミングで BlogsController が読み込まれる。( blogs_controller_spec.rb loadedBlogsController loaded
  3. 続いて、staff/blogs_controller_spec.rb の読み込みに移る。このファイルには RSpec.describe Staff::BlogsController, type: :controller do の記述がある。( staff/blogs_controller_spec.rb loaded
  4. ここでまず Staff::BlogsControllerStaff クラスが読み込まれる。( Staff model loaded
  5. 次に、Rubyは Staff::BlogsController の読み込み(定数探索)を試みる。この時点ではまだ Staff::BlogsController は実行環境内では読み込まれていないため、Rubyは「Staff クラスは BlogsController という定数(クラス)を定義していない」と見なす。
  6. Staff クラスには BlogsController が見つからなかったので、Rubyは Staff クラスの親クラス(正確には Staff.ancestors で返されるクラス)を順にたどっていき、それぞれのクラス内に BlogsController が定義されていないかチェックする。
  7. 親クラスを順にたどっていくと、Object クラスにぶつかる。実はトップレベル(ネームスペース無し)のクラスは Object クラス内の定数として定義される。上記 2 で読み込まれた BlogsControllerObject クラス内に定義されている。よって、ここで BlogsController が発見される。( ここが意図しない挙動!!
  8. BlogsController が見つかったので、Ruby は Staff::BlogsController がトップレベルの BlogsController を参照していると見なして、定数探索を終了する。ただし、本来意図しない読み込みである可能性もあるため、"warning: toplevel constant BlogsController referenced by Staff::BlogsController" という 警告を出力 する。
  9. 結局、スタッフ用の Staff::BlogsController は読み込まれず、一般ユーザー用の BlogsController が使われるため、テストが失敗する。

上の処理フローで重要なポイントは以下の通りです。

  1. 一般ユーザー用の BlogsController が先に読み込まれている。
  2. BlogsController はトップレベルで定義されているため、Object クラスの定数(Object::BlogsController)として扱われる。
  3. スタッフ用の Staff::BlogsController はまだ読み込まれていない。
  4. 指定されたネームスペース(今回であればStaff)内に対象となる定数(クラス)が見つからない場合、Rubyはネームスペースの親クラスを順に探索していく。その結果、一般ユーザー用の BlogsControllerObject::BlogsController)が見つかる

特に2番目と4番目ののポイントが、多くのRubyプログラマの普段意識しないところだと思います。

単体実行時にパスする原因

一方、staff/blogs_controller_spec.rb を単体で実行すると、なぜかパスしてしまいました。
これはなぜなんでしょうか?

先ほどと同様、デバッグ用に仕込んだ puts の結果を載せます。

staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded

次に、RSpecの単体実行時に起きていることを説明します。

  1. RSpecが staff/blogs_controller_spec.rb を読み込む。このファイルには RSpec.describe Staff::BlogsController, type: :controller do の記述がある。( staff/blogs_controller_spec.rb loaded
  2. ここでまず Staff::BlogsControllerStaff クラスが読み込まれる。( Staff model loaded
  3. 次に、Rubyは Staff::BlogsController の読み込み(定数探索)を試みる。この時点ではまだ Staff::BlogsController は実行環境内では読み込まれていないため、Rubyは「Staff クラスは BlogsController という定数(クラス)を定義していない」と見なす。
  4. Staff クラスには BlogsController が見つからなかったので、Rubyは Staff クラスの親クラス(正確には Staff.ancestors で返されるクラス)を順にたどっていき、 BlogsController が定義されていないかチェックする。
  5. しかし、親クラスをたどっても BlogsController は見つからない。よって、Rubyはいったん定数探索に失敗する。
  6. Rubyは Staff クラスの const_missing メソッドを呼び出す。厳密には ActiveSupport::Dependenciesconst_missing メソッドが呼び出される。( ここがさっきと違うところ!!
  7. const_missing メソッド内では autoload_paths で定義された各ディレクトリ内を探索して、"staff/blogs_controller" という名前に合致するファイルがないか探す。ここで app/controllers/staff/blogs_controller.rb が見つかる。
  8. app/controllers/staff/blogs_controller.rb をrequireする。ここでスタッフ用の Staff::BlogsController が読み込まれる。( Staff::BlogsController loaded
  9. Staff::BlogsController が無事に見つかったので、Rubyは定数探索を終了する。
  10. スタッフ用の Staff::BlogsController が読み込まれているので、テストは正常にパスする。

上の処理フローで重要なポイントは以下の2点です。

  • Rubyは Staff::BlogsController の探索にいったん失敗する。
  • しかし、const_missing メソッドを利用して、Railsが Staff::BlogsController を自動的に見つけ出してくれる。

ちなみに、上記ステップ 2 の Staff クラスも const_missing メソッド経由で読み込まれています。

参考までにRubyMineを使って Staff::BlogsController が読み込まれるまでのコールスタックを表示してみました。
これを見ると、 const_missing メソッドが呼び出されているのがわかると思います。

Screen Shot 2016-02-11 at 8.43.02.png

さて、原因はわかりましたが、どうやって解決するのが良いでしょうか?

解決策1:モデル名とかぶらないようにネームスペースを変更する(オススメ)

若干修正の手間はかかりますが、テストコード以外の場所でも予期しないトラブルが発生したりしないよう、モデル名とは異なるネームスペースに変更することをオススメします。

たとえば、staffstaffs に変更するなどです。

ルーティング

config/routes.rb
Rails.application.routes.draw do
  resources :blogs
  namespace :staffs do
    resources :blogs, only: %i(index show)
  end
  root to: 'blogs#index'
end

スタッフ用コントローラ

app/controllers/staffs/blogs_controller.rb
class Staffs::BlogsController < ApplicationController
  puts "Staffs::BlogsController loaded"

  before_action :set_blog, only: [:show]

  def index
    @blogs = Blog.all
  end

  def show
  end

  private
    def set_blog
      @blog = Blog.find(params[:id])
    end
end

テストコード(スタッフ用コントローラ)

spec/controllers/staffs/blogs_controller._specrb
require 'rails_helper'

puts "staffs/blogs_controller_spec.rb loaded"

RSpec.describe Staffs::BlogsController, type: :controller do
  let!(:blog) { create :blog, :draft }
  describe 'GET #index' do
    it '下書きのブログも含まれる' do
      get :index
      expect(assigns(:blogs)).to eq [blog]
    end
  end
  describe 'GET #show' do
    it '下書きのブログも表示できる' do
      get :show, id: blog
      expect(assigns(:blog)).to eq blog
    end
  end
end

こうすると、全体実行してもテストはパスします。

$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staffs/blogs_controller_spec.rb loaded
Staffs::BlogsController loaded
Staff model loaded

Randomized with seed 160
....

Finished in 0.09497 seconds (files took 1.4 seconds to load)
4 examples, 0 failures

Randomized with seed 160

ネームスペースを変えるとテストがパスする理由

エラーが起きない理由はこうです。

  1. RSpecがspecディレクトリ内のファイルを順に読み込む。今回であれば blogs_controller_spec.rbstaffs/blogs_controller_spec.rb の順に読み込む。
  2. blogs_controller_spec.rb 内には RSpec.describe BlogsController, type: :controller do の記述がある。このタイミングで BlogsController が読み込まれる。( blogs_controller_spec.rb loadedBlogsController loaded
  3. 続いて、staffs/blogs_controller_spec.rb の読み込みに移る。このファイルには RSpec.describe Staffs::BlogsController, type: :controller do の記述がある。( staffs/blogs_controller_spec.rb loaded
  4. Staffs はどこにも定義されていないが、Railsの自動モジュール機能(参照)により、自動的に モジュール として定義される。
  5. 次に、Rubyは Staffs::BlogsController の読み込み(定数探索)を試みる。この時点ではまだ Staffs::BlogsController は実行環境内では読み込まれていないため、Rubyは「Staffs モジュールは BlogsController という定数(クラス)を定義していない」と見なす。
  6. また、Staffs モジュールは Object クラスを継承していない(正確には Staffs.ancestorsObject クラスが含まれない)ので、親クラスをたどっても BlogsController が見つからない。よって、Rubyはいったん定数探索に失敗する。( ここがさっきと違うところ!!
  7. Rubyは Staffs モジュールの const_missing メソッドを呼び出す。厳密には ActiveSupport::Dependenciesconst_missing メソッドが呼び出される。
  8. const_missing メソッド内では autoload_paths で定義された各ディレクトリ内を探索して、"staffs/blogs_controller" という名前に合致するファイルがないか探す。ここで app/controllers/staffs/blogs_controller.rb が見つかる。
  9. app/controllers/staffs/blogs_controller.rb をrequireする。ここでスタッフ用の Staffs::BlogsController が読み込まれる。( Staffs::BlogsController loaded
  10. Staffs::BlogsController が無事に見つかったので、Rubyは定数探索を終了する。
  11. スタッフ用の Staffs::BlogsController が読み込まれているので、テストは正常にパスする。

簡潔にまとめると、Staff はクラスだったので、Object クラスが探索対象になりましたが、Staffs はモジュールなので Object クラスが探索対象にならない、というのが大きな違いです。

解決策2:Staffクラスを読み込んだ際に、ネームスペース配下のクラスを強制的に読み込む(未検証)

解決策1はエラーは起きなくなりますが、ネームスペースを変更するため、これまで使っていた URL も変わります。
http://localhost:3000/staff/blogshttp://localhost:3000/staffs/blogs に変わる)
すでにリリース済みのアプリケーションだと、URLが突然変わってしまうのはあまり望ましくありません。

その場合は、以下のようにネームスペース配下のクラスを強制的に読み込むようにすると良いかもしれません。

app/models/staff.rb
class Staff < ActiveRecord::Base
  puts "Staff model loaded"
end
Dir[File.join(Rails.root, 'app/controllers/staff/*.rb')].each { |f| require_dependency f }

こうしておけば、Staff::BlogsController を参照したときに、以下のような動きになるのでエラーが起きなくなるはずです。

  1. Staff クラスが読み込まれる
  2. Staff クラスの読み込みが終わると、その直後に同一ネームスペース配下のクラス(主にコントローラ)が読み込まれる。
  3. BlogsController という定数が Staff クラス内に見つかるので、定数探索がそこで終わる。

以下はテストの実行結果です。

$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded

Randomized with seed 31099
....

Finished in 0.09575 seconds (files took 1.44 seconds to load)
4 examples, 0 failures

Randomized with seed 31099

ただし、これはジャストアイデアであり、実務で開発しているアプリケーションで試したことはないので、もしかすると別の問題を引き起こしたりするかもしれません。
僕は普段、解決策1を使っています。

なお、上記のコードで使っている require_dependency については下記のドキュメントを参照してください。

https://railsguides.jp/autoloading_and_reloading_constants_classic_mode.html#require-dependency

解決策3:テスト実行前に強制的にクラスを読み込む(非推奨)

別のアイデアとして、spec/rails_helper.rb 内で強制的にネームスペースに属するクラスを読み込んでしまう(つまりテスト実行前に読み込んでしまう)、という案もあります。

spec/rails_helper.rb
Dir[File.join(Rails.root, 'app/controllers/staff/*.rb')].each { |f| require_dependency f }

RSpec.configure do |config|
  # ...

ただ、これだと確かにテストはパスするようになりますが、開発環境や本番環境で動かしたときに、予期しないタイミングで "warning: toplevel constant" が発生するかもしれません。(発生しないかもしれませんが、僕にはハッキリわかりません)
なので、テスト環境だけに特殊な仕掛けを入れるのはあまり得策ではない気がします。

サンプルコード

今回使用したアプリケーションのソースコードはGitHubに置いてあります。

JunichiIto/namespaced-controller-sandbox

まとめ

僕はもともと社内で「モデル名と同じネームスペースを付けるとトラブルの原因になるよ」と聞いていたので、いつも解決策1の方法でアプリケーションを作っていました。
しかし、そういった予備知識が無いと、どちらも同じ名前を付けてしまうことは結構起きそうな気がします。

もしこの話を知らなかった人がいたら、これからRailsアプリケーションを開発するときにモデル名とネームスペースの設計に十分注意してください。

参考文献

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした