🫣

Concern/ActiveSupport::Concern の使い方

2023/07/01に公開

Concern とは?

ModelやControllerを構成する一部の概念や機能を実装するためのモジュールのことを指します。特定の概念や機能を有するロジックをModelやControllerとは分離させ実装することができます。また、複数のModelやControllerで再利用することができます。

Concernを使用するには、主にActiveSupport::Concernというライブラリを用いることが通例となっています。

 

ActiveSupport::Concern とは?

Concernの実装を容易にしてくれるライブラリのことです。

ActiveSupport::Concernを extend することで、クラス定義を容易にする class_methods メソッドやブロック渡しができる included メソッドなどを使用できるようになります。

 

Concern の使用タイミング

Concern を利用する前に、特定のロジックを値オブジェクトやサービスオブジェクトとして実装することを検討するべきです。

同様に、コントローラのConcernを利用する前には、そのロジックをモデルやフォームオブジェクトに実装することを考えるべきです。

Concernを利用すべきなのは、これらのオブジェクトに実装することが適さない場合のみです。

次にConcernの使用例について紹介します。

 

使用例① 通常の Concern

Concernを使用するときは、まず Concern (モジュール)を作成し、ActiveSupport::Concern を extend します。

そして、作成した Concern (モジュール)include したいクラスへ include することでそのクラス内で使用することができるようになります。

app/models/concerns/searchable.rb

module Searchable
  extend ActiveSupport::Concern

  def search(query)
    where("name LIKE ?", "%#{query}%")
  end
end

app/controllers/users_controller.rb

class UsersController < ApplicationController
  include Searchable

  def index
    if params[:query].present?
      @users = search(params[:query])
    else
      @users = User.all
    end
  end
end

上記の例では、searchメソッド内でwhereメソッドを使用して、nameカラムが指定されたクエリに一致するデータを検索しています。

まず、UsersControllerindexアクション内でsearchメソッドを呼び出しています。リクエストパラメータqueryが指定されている場合、User.search(params[:query]) によって指定されたクエリに一致するユーザーを検索します。指定されていない場合は、全てのユーザーを取得します。

このようにすることで、UsersController内で検索機能を再利用することができます。例えば、別のコントローラーでも同じSearchableモジュールを include すれば、同様の検索機能を利用することができます。

 

使用例② ブロック渡しする included メソッド

ActiveSupport::Concern をextendすると、included メソッドを使用することができるようになります。

included メソッドは、include した際に呼び出されるコールバックメソッドです。

つまり、クラスがあるモジュールを include すると、そのモジュール内で定義された included メソッドを自動的に呼び出すことができます。

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

また、ActiveSupport::Concern では included メソッドは下記のように定義されています。

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

https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/activesupport/lib/active_support/concern.rb#L156

included メソッドがどのようなことを行なっているのか解説します。

まず、included メソッドは、2つの引数を取ります。

baseはクラスオブジェクトまたはモジュールオブジェクトを表す変数であり、blockはブロックオブジェクトを表す引数です。

このメソッドの主な目的は、basenilかどうかに基づいて処理を分岐することです。

もしbasenilであれば、現在のクラスが include される前に実行されるコードブロックを設定します。

既に@_included_blockというインスタンス変数が定義されている場合は、新しいブロックが前に定義されたブロックと異なる場合は、MultipleIncludedBlocksというエラーを発生させます。

そうでない場合は、@_included_blockに渡されたブロックを代入します。

basenilでない場合は、親クラスのincludedメソッドを呼び出します(superを使用)。

これにより、親クラスのincludedメソッドが実行され、モジュールの include 時の追加の処理が実行されることが期待されます。

つまり、ActiveSupport::Concern により拡張された included メソッドは、引数にブロックを渡すことで呼び出すことができます。

渡されたブロックは、モジュールが include された時に、モジュールを include したクラスやモジュールのコンテキスト(特定のコードやプログラムの実行時の状況や環境)で評価されます。

下記に示したコードが、 included メソッドの使用例です。

module Cancelable
  extend ActiveSupport::Concern

  included do
    has_one :cancellation, class_name:"#{name}Cancellation"
  end
end

例えば、次のようなクラスが Cancelable モジュールを取り込んでいるとします。

class Book
  include Cancelable
end

このようにすることで Book モデルに関連づけられた Cancellation オブジェクトを取得できるようになります。

 

使用例③ クラスメソッドの定義をしやすくするclass_methods

ActiveSupport::Concern では、class_methodsメソッドは 下記のように定義されています。

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

まず、class_methodsメソッドは、ClassMethodsという名前のモジュールがクラス内に既に定義されているかどうかをチェックします。

もし既に定義されていれば、そのモジュールを取得します。もしまだ定義されていなければ、Module.newを使って新しいClassMethodsモジュールを作成します。

ブロック内のコードをClassMethodsモジュールのコンテキスト(特定のコードやプログラムの実行時の状況や環境)で評価します。

これにより、ブロック内で定義されたクラスメソッドがClassMethodsモジュールに追加されます。

つまり、class_methodsメソッドは、ブロックを引数として受け取り、そのブロック内でクラスメソッドを定義するためのモジュールを作成します。

