Ruby
Rails

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

More than 3 years have passed since last update.


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 については下記のドキュメントを参照してください。

http://railsguides.jp/constant_autoloading_and_reloading.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アプリケーションを開発するときにモデル名とネームスペースの設計に十分注意してください。


参考文献