はじめに
◆この記事は何か
extend ActiveSupport::Concern
- 実際に Rails7.1.3.2 のリポジトリで grep すると235ファイルヒットすることからも ActiveSupport::Concern モジュールは Rails で非常によく使われているモジュールであることがわかる
- 今回はそんな ActiveSupport::Concern モジュールについて、使い方を確認した上でモジュールの中身を紐解いていく
◆対象は誰か
◆この記事のねらい
- Rails のソースコードでよく出てくる ActiveSupport::Concern を理解することで、ソースコードリーディングの解像度を上げたい
先に結論
- ActiveSupport::Concern とは、クラスメソッドを追加する機能をカプセル化して、モジュールを include(もしくは prepend) した時にインスタンスメソッドだけではなく、クラスメソッドも取得できるようにするモジュールのこと
- モジュールに ActiveSupport::Concern を extend しておくことによって、依存関係を適切に管理しながらクラスメソッドの追加機能を簡単に使えるようになる
内容
ActiveSupport::Concernとは
- モジュールを include した時に、ベースクラスにインスタンスメソッドと一緒にクラスメソッドも一緒に追加する機能をカプセル化した Rails のモジュール
- モジュールに ActiveSupport::Concern を extend しておくことによって、クラスメソッドの追加機能を簡単に使えるようになる
ActiveSupport::Concernの使い方
module M #モジュールの中でActiveSupport::Concernモジュールをextendする extend ActiveSupport::Concern def instance_method puts "M#instance_method" end included do attr_accessor :name end #ClassMethodsモジュールの中で、ベースクラスに追加するクラスメソッドを定義する module ClassMethods def class_method puts "M.class_method" end end end class C #ActiveSupport::Concernをextendしたモジュールをincludeする include M end c = C.new c.instance_method # => M#instance_method c.name = "C#name" c.name # => C#name C.class_method # => M.class_method
- 上記の例のように ActiveSupport::Concern モジュールを extend したモジュールの中で、インスタンスメソッドとは別で、ベースクラスに対してクラスメソッドとして追加したいメソッドを ClassMethods モジュールのスコープ内で定義する
- このモジュールを include すると、ベースクラスで ActiveSupport::Concern モジュールを extend したモジュールのインスタンスメソッドだけではなく、クラスメソッドも使えるようになる
- ※後述するが ClassMethods モジュールは class_methods メソッドで定義することもできる
- 以下を例にとると M モジュールが「concern」、C クラスが「ベースクラス」ということになる
- ※ここを覚えておかないと本記事の内容が8割方理解できなくなるので、曖昧になった際は都度戻ってきて確認して欲しい
module M extend ActiveSupport::Concern def instance_method puts "M#instance_method" end included do attr_accessor :name end module ClassMethods def class_method puts "M.class_method" end end end class C include M end
Railsのソースコードで実際に使われている箇所
- 上で ActiveSupport::Concern の使い方を確認したが、Rails のソースコードでは実際どのように使われているのか?典型的な User モデルを例に見ていく
- 以下の User クラスは username のバリデーションが定義されており、ApplicationRecord クラスを継承している
# app/models/user.rb class User < ApplicationRecord validates :username, presence: true end
- ApplicationRecord クラスは ActiveRecord::Base クラスを継承している
# app/models/application_record.rb class ApplicationRecord < ActiveRecord::Base end
- ActiveRecord::Base クラスは ActiveModel::API モジュールを include している
# rails/activerecord/lib/active_record/base.rb module ActiveRecord # :nodoc: class Base include ActiveModel::API ... end end
- ActiveModel::API モジュールは ActiveModel::Validations モジュールを include している
- また ActiveSupport::Concern モジュールを extend している(ここで登場!)
- (つまり ActiveModel::API は concern ということ)
# rails/activemodel/lib/active_model/api.rb module ActiveModel module API extend ActiveSupport::Concern include ActiveModel::Validations end end
- ActiveModel::Validations モジュール内には ClassMethods モジュールが定義されており、そこで validates メソッドが定義されている
# activemodel/lib/active_model/validations/validates.rb module ActiveModel module Validations module ClassMethods def validates(*attributes) defaults = attributes.extract_options!.dup validations = defaults.slice!(*_validates_default_keys) raise ArgumentError, "You need to supply at least one attribute" if attributes.empty? raise ArgumentError, "You need to supply at least one validation" if validations.empty? defaults[:attributes] = attributes validations.each do |key, options| key = "#{key.to_s.camelize}Validator" begin validator = const_get(key) rescue NameError raise ArgumentError, "Unknown validator: '#{key}'" end next unless options validates_with(validator, defaults.merge(_parse_validates_options(options))) end end ... end end end
- これにより、ActiveModel::Validations::ClassMethods モジュールで定義された validates メソッドが Userクラスのクラスメソッドとして利用できるようになっているため、User モデルでクラスマクロとしてバリデーションを定義できるようになっている
ソースコードを読んでみる
- ここからは、ActiveSupport::Concern モジュールをメソッドごとに分解して詳細に見ていくことで「クラスメソッドを追加する機能をカプセル化して、モジュールを include した時にインスタンスメソッドだけではなく、クラスメソッドも取得できるようにする」機能をどのように実現しているのかを確認する
- まず、以下が ActiveSupport::Concern モジュールの全体像
# rails/activesupport/lib/active_support/concern.rb module ActiveSupport module Concern class MultipleIncludedBlocks < StandardError # :nodoc: def initialize super "Cannot define multiple 'included' blocks for a Concern" end end class MultiplePrependBlocks < StandardError # :nodoc: def initialize super "Cannot define multiple 'prepended' blocks for a Concern" end end def self.extended(base) # :nodoc: base.instance_variable_set(:@_dependencies, []) end def append_features(base) # :nodoc: 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 def prepend_features(base) # :nodoc: if base.instance_variable_defined?(:@_dependencies) base.instance_variable_get(:@_dependencies).unshift self false else return false if base < self @_dependencies.each { |dep| base.prepend(dep) } super base.singleton_class.prepend const_get(:ClassMethods) if const_defined?(:ClassMethods) base.class_eval(&@_prepended_block) if instance_variable_defined?(:@_prepended_block) end end 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 def prepended(base = nil, &block) if base.nil? if instance_variable_defined?(:@_prepended_block) if @_prepended_block.source_location != block.source_location raise MultiplePrependBlocks end else @_prepended_block = block end else super end end 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 end end
参考:https://github.com/rails/rails/blob/main/activesupport/lib/active_support/concern.rb
self.extended(base)
def self.extended(base) # :nodoc: base.instance_variable_set(:@_dependencies, []) end
- ActiveSupport::Concern を extend した時、Ruby がベースクラスを引数にして self(ここでいうと ActiveSupport::Concern )の extended というフックメソッドを呼び出す
- ここでは、concern(ソースコードでいうと base ) に対して Object#instance_variable_set メソッドを使って @_dependencies クラスインタンス変数を空配列で定義している
class_methods(&class_methods_module_definition)
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
- concern に class_methods メソッドが定義されていた場合はこの処理が呼ばれる
- class_methods メソッドは引数としてブロックを Proc オブジェクトに変換して受け取る
- Module#const_defined? メソッドで concern に ClassMethods という定数が存在するかを確認し、定数があれば Module#const_get メソッドで定数の値を取得し、定数がなければ Module#const_set ****メソッドで ClassMethods というモジュールを定義する
- ※ Module#const_defined? メソッドの第二引数は inherit を boolean で指定する。false を指定するとスーパークラスや include したモジュールで定義された定数は対象に含まない
- 次に Module#module_eval メソッドを使って ClassMethods モジュールのコンテキストで Procオブジェクト(class_methods_module_definition)をブロックに変換して評価する
- つまり、ブロックが ClassMethods モジュールのコンテキスト内で評価される
# このように定義されていた場合... module M extend ActiveSupport::Concern class_methods do def hoge 'M#hoge' end end end # 以下のように定義し直されるということ module M module ClassMethods def hoge 'M#hoge' end end end
included(base = nil, &block)
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 メソッドはベースクラスが concern を include したときにフックメソッドとして呼ばれる
- まずは、base が nil かどうかで条件分岐。base が nil の場合は後述の処理を、base が存在する場合はオーバーライドせず Module#included を呼び出す
- base が nil 以外になるのは、以下のように concern が include された場合。この場合は M1 モジュールが base になるので、Module クラスの included メソッドが呼ばれる
**module M2 include M1 end**
- base が nil になるのは、concern で以下のように呼び出しを行った場合
**module M1 extend ActiveSupport::Concern included do def hoge puts "M#hoge" end end end**
- この場合、まず Object#instance_variable_defined? メソッドで @_included_block クラスインスタンス変数が定義されているかを確認
- 定義されていない場合は、引数の Proc オブジェクトを @_included_block クラスインスタンス変数に代入
- 定義されている場合は、Proc#source_location ****メソッドで @_included_block の Proc オブジェクトと引数の Proc オブジェクトを比較し、同一の定義ではない場合は included ブロックが複数設定されていると判断し例外を発生させる
append_features(base)
def append_features(base) # :nodoc: 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 メソッドは Module クラスのインスタンスメソッドで、リファレンスでは「Module#include メソッドの実態」と表現されており、ベースクラスの継承チェーンにモジュールを追加する役割を担っている
- 本メソッドは Module#append_features メソッドをオーバーライドしているため、concern を include したときに呼ばれる
- まずは Object#instance_variable_defined? メソッドで ベースクラスに @_dependencies クラスインスタンス変数があるかどうかで条件分岐(= ベースクラスが concern かどうかを判定している)
- ベースクラスに @dependencies が定義されている場合(= ベースクラスが concern の場合)、Object#instance_variable_get ****メソッドで ベースクラスの @dependencies (Array) を取得し、self を追加する
- concern に concern を include すると、クラスメソッドを読み込むべきクラスがずれてしまうため、この場合はベースクラスの継承チェーンに自身を追加する代わりに、@_dependencies に自身を追加することで依存管理を実現している
- ベースクラスに @_dependencies が定義されていない場合(= ベースクラスが concern ではない場合)、以下の処理を行なっている
- ベースクラスの継承チェーンに self が追加されている場合は二重のinclude を防ぐために false を返し、include を中止する(Module#append_features が include の実態であるため super を呼び出さないと include は行われない)
- 継承チェーンに追加されていない場合は処理を続行し、@_dependencies の配列に含まれるモジュールをベースクラスに再帰的に include していく
- その後、super で Module#append_features メソッドを呼び出し、self をベースクラスの継承チェーンに追加する
- さらに、Module#const_defined? メソッドで ClassMethods という定数が存在するかを確認し、定数があれば Module#const_get メソッドで定数を取得し、ベースクラスに extend する
- さらに Object#instance_variable_defined? メソッドで @included_block クラスインスタンス変数が定義されているかを確認し、定義されている場合は Module#class_eval メソッドを使ってベースクラスのコンテキストで @included_block の Procオブジェクトをブロックに変換して評価する
- 以上のような処理でモジュールを include した時にインスタンスメソッドだけではなく、クラスメソッドも取得できるようにしている
prepended(base = nil, &block)
def prepended(base = nil, &block) if base.nil? if instance_variable_defined?(:@_prepended_block) if @_prepended_block.source_location != block.source_location raise MultiplePrependBlocks end else @_prepended_block = block end else super end end
- prepended メソッドはベースクラスが concern を prepend したときにフックメソッドとして呼ばれる
- 内容については included と同じなので割愛
prepend_features(base)
def prepend_features(base) # :nodoc: if base.instance_variable_defined?(:@_dependencies) base.instance_variable_get(:@_dependencies).unshift self false else return false if base < self @_dependencies.each { |dep| base.prepend(dep) } super base.singleton_class.prepend const_get(:ClassMethods) if const_defined?(:ClassMethods) base.class_eval(&@_prepended_block) if instance_variable_defined?(:@_prepended_block) end end
- prepend_features メソッドは Module クラスのインスタンスメソッドで、リファレンスでは「Module#prepend メソッドの実態」と表現されており、ベースクラスの継承チェーンの先頭にモジュールを追加する役割を担っている
- 本メソッドは Module#prepend_features メソッドをオーバーライドしているため、concern を prepend したときに呼ばれる
- 内容については append_features とほぼ同じなので割愛