Active Support Concernモジュール
概要
この記事は、「メタプログラミングRuby 第2版」第10章「Active Supportのconcernモジュール」を読み学習した内容を個人学習用にまとめ直したものです。
RailsのActive Supportが提供するActiveSupport::Concernは、モジュールのミックスインにまつわる煩雑さを解消するための仕組みです。本記事では、メタプログラミングの観点から、Concernモジュールがどのようにクラスメソッドの追加やモジュール依存関係の解決を実現しているのかを読み解きます。
includeとextendのイディオム
Rails 2 の頃は、妥当性確認メソッドは以下のように全てがActiveRecord::Validationsで定義されていた(Active Modelライブラリは当時存在しなかった)。
module ActiveRecord
module Validations
# ...
# モジュールがインクルードされた時に発火するフックメソッド
# 内部モジュールのClassMethodsを同時にエクステンドする
def self.included(base)
base.extend ClassMethods # baseはインクルード先のクラス
# ...
end
module ClassMethods
def validates_length_of(*attrs) # ...
# ...
end
def valid?
# ...
end
# ...
end
end
end
ActiveRecord::BaseがValidationsをインクルードすると、以下のような流れでインスタンスメソッドとクラスメソッドの両方を手にいれる。
-
Validationsのインスタンスメソッド(valid?など)が、Baseクラスのインスタンスメソッドになる -
Rubyが
ActiveRecord::Baseを引数にして、Validationsのincludedというフックメソッドを呼び出す -
フックメソッドの中で、
BaseクラスをActiveRecord::Validations::ClassMethodsでエクステンド(クラス拡張)し、ClassMethodsのメソッドはBaseのクラスメソッドとなる
includeの連鎖の問題
上記のイディオムはインスタンスメソッドとクラスメソッドを同時に挿入できるという意味では便利ではあるものの、以下のような連鎖したインクルード時に問題が発生する。
module SecondLevelModule
def self.included(base)
base.extend ClassMethods
end
def second_level_instance_method
"ok"
end
module ClassMethods
def second_level_class_method
"ok"
end
end
end
module FirstLevelModule
def self.included(base)
base.extend ClassMethods
end
def first_level_instance_method
"ok"
end
module ClassMethods
def first_level_class_method
"ok"
end
end
include SecondLevelModule
end
class BaseClass
# 内部でSecondLevelModuleをインクルードしたモジュールをさらにインクルード
include FirstLevelModule
end
この形では以下のように、インスタンスメソッドに関しては期待通り全てがBaseへ挿入される。
BaseClass.new.first_level_instance_method # => "ok"
BaseClass.new.second_level_instance_method # => "ok"
しかし、クラスメソッドはFirstLevelModule::ClassMethodsに関しては期待通りにBaseのクラスメソッドとなっているが、SecondLevelModule::ClassMethodsはNoMethodErrorとなってしまう。
BaseClass.first_level_class_method # => "ok"
BaseClass.second_level_class_method # => "NoMethodError"
これは、RubyがSecondLevelModuleを呼び出すタイミングにおいては、インクルード先を示す変数baseはFirstLevelModuleなためである。
つまり、second_level_class_methodはFirstLevelModuleオブジェクト自身の特異メソッドとして定義される。includeはインスタンスメソッドを継承チェーンに載せるが、特異メソッドは継承チェーンに載らないため、BaseClassへは伝搬されない。
ActiveSupport::Concernによる解決
ActiveSupport::Concernを使うと、上記の連鎖の問題が解消される。各モジュールでextend ActiveSupport::Concernし、ClassMethods内部モジュールを定義するだけでよい。includedフックを自分で書く必要はなくなる。
require "active_support"
module SecondLevelModule
extend ActiveSupport::Concern
def second_level_instance_method
"ok"
end
module ClassMethods
def second_level_class_method
"ok"
end
end
end
module FirstLevelModule
extend ActiveSupport::Concern
include SecondLevelModule
def first_level_instance_method
"ok"
end
module ClassMethods
def first_level_class_method
"ok"
end
end
end
class BaseClass
include FirstLevelModule
end
今度はインスタンスメソッドもクラスメソッドも全てBaseClassに挿入される。
BaseClass.new.first_level_instance_method # => "ok"
BaseClass.new.second_level_instance_method # => "ok"
BaseClass.first_level_class_method # => "ok"
BaseClass.second_level_class_method # => "ok"
ActiveSupport::Concernは、モジュールがインクルードされた際にClassMethodsのextendを最終的なインクルード先(BaseClass)に対して実行する。これにより、連鎖の途中で特異メソッドとして行き止まりになる問題が解消される。
Concernの仕組み
Concernの仕組みはextendedとappend_featuresの二つの重要なメソッドによって成り立つ。
extended
あるモジュールがextend ActiveSupport::Concernを実行すると、RubyがConcern.extendedフックを呼び出し、そのモジュールに@_dependenciesという空の配列がセットされる。
この配列が、後述するappend_featuresで依存モジュールを管理するために使われる。
module ActiveSupport
module Concern
class MultipleIncludedBlocks < StandardError
def initialize
super "Cannot define multiple 'included' blocks for a Concern"
end
end
# モジュールがエクステンドされた時に空の配列をセットする
def self.extended(base)
base.instance_variable_set(:@_dependencies, [])
end
# ...
end
end
Module#append_features
Module#append_featuresはincludedと同様にinclude時に内部的に呼ばれるが、実行順序と役割が異なる。include時の処理順序は以下の通り。
-
append_features(base)— モジュールをbaseの継承チェーンに追加する(インクルード処理本体) -
included(base)— インクルード完了後に呼ばれるフックメソッド(デフォルトは空なのでオーバーライドして挙動をカスタマイズ)
includedはインクルード後の通知用フックに過ぎないため、オーバーライドしてもインクルード自体の挙動は変わらない。
一方、append_featuresをオーバーライドするとインクルード処理そのものを制御できるが、元々の処理内容が存在するので、下記のように何も記述せずにオーバーライドするとモジュールが一切インクルードされなくなる。
module M
# 本来の処理(super)を呼ばずにオーバーライド
def self.append_features(base)
end
end
class C
include M
end
# 継承チェーンにモジュールがインクルードされなくなる
C.ancestors # => [C, Object, Kernel, BasicObject]
Concern#append_features
ActiveSupport::Concernはappend_featuresを以下のようにオーバーライドしている。
module ActiveSupport
module Concern
def append_features(base)
if base.instance_variable_defined?(:@_dependencies)
base.instance_variable_get(:@_dependencies) << self
false
else
# baseがモジュール自身でなく、@_dependenciesが定義されていない場合
# つまり「最終的なインクルード先のクラス」である場合
return false if base < self
@_dependencies.each { |dep| base.include(dep) }
super
# ここでClassMethodsのエクステンドをまとめて行う
base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
# ...
end
end
end
end
base(インクルード先)が@_dependenciesを持っているかどうかで処理が分岐する。
-
baseがConcernモジュールの場合 —@_dependenciesを持つので、superを呼ばずに自身を依存として蓄積するだけでインクルードを保留する -
baseがクラスなど最終的なインクルード先の場合 —@_dependenciesを持たないので、蓄積した依存モジュールを順にインクルードし、superで自身もインクルード、ClassMethodsのextendをまとめて行う
この仕組みにより、連鎖の途中ではインクルードを保留し、最終的なクラスに到達した時点で全てが正しいbaseに対して適用される。
まとめ
- Rails 2時代の
includedフック +extend ClassMethodsイディオムでは、モジュールが連鎖してインクルードされた場合にクラスメソッドが最終クラスへ伝搬しない問題がある -
ActiveSupport::Concernをextendすることで、ClassMethods内部モジュールのextendが最終的なインクルード先に対して自動的に行われる - Concernは
extendedフックで@_dependencies配列をセットし、append_featuresをオーバーライドすることで実現されている - Concernモジュール間のインクルードでは実際のインクルード処理を保留し、最終クラスに到達した時点で蓄積した依存を順にインクルードする
補足: 現代のRailsにおけるConcernの拡張
本記事は書籍の内容に基づき、Concernの核心である依存解決とClassMethodsの自動extendの仕組みを解説した。現代のRails(8.x時点)では、以下の機能がさらに追加されている。
-
includedブロック —included do ... endの形でブロックを渡すと、最終的なインクルード先クラスのコンテキストでclass_evalされる。append_features内でClassMethodsのextendと合わせて実行される -
class_methodsDSL —module ClassMethodsを手動定義する代わりに、class_methods do ... endブロックで簡潔に定義できる(Rails 4.2以降) -
prependサポート —prepend_featuresのオーバーライドにより、includeと同様の依存解決がprependでも可能になった(Rails 6.1以降)。本記事の説明はincludeベースの動作を対象としている
参考文献
この記事は以下の情報を参考にして執筆しました。