Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: default_roleというクラスメソッド名は避けるべき(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

参考: 週刊Railsウォッチ(20201020前編)WIP: 高粒度なロールやシャーディングスワップを実装

TIL: today I learned(今日の教訓)

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_handling2という設定フラグが導入されていました。

このフラグは、私たちが変更する前は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_handlingtrue に設定することで回避できます。ただしフレームワークのデフォルトを上書きすることを避けたかったので、私たちにとって望ましいソリューションは、カスタムのクラスメソッド名の方を変更して、名前の衝突を完全に回避することでした。

参考資料

  1. Edgeガイドのzeitwerkドキュメント
  2. Active Recordにdefault_roleを導入した コミット: 31461d8
  3. Rails 6.1でlegacy_connection_handlingから移行する

関連記事

Rails: enumに定義できない、ActiveRecord::Relationと競合する文字列の一覧


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。