ActiveSupport::Concernが裏でやっていること
ActiveSupport::Concern を extend したモジュールは以下の機能が使えるようになる。
class_methods do .. end
ブロックに定義したメソッドをクラスメソッドとして、include する側のクラスに取り込むincluded do .. end
ブロックに定義した処理を include する側のクラスのコンテキストで実行する
これらの機能を実現するために、Rails内部でどのようなことをしているかを調べた。
Railsのバージョン
サンプルコード
まずはサンプルコードでそれぞれの機能を使ったときの動作を確認しよう。
class_methods を使ったサンプルコード
app/models/concerns/sample.rb
モジュールを作成し、class_methods
ブロックの中にメソッドを1つ定義する。
# app/models/concerns/sample.rb
module Sample
extend ActiveSupport::Concern
class_methods do
def sample_class_method
'sample_class_method'
end
end
end
これをモデルのUserクラスの中で include する。
# app/models/user.rb
class User < ApplicationRecord
include Sample
end
すると、次のようにクラスメソッドが使えるようになる。
[1] pry(main)> User.sample_class_method
=> "sample_class_method"
included を使ったサンプルコード
先ほど作成した app/models/concerns/sample.rb
モジュールに対し、今度は included ブロックに validates の処理を書く。
# app/models/concerns/sample.rb
module Sample
extend ActiveSupport::Concern
included do
validates :name, presence: true
end
end
同じようにUserクラスの中で include する。
# app/models/user.rb
class User < ApplicationRecord
include Sample
end
次のように、Userクラスに validates
が効いた状態になっている
[1] pry(main)> user = User.new(name: nil)
=> #<User:0x00007fbda44d76a0 id: nil, name: nil, created_at: nil, updated_at: nil>
[2] pry(main)> user.valid?
=> false
[3] pry(main)> user.errors[:name]
=> ["can't be blank"]
class_methods の実装
それでは、#class_methods
の実装を見てみよう。
# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L140-L146
def class_methods(&class_methods_module_definition)
mod = const_defined?(:ClassMethods, false) ?
const_get(:ClassMethods) :
const_set(:ClassMethods, Module.new)
mod.module_eval(&class_methods_module_definition)
end
最初に const_defined?(:ClassMethods, false)
で、ClassMethods
という定数(モジュール)が存在するかを確認し、あればそれを取得、なければモジュールとして新しく定義している。
その後、#module_eval
をもって ClassMethods
のコンテキストで引数の &class_methods_module_definition
ブロックを実行している。
これが実行されると、先のサンプルコードであれば次の状態になったことと同義となる。
module Sample
module ClassMethods
def sample_class_method
'sample_class_method'
end
end
end
included の実装
続いて #included
の実装を見てみよう。
# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L126-L138
def included(base = nil, &block)
if base.nil?
if instance_variable_defined?(:@_included_block)
if @_included_block.source_location != block.source_location
raise MultipleIncludedBlocks
end
else
@_included_block = block
end
else
super
end
end
included do .. end
のようにブロック付きで実行される想定なので、引数 base
は nil となり最初の条件分岐は真となる。
続いて if instance_variable_defined?(:@_included_block)
の条件分岐は最初の実行の場合は偽となるので @_included_block = block
の部分だけが実行されることになる。
#append_features
さて、class_methods
と included
の実装を見たところだが、それぞれ次のことをやっているだけであった
class_methods
はブロックの中の定義をClassMethods
モジュールに展開するincluded
はブロックの中の定義を@_included_block
インスタンス変数に代入する
これだけでは include した側のクラスにクラスメソッドを定義したり、include する側のコンテキストでコードを実行することはできない。
ではどうしているか?この謎の答えは ActiveSupport::Concern
に定義された #append_features
というメソッドにある。
# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L113-L124
def append_features(base)
if base.instance_variable_defined?(:@_dependencies)
base.instance_variable_get(:@_dependencies) << self
false
else
return false if base < self
@_dependencies.each { |dep| base.include(dep) }
super
base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
end
end
そもそも #append_features
は Ruby の Moduleクラスにあるメソッドで、include
の実体であるとドキュメントに書かれている。
モジュール(あるいはクラス)に self の機能を追加します。 このメソッドは Module#include の実体であり、...(略)
※ https://docs.ruby-lang.org/ja/latest/method/Module/i/append_features.html
つまり、モジュールを include したとき、内部では #append_features
メソッドが実行されるようになっていて、ActiveSupport::Concern
はこの #append_features
をオーバーライドしているのだ。
このオーバライドした #append_features
メソッドの最後の2行に注目してみよう。
base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
base
というのは include した側のクラスなので、サンプルコードで言えば Userクラスにあたる。
base.extend const_get(:ClassMethods)
- Userクラスに対して
ClassMethods
モジュールをextend
する - よって、
ClassMethods
に定義されたメソッドが、Userクラスにクラスメソッドとして定義される
- Userクラスに対して
base.class_eval(&@_included_block)
class_eval
により Userクラスのコンテキストで@_included_block
を実行する- サンプルコードで言えば、
validates: :name, presence: true
が実行されることになる
このようにして、2つの機能を実現していた。
コードを読むとき、Concernモジュール側の視点とConcernモジュールを include する側の視点とを切り替えなければならず、少し混乱した...。