はじめに
◆この記事は何か
extend ActiveSupport ::Concern
◆対象は誰か
◆この記事のねらい
先に結論
ActiveSupport ::Concern とは、クラスメソッドを追加する機能をカプセル化 して、モジュールを include(もしくは prepend) した時にインスタンス メソッドだけではなく、クラスメソッドも取得できるようにするモジュールのこと
モジュールに ActiveSupport ::Concern を extend しておくことによって、依存関係を適切に管理しながらクラスメソッドの追加機能を簡単に使えるようになる
内容
モジュールを include した時に、ベースクラスにインスタンス メソッドと一緒にクラスメソッドも一緒に追加する機能をカプセル化 した Rails のモジュール
モジュールに ActiveSupport ::Concern を extend しておくことによって、クラスメソッドの追加機能を簡単に使えるようになる
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
c = C .new
c.instance_method
c.name = " C#name "
c.name
C .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
上で ActiveSupport ::Concern の使い方を確認したが、Rails のソースコード では実際どのように使われているのか?典型的な User モデルを例に見ていく
以下の User クラスは username のバリデーションが定義されており、ApplicationRecord クラスを継承している
class User < ApplicationRecord
validates :username , presence : true
end
class ApplicationRecord < ActiveRecord ::Base
end
module ActiveRecord
class Base
include ActiveModel ::API
...
end
end
ActiveModel::API モジュールは ActiveModel::Validations モジュールを include している
また ActiveSupport ::Concern モジュールを extend している(ここで登場!)
(つまり ActiveModel::API は concern ということ)
module ActiveModel
module API
extend ActiveSupport ::Concern
include ActiveModel ::Validations
end
end
ActiveModel::Validations モジュール内には ClassMethods モジュールが定義されており、そこで validates メソッドが定義されている
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 モジュールの全体像
module ActiveSupport
module Concern
class MultipleIncludedBlocks < StandardError
def initialize
super " Cannot define multiple 'included' blocks for a Concern "
end
end
class MultiplePrependBlocks < StandardError
def initialize
super " Cannot define multiple 'prepended' blocks for a Concern "
end
end
def self .extended (base)
base.instance_variable_set(:@_dependencies , [])
end
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
def prepend_features (base)
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)
base.instance_variable_set(:@_dependencies , [])
end
💡 ポイント
📝 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#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 **
💡 ポイント
📝 concern に included ブロックが定義されている場合は @_included_block クラスインスタンス 変数に Proc オブジェクトを代入した
append_features(base)
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 メソッドは 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 ではない場合)、以下の処理を行なっている
💡 ポイント
📝 ベースクラスが 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)
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_feature s メソッドをオーバーライドしているため、concern を prepend したときに呼ばれる
内容については append_features とほぼ同じなので割愛
おわりに