2019-05-06

ActiveSupport::Concernが裏でやっていること

ActiveSupport::Concern を extend したモジュールは以下の機能が使えるようになる。

  • class_methods do .. end ブロックに定義したメソッドをクラスメソッドとして、include する側のクラスに取り込む
  • included do .. end ブロックに定義した処理を include する側のクラスのコンテキストで実行する

これらの機能を実現するために、Rails内部でどのようなことをしているかを調べた。

Railsのバージョン

サンプルコード

まずはサンプルコードでそれぞれの機能を使ったときの動作を確認しよう。

class_methods を使ったサンプルコード

app/models/concerns/sample.rb モジュールを作成し、class_methods ブロックの中にメソッドを1つ定義する。

# app/models/concerns/sample.rb
module Sample
  extend ActiveSupport::Concern

  class_methods do
    def sample_class_method
      'sample_class_method'
    end
  end
end

これをモデルのUserクラスの中で include する。

# app/models/user.rb
class User < ApplicationRecord
  include Sample
end

すると、次のようにクラスメソッドが使えるようになる。

[1] pry(main)> User.sample_class_method
=> "sample_class_method"

included を使ったサンプルコード

先ほど作成した app/models/concerns/sample.rb モジュールに対し、今度は included ブロックに validates の処理を書く。

# app/models/concerns/sample.rb
module Sample
  extend ActiveSupport::Concern

  included do
    validates :name, presence: true
  end
end

同じようにUserクラスの中で include する。

# app/models/user.rb
class User < ApplicationRecord
  include Sample
end

次のように、Userクラスに validates が効いた状態になっている

[1] pry(main)> user = User.new(name: nil)
=> #<User:0x00007fbda44d76a0 id: nil, name: nil, created_at: nil, updated_at: nil>
[2] pry(main)> user.valid?
=> false
[3] pry(main)> user.errors[:name]
=> ["can't be blank"]

class_methods の実装

それでは、#class_methods の実装を見てみよう。

# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L140-L146
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

最初に const_defined?(:ClassMethods, false) で、ClassMethods という定数(モジュール)が存在するかを確認し、あればそれを取得、なければモジュールとして新しく定義している。

その後、#module_eval をもって ClassMethods のコンテキストで引数の &class_methods_module_definition ブロックを実行している。

これが実行されると、先のサンプルコードであれば次の状態になったことと同義となる。

module Sample
  module ClassMethods
    def sample_class_method
      'sample_class_method'
    end
  end
end

included の実装

続いて #included の実装を見てみよう。

# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L126-L138
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 do .. end のようにブロック付きで実行される想定なので、引数 base は nil となり最初の条件分岐は真となる。

続いて if instance_variable_defined?(:@_included_block) の条件分岐は最初の実行の場合は偽となるので @_included_block = block の部分だけが実行されることになる。

#append_features

さて、class_methodsincluded の実装を見たところだが、それぞれ次のことをやっているだけであった

  • class_methods はブロックの中の定義を ClassMethods モジュールに展開する
  • included はブロックの中の定義を @_included_block インスタンス変数に代入する

これだけでは include した側のクラスにクラスメソッドを定義したり、include する側のコンテキストでコードを実行することはできない。

ではどうしているか?この謎の答えは ActiveSupport::Concern に定義された #append_features というメソッドにある。

# https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/concern.rb#L113-L124
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 は Ruby の Moduleクラスにあるメソッドで、include の実体であるとドキュメントに書かれている。

モジュール(あるいはクラス)に self の機能を追加します。 このメソッドは Module#include の実体であり、...(略)

https://docs.ruby-lang.org/ja/latest/method/Module/i/append_features.html

つまり、モジュールを include したとき、内部では #append_features メソッドが実行されるようになっていて、ActiveSupport::Concern はこの #append_features をオーバーライドしているのだ。
このオーバライドした #append_features メソッドの最後の2行に注目してみよう。

base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)

base というのは include した側のクラスなので、サンプルコードで言えば Userクラスにあたる。

  • base.extend const_get(:ClassMethods)
    • Userクラスに対して ClassMethods モジュールを extend する
    • よって、ClassMethods に定義されたメソッドが、Userクラスにクラスメソッドとして定義される
  • base.class_eval(&@_included_block)
    • class_eval により Userクラスのコンテキストで @_included_block を実行する
    • サンプルコードで言えば、validates: :name, presence: true が実行されることになる

このようにして、2つの機能を実現していた。

コードを読むとき、Concernモジュール側の視点とConcernモジュールを include する側の視点とを切り替えなければならず、少し混乱した...。