class_methodsメソッドを使用することで、ブロックに定義したメソッドをクラスメソッドとして、include する側のクラスに取り込むことができます。

下記に示したコードが、class_methodsメソッドの使用例です。

module MyConcern
  extend ActiveSupport::Concern

  class_methods do
    def my_class_method
      puts "This is a class method defined in the concern."
    end
  end
end

class MyClass
  include MyConcern
end

MyClass.my_class_method # "This is a class method defined in the concern."

上記の例では、MyConcernモジュール内でclass_methodsブロックを使用し、my_class_methodを定義しています。

その後、MyClassクラスがMyConcernを include することにより、my_class_methodMyClassのクラスメソッドとして利用できるようになります。

 

使用例④ モジュール間の依存関係の解決

あるモジュールが他のモジュールで定義された変数やメソッドを参照している場合、そのモジュールを利用するクラスでは、両方のモジュールを include する必要があります。

例えば、モジュールAとモジュールAに依存するモジュールB、さらにモジュールBを取り込む(Mix-inする)クラスCがあるとします。コードを書くと下記のようになります。

コード1

module A
  def self.included(base)
    base.class_eval do
      def self.method_injected_by_foo
        puts 'モジュールAが実行されました。'
      end
    end
  end
end

module B
  def self.included(base)
    base.method_injected_by_foo
  end
end

class C
  include A
  include B
end

C.method_injected_by_foo

#=> モジュールAが実行されました。
#=> モジュールAが実行されました。

上記のコードで、私は、モジュールBはモジュールAに依存しているので、モジュールBにモジュールAを include すれば良いと思い、下記のように修正してみました。

コード2

module A
  def self.included(base)
    base.class_eval do
      def self.method_injected_by_foo
        puts 'モジュールAが実行されました。'
      end
    end
  end
end

module B
  include A
  def self.included(base)
    base.method_injected_by_foo
  end
end

class C
  include B
end

C.method_injected_by_foo

#=> undefined method `method_injected_by_foo' for C:Class (NoMethodError)

しかし、上記のコードは正常に実行されず、エラーになります。

理由は、mix-inされるクラスCにモジュールBの依存関係にあるモジュールAが include されていないからです。

mix-in するモジュールが他のモジュールに依存する場合、mix-in される側では両方のモジュールをincludeする必要があります。

しかし、クラスCでモジュールBを使用したいのに、モジュールBが依存するモジュールAもクラスC内に取り込むべきでしょうか?

この問題は、ActiveSupport::Concern を使用することで解決することができます。

上記のコード1 をActiveSupport::Concern を用いて修正した内容が下記のコードです。

module A
  extend ActiveSupport::Concern

  class_methods do
    def method_injected_by_foo
      ...
    end
  end
end

module B
  extend ActiveSupport::Concern
  include A

  included do
    method_injected_by_foo
  end
end

class C
  include B
end

上記のようにすることで、モジュール間の依存関係を解決することができます。

 

使用例⑤ concernメソッドを用いたルーティングの共通化

ルーティングにconcernメソッドを用いることで、ルーティングの共有可能な機能をまとめることができます。これにより、同じ機能を複数の場所で繰り返し記述する必要がなくなります。

使用方法を説明します。

まず、下記のようなルーティングを表すコードがあるとします。

# config/routes.rb

resources :posts do
  resources :comments
end

resources :articles do
  resources :comments
end

このコードでは、posts と articles の両方のルーティングに comments が設定されています。

これをconcern メソッドを用いることで共通化することができます。

concernメソッド適用後のコードが下記のようになります。

# config/routes.rb

concern :commentable do
  resources :comments
end

resources :posts, concerns: :commentable
resources :articles, concerns: :commentable

上記のコードでは、:commentableという名前の共通の設定をconcernメソッドを使って定義しています。

その後、resourcesメソッドを使用してpostsarticlesリソースに:commentableを指定しています。これにより、postsおよびarticlesリソース内で共通の comments のルーティングを生成することができます。

以上が concern メソッドを使用したルーティングの共通化です。

 

まとめ

今回は、Concern の使い方について紹介してみました。

調べる前は使い方がわかりませんでしたが、いろんな記事やパーフェクト Ruby on Rails という技術書を読み込んでいくうちにConcernの使い方や使用タイミングを理解することができました。

今後は、ModelやControllerを肥大化させないようにするためにConcernをうまく使用していきたいです。

 

参考

https://www.amazon.co.jp/パーフェクト-Ruby-Rails-【増補改訂版】-Perfect/dp/4297114623/ref=sr_1_1?adgrpid=52707338829&hvadid=658803729810&hvdev=c&hvlocphy=1009293&hvnetw=g&hvqmt=e&hvrand=17263729121701481266&hvtargid=kwd-387895450927&hydadcr=27294_14678600&jp-ad-ap=0&keywords=パーフェクト+ruby+on+rails&qid=1688205341&sr=8-1

https://api.rubyonrails.org/classes/ActiveSupport/Concern.html

https://railsguides.jp/getting_started.html#concernを使う

Discussion