ActiveSupport::Concernとは 〜ソースコードリーディング〜

はじめに
この記事は何か
Railsのソースコードリーディングをしているとよく見かける以下の1文
extend ActiveSupport::Concern
実際に Rails7.1.3.2 のリポジトリで grep すると235ファイルヒットすることからも ActiveSupport::Concern モジュールは Rails で非常によく使われているモジュールであることがわかる
今回はそんな ActiveSupport::Concern モジュールについて、使い方を確認した上でモジュールの中身を紐解いていく
対象は誰か
Rails やその他 gem のソースコードリーディングに挑戦しようとしてる人
この記事のねらい
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 メソッドで定義することもできる
- ActiveSupport::Concern を extend したモジュール = 「concern」
- concern を include したクラス = 「ベースクラス」
以下を例にとると 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 クラスインタンス変数を空配列で定義している
- concern に @_dependencies = [] を定義した
- 全ての concern は @_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
-
concern に ClassMethods モジュールを定義し、class_methods ブロックを ClassMethods モジュールのコンテキストとして評価した
-
concern 内で直接 ClassMethods モジュールが定義されている場合は、本メソッドは実行されない
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 ブロックが複数設定されていると判断し例外を発生させる
- concern に included ブロックが定義されている場合は @_included_block クラスインスタンス変数に Proc オブジェクトを代入した
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 した時にインスタンスメソッドだけではなく、クラスメソッドも取得できるようにしている
- ベースクラスが concern の場合、ベースクラスの @_dependencies に self を追加する
- ベースクラスが concern では無い場合、self と @_dependencies の配列に含まれるモジュールをすべて include し、さらに ClassMethods モジュールを extend する
- @_included_block が定義されている場合は、その内容もベースクラスのコンテキストで評価する
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 とほぼ同じなので割愛
おわりに
「メタプログラミング Ruby」を読んで理解したつもりでも、いざ言語化してみると意外と理解できてなかったことに気づいた
アウトプット大事!