こんにちは。株式会社iCAREでサーバーサイドエンジニアをしている越川と申します。
この記事は、iCARE Dev Advent Calendar 第2レーン の18日目です。
今回は、なぜModel::ActiveRecord_RelationオブジェクトはModelに定義されたクラスメソッドを呼び出せるのか調べてみました。
環境
Ruby 2.6.9
Rails 6.1.4
前準備
pry-railsとpry-byebugをinstallしておきます。
# Gemfile
group :development, :test do
gem 'pry-rails'
gem 'pry-byebug'
end
$ bundle install
次に、ApplicationRecordを継承した適当なクラスにscopeとクラスメソッドを定義しておきます。
class User < ApplicationRecord
scope :test_scope, -> {
binding.pry
roles
}
def self.roles
1
end
end
これで準備完了です。
ソースコードを追う
rails c でコンソールを起動して、Userクラスに対してtest_scopeを実行するところからスタートです。
> User.test_scope
From: /app/app/models/user.rb:9 :
7: scope :test_scope, -> {
8: binding.pry
=> 9: roles
10: }
11:
12: def self.roles
13: 1
14: end
15: end
ここからstepメソッドでコードリーディングの旅の始まりです。
ActiveRecord::Delegation::ClassSpecificRelation#method_missing
stepメソッドでrolesの処理の中に入ると、
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:106 ActiveRecord::Delegation::ClassSpecificRelation#method_missing:
105: def method_missing(method, *args, &block)
=> 106: if @klass.respond_to?(method)
107: @klass.generate_relation_method(method)
108: scoping { @klass.public_send(method, *args, &block) }
109: else
110: super
111: end
112: end
ActiveRecord::Delegation::ClassSpecificRelationクラスのmethod_missingが呼ばれるようです。
引数やインスタンス変数の中身を確認していきます。
> method
=> :roles
> args
=> []
> block
=> nil
> @klass
=> User (call 'User.connection' to establish a connection)
> @klass.respond_to?(method)
=> true
Userクラスに対して引数のmethodが定義されているかどうか?をチェックし、定義されていればgenerate_relation_method(method)を呼び出すようになっていました。
もし定義されていなければ、BasicObjectクラスのmethod_missingが呼び出されると思われます。(未確認でごめんなさい)
ActiveRecord::Delegation::DelegateCache#generate_relation_method
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:38 ActiveRecord::Delegation::DelegateCache#generate_relation_method:
37: def generate_relation_method(method)
=> 38: generated_relation_methods.generate_method(method)
39: end
generated_relation_methodsの返り値は以下です。
> generated_relation_methods
=> User::GeneratedRelationMethods
今回はどうやってUser::GeneratedRelationMethodsが定義されるのか、そもそもGeneratedRelationMethodsってなんぞや、については追いませんが、調査してみると面白そうです。
ActiveRecord::Delegation::DelegateCache#generated_relation_methods
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:49 ActiveRecord::Delegation::DelegateCache#generated_relation_methods:
48: def generated_relation_methods
=> 49: @generated_relation_methods ||= GeneratedRelationMethods.new.tap do |mod|
50: const_set(:GeneratedRelationMethods, mod)
51: private_constant :GeneratedRelationMethods
52: end
53: end
||=の構文が使われているので、@generated_relation_methodsが未定義ならGeneratedRelationMethods.new.tap以降の結果が代入される感じですね。
stepメソッドでさらにコードに潜ってみます。
ActiveRecord::Delegation::GeneratedRelationMethods#generate_method
このメソッドが本丸ですね、、、
module_evalメソッドで動的にメソッドを生やしているところが肝だと思いますが、処理を追ってみます。
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:60 ActiveRecord::Delegation::GeneratedRelationMethods#generate_method:
59: def generate_method(method)
=> 60: synchronize do
61: return if method_defined?(method)
62:
63: if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) && !DELEGATION_RESERVED_METHOD_NAMES.include?(method.to_s)
64: definition = RUBY_VERSION >= "2.7" ? "..." : "*args, &block"
65: module_eval <<-RUBY, __FILE__, __LINE__ + 1
66: def #{method}(#{definition})
67: scoping { klass.#{method}(#{definition}) }
68: end
69: RUBY
70: else
71: define_method(method) do |*args, &block|
72: scoping { klass.public_send(method, *args, &block) }
73: end
74: ruby2_keywords(method) if respond_to?(:ruby2_keywords, true)
75: end
76: end
77: end
synchronizeメソッドの定義元は以下でした。
$ method(:synchronize).source_location
=> ["/usr/local/lib/ruby/2.6.0/mutex_m.rb", 77]
Mutexクラスについては以下のドキュメントが詳しかったです。
一部引用します。
Mutex(Mutal Exclusion = 相互排他ロック)は共有データを並行アクセスから保護する ためにあります。Mutex の典型的な使い方は(m を Mutex オブジェクトとします):
出典: https://docs.ruby-lang.org/ja/3.0.0/class/Thread=3a=3aMutex.html
generate_methodの実行は並行アクセスの可能性があるから、synchronizeメソッドを使って排他制御を加えているようですね。
引き続きコードを読み進めてみます。
> method_defined?(method)
=> false
> /\A[a-zA-Z_]\w*[!?]?\z/.match?(method)
=> true
> !DELEGATION_RESERVED_METHOD_NAMES.include?(method.to_s)
=> true
# 返り値を見たところ予約語の集合でした。
> DELEGATION_RESERVED_METHOD_NAMES.class
=> Set
> definition = RUBY_VERSION >= "2.7" ? "..." : "*args, &block"
=> "*args, &block"
> RUBY_VERSION
=> "2.6.9"
> RUBY_VERSION >= "2.7"
=> false
諸々の処理を行った後で、module_evalメソッドを使って、引数で与えられたメソッド名を元にメソッドをActiveRecord_Relationオブジェクトに対して動的に定義していることがわかりました。
実際にActiveRecord_Relationに対してクラスメソッドを呼び出してみる
一通りコードを追えました。これでブラックボックスだった内部の仕組みの一端を理解することができましたね。(Rubyすごい)
この感動を味わうために、ActiveRecord_Relationオブジェクトを作って、クラスメソッドを呼び出してみましょう。
$ rails c
$ @users = User.all
$ @users.method(:roles).source_location
=> nil
$ @users.roles
=> 1
$ @users.method(:roles).source_location
=> ["/usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb", 66]
定義元がUserクラスではなくActiveRecord::Relation::Delegationクラスの66行目になっていることがわかります。Rubyすごい(2回目)
まとめ
method_missingメソッドを使って、メソッドの呼び出し時にそのメソッドがレシーバーに定義されてなかったら、
レシーバーのクラスに対してメソッドが定義されているかを確認し、定義されていればレシーバーにそのメソッドを生やすという黒魔術的なコードになっていました、、、
Rubyの表現力というか自由さには本当に驚かされます、、、
ただ、「実務で同様の手法を使うのか?」と問われると、コードの難読化を招き、実装によっては後々の負債の原因にもなりかねないので、
「あーこういうやり方もあるんだなぁ」で留めておきたいと思います、、、
株式会社iCAREではエンジニア採用を強化しています
各ポジションにて求人をしております。働くひとの健康を世界中に創る事業にご興味のある方は、まずはカジュアル面談からでもお気軽ご連絡ください!
紹介商談も募集しております
弊社、株式会社iCAREでは、企業が従業員の健康管理をするSaaSの商談をしていただける企業さまをご紹介いただける企業さまを募っております。
健康管理にお困りの人事の方のお知り合いがいらっしゃいましたら是非株式会社iCAREまでご連絡くださいませ!