Rails: default_roleというクラスメソッド名は避けるべき(翻訳)
長年成熟したRailsプロジェクトでRails 6.1のデフォルトを使うようにアップデートしたところ、まさかのstack level too deep
エラーに出くわしました。調べた結果、クラスメソッドの1つに:default_role
という名前を付けたのがまずかったことが判明しました。
はじめに
私たちは、あるレガシーRailsプロジェクトをRails 7にアップグレードするための準備を続けています。その一環として、まずはRails 6.1のフレームワークデフォルトに切り替えたいと考えています(zeitwerkによるオートロードへの切り替えも作業のひとつです)。
その作業中に、Role
モデルを操作していてこんなエラーを踏んでしまいました。
> Role.where(name: 'basic')
…/gems/activesupport-6.1.7.3/lib/active_support/core_ext/string/inflections.rb:61:in `singularize`:
stack level too deep (SystemStackError)
> Role.count
…/gems/activerecord-6.1.7.3/lib/active_record/attributes.rb:250:in `load_schema!`:
stack level too deep (SystemStackError)
一体何が起きたのでしょうか?このモデル自体は、User
モデルにHABTM1関連付けを追加して、いくつかの便利メソッドをクラスに追加しただけのとてもシンプルなものでした。
class Role < ApplicationRecord
has_and_belongs_to_many :users
def self.admin_role_names
Rails.configuration.admin_role_names.values
end
def self.default_role
where(name: 'basic').first
end
…
end
さて原因は何でしょうか?最初はてっきりzeitwerk
オートロードのせいかと思い込み、さらに頭をかきむしってから、やっとのことで#31461dによるActiveRecord::Core
の変更にたどり着きました。このコミットがActive Recordの全モデルにdefault_role
というクラス属性を追加していました。
module ActiveRecord
module Core
extend ActiveSupport::Concern
…
class_attribute :default_role, instance_writer: false
class_attribute :default_shard, instance_writer: false
mattr_accessor :legacy_connection_handling, instance_writer: false, default: true
…
この変更はマルチプルデータベースのサポートに関連するもので、Active Recordモデルが:reading
と:writing
のどちらのrole
を想定しているかに応じて接続先データベースを変更できるようにします。
Rails 6.1のデフォルトに切り替える前だったら、このメソッドが他のActive Recordモデル(User
など)に存在し、実装をActiveRecord::Base
から得ていることは少し調べればわかります。しかしRole
クラスは引き続きメソッド定義を私たちのカスタムクラスメソッドから得ていたのです。
> Role.method(:default_role).owner
=> Class:Role(id: integer, name: string, created_at: datetime, updated_at: datetime)
> User.method(:default_role).owner
=> Class:ActiveRecord::Base
つまりRole
は、以前のバージョンのRailsでの私たちの経験に基づいて、期待通りに動いていたということです。しかしながら、もしRails 6.1マルチデータベースのロール操作を使おうとしたら、おそらく何らかの問題が起きていたことでしょう。
Rails 6.1のデフォルトに切り替えるということは、指定のActive Recordモデルが読み込まれるときにActiveRecord::Core
の実装が参照されるということです。
これは私たちのドメインモデルではほぼ問題になりませんが、Role
モデルを読み出そうとするとActiveRecord::Core
内部ではdefault_role
クラス属性が参照され、これが(無関係な)カスタムdefault_role
クラスメソッドを呼び出して、Role
クラスでデータベースクエリを実行しようとします。そしてRailsはこのクラスを読み込もうとして堂々巡りになってしまいます。
このようにして、RailsがRole
モデルを読み込もうとするとstack level too deep
エラーが発生します。
Rails 6.1のデフォルトに切り替えたときだけこの現象が発生したのはなぜでしょうか?
Rails 6.1のデフォルトを調べてみると、コネクションハンドリング変更の一環としてlegacy_connection_handling
2という設定フラグが導入されていました。
このフラグは、私たちが変更する前はtrue
と評価されていました。この状況ならモデルの読み込み中にActiveRecord::Core
の実装がdefault_role
を参照することもなく、問題は表面化しません。
しかし新しいRails 6.1のフレームワークデフォルトではlegacy_connection_handling
フラグがfalse
と評価されるので、モデルの読み込み中にdefault_role
が参照され、私たちのカスタム実装がえらいことになってしまうというわけです。
つまり、このフラグをtrue
に戻せば従来のコネクションハンドリング方式に戻り、default_role
メソッドの問題を解決できそうです。
そこで、config/application.rb
ファイルで以下のようにフラグを設定しました。
require_relative 'boot'
require 'rails/all'
Bundler.require(:default, Rails.env)
module Foo
class Application < Rails::Application
config.load_defaults 6.1
config.active_record.legacy_connection_handling = true
config.active_record.belongs_to_required_by_default = false
…
Railsコンソールを起動して試してみると、期待通りにRole
クラスからRole.default_role
メソッドを得ていることがわかります。
> Role.method(:default_role).owner
=> Class:Role(id: integer, name: string, created_at: datetime, updated_at: datetime)
これは問題解決方法の1つですが、この設定のためにフレームワークのデフォルトを上書きする必要があります。この設定を長期間残したくありません。
そこでプロジェクトを見直したところ、Role.default_role
メソッドの呼び出しはアプリケーション全体で数箇所しかないことがわかりました。ならば、このカスタムメソッド名を変更する方が、このプロジェクトの解決方法として(そしてほとんどの場合)ベターです。リネーム後の Role.default
は、動作は同じですが、名前は少々すっきりしました。
class Role < ApplicationRecord
…
def self.default
where(name: 'basic').first
end
…
end
まとめ
Rails 6.1のデフォルトにアップグレードすると、Role
モデルにアクセスするときにstack level too deep
というエラーが発生するようになりました。問題はカスタムのクラスメソッドが存在していたことで、残念ながら Role.default_role
という名前でした。Rails 6では ActiveRecord::Core
がこれと同じ名前のクラス属性を導入し、マルチデータベースのサポートに関連するようになりました。
この名前衝突の影響は、フレームワークフラグ legacy_connection_handling
を true
に設定することで回避できます。ただしフレームワークのデフォルトを上書きすることを避けたかったので、私たちにとって望ましいソリューションは、カスタムのクラスメソッド名の方を変更して、名前の衝突を完全に回避することでした。
参考資料
- Edgeガイドのzeitwerkドキュメント
- Active Recordに
default_role
を導入した コミット: 31461d8 - Rails 6.1で
legacy_connection_handling
から移行する
概要
元サイトの許諾を得て翻訳・公開いたします。
参考: 週刊Railsウォッチ(20201020前編)WIP: 高粒度なロールやシャーディングスワップを実装