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

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」を読んで理解したつもりでも、いざ言語化してみると意外と理解できてなかったことに気づいた

アウトプット大事!

Recommend
こんな記事も読まれています!