Railsで、included付のConcernをincludeしたり、prepended付のConcernをprependしてみた

Railsには Concern と呼ばれる関心事を分離する機能があります。
我々はConcernsとどう向き合うか - おもしろwebサービス開発日記

そのConcernをモデルに組み込もうとした時、

などを考えることがありそうでした。

 
そこで、

  • モデルやConcernに定義してあるメソッドを使う
  • Concernでモデルのコールバックを使う
  • モデルとConcernで名前が重複したメソッドがあるときの挙動を確認する

を試しながら、Concernをモデルに組み込んでみたときのメモを残します。

 
目次

 

環境

 
なお、今回Concernを追加するモデルは Robot とします。また、マイグレーションは以下のような感じで、 namenote の2つの属性を持つとします。

class CreateRobots < ActiveRecord::Migration[7.0]
  def change
    create_table :robots do |t|
      t.string :name
      t.string :note

      t.timestamps
    end
  end
end

 
また、挙動確認はRSpecによるテストコードで行います。

 

includedやprependedがないConcernをincludeする

まずは、includedやprependedが無いConcernを用意します。

module Robot::Speakable
  extend ActiveSupport::Concern

  # モデルのコールバックを使う
  after_initialize :add_name_by_speakable

  # Concernだけに存在するメソッド
  def speak
    'Hello, world!'
  end

  # モデルとConcernで名前が重複しているメソッド
  def same_method_as_speakable
    '話します'
  end

  # モデルのコールバックで呼ばれる関数
  private def add_name_by_speakable
    self.name = self.name.present? ? self.name + 'add_speakable' : 'speakable'
  end
end

 
次に、モデルにConcernを include します。

class Robot < ApplicationRecord
  include Robot::Speakable

  # モデルだけにあるメソッド
  def robot_method
    'called robot method'
  end

  # Concernと名前が重複するメソッド
  def same_method_as_speakable
    'speak'
  end
end

 
準備ができたのでRSpecによるテストコードを書きます。

RSpec.describe Robot, type: :model do
  describe 'Robot::Speakable' do
    let(:actual) { Robot.new }

    it 'モデルのメソッドを呼ぶことができる' do
      expect(actual.robot_method).to eq('called robot method')
    end

    it 'speakableのメソッドを呼ぶことができる' do
      expect(actual.speak).to eq('Hello, world!')
    end

    it 'モデルとConcernに同名のメソッドがある場合は、モデルのメソッドが使われる' do
      expect(actual.same_method_as_speakable).to eq('speak')
    end
  end
end

 
テストコードを実行するとエラーになりました。

An error occurred while loading ./spec/models/robot_spec.rb.
Failure/Error: after_initialize :add_name_by_speakable

NoMethodError:
  undefined method `after_initialize' for Robot::Speakable:Module

    after_initialize :add_name_by_speakable
    ^^^^^^^^^^^^^^^^

 
そこで、Concernの after_initialize :add_name_by_speakableコメントアウトしたところ、テストがパスしました。

これより、Concernの中でモデルのコールバックを定義する場合は、メソッドと同じように定義してもダメそうと分かりました。

 

includedのあるConcernをincludeする

続いて、 included のあるConcernを、モデルで include してみます。

Concernの included について、RailsAPIドキュメントを見ると

Evaluate given block in context of base class, so that you can write class macros here. When you define more than one included block, it raises an exception.

https://api.rubyonrails.org/classes/ActiveSupport/Concern.html#method-i-included

とありました。

included の中で書いたものはモデルのコンテキストで実行されることから、 included の中にモデルのコールバックを定義するのが良さそうですので、実際に試してみます。

 
まず、先ほどと同じく、Concernを定義します。

includedの中には

  • モデルのコールバック
  • モデルのメソッドと重複する名前のメソッド

を実装します。

また、includeの外にも同様のメソッドを実装しておき、includeの外と中で挙動が異なるか確認します。

module Robot::WalkableWithIncluded
  extend ActiveSupport::Concern

  included do
    after_initialize :add_name_by_walkable

    # モデルとConcernで名前が重複しているメソッド
    def same_method_as_walkable_included
      'includeの中で歩きます'
    end
  end

  def walk
    'step forward'
  end

  # こちらも、モデルとConcernで名前が重複しているメソッド
  def same_method_as_walkable
    '歩きます'
  end

  private def add_name_by_walkable
    self.name = 'walkable'
  end
end

 
続いて、モデルに include します。

class Robot < ApplicationRecord
  include Robot::WalkableWithIncluded

  # モデルだけにあるメソッド
  def robot_method
    'called robot method'
  end

  # Concernと名前が重複するメソッド(includedの外で定義)
  def same_method_as_walkable
    'walk'
  end

  # Concernと名前が重複するメソッド(includedの中で定義)
  def same_method_as_walkable_included
    'walk included'
  end
end

 
あとはテストコードを書きます。今回は以下のテストコードがパスしました。

RSpec.describe Robot, type: :model do
  describe 'Robot::WalkableWithIncluded' do
    let(:actual) { Robot.new }

    it 'モデルのメソッドを呼ぶことができる' do
      expect(actual.robot_method).to eq('called robot method')
    end

    it 'WalkableWithIncludedのメソッドを呼ぶことができる' do
      expect(actual.walk).to eq('step forward')
    end

    it 'モデルとincludedの外で同名のメソッドが定義されている場合は、モデルのメソッドが使われる' do
      expect(actual.same_method_as_walkable).to eq('walk')
    end

    it 'モデルとincludedの中で同名のメソッドが定義されている場合も、モデルのメソッドが使われる' do
      expect(actual.same_method_as_walkable_included).to eq('walk included')
    end

    it 'includedにて定義されたコールバックが使える' do
      expect(actual.name).to eq('walkable')
    end
  end
end

 
テストコードの結果より、

  • Concernの included に書いたコールバックは、モデルのコールバックとして動作する
  • included の内外に関わらず、Concernとモデルで名前が重複したメソッドがある場合、 モデルのメソッドの方が使われる

ということが分かりました。

 

prependedのあるConcernをprependする

ここまではモデルにConcernを include してきました。

ただ、Rails 6.1からはConcernを prepend できるようになりました。これにより、 include とは異なる探索チェインにできそうです。
Rails: ActiveSupport::Concernをextendしたモジュールをprependする機能(翻訳)|TechRacho by BPS株式会社

 
そこで、Concernを prepend したときの挙動を見ていこうと思います。

また、Concernには prepend されたときに実行される prepended が用意されているので、合わせて挙動を確認していきます。
https://api.rubyonrails.org/classes/ActiveSupport/Concern.html#method-i-prepended

 
まずはConcernを作成します。用意するConcernは

  • prepended の中で、コールバックやモデルと同名のメソッドを定義
  • prepended の外で、モデルと同名のメソッドを定義

と、先ほどの included の部分を prepended へ差し替えたような実装とします。

module Robot::JumpableWithPrepended
  extend ActiveSupport::Concern

  prepended do
    after_initialize :add_note_by_jumpable

    # モデルと同名のメソッド
    def same_method_as_jumpable_prepended
      'prependedの中でジャンプします'
    end
  end

  def jump
    'jump!'
  end

  # こちらもモデルと同名のメソッド
  def same_method_as_jumpable
    'ジャンプします'
  end

  private def add_note_by_jumpable
    self.note = 'jumpable'
  end
end

 
続いて、モデルでConcernを prepend します。

class Robot < ApplicationRecord
  # Concernをprependするところ
  prepend Robot::JumpableWithPrepended

  # モデルだけにあるメソッド
  def robot_method
    'called robot method'
  end

  # Concernと名前が重複するメソッド(prependedの外で定義)
  def same_method_as_jumpable
    'jump'
  end

  # Concernと名前が重複するメソッド(prependedの中で定義)
  def same_method_as_jumpable_prepended
    'jump prepended'
  end
end

 
あとはテストコードを書いて挙動を確認します。

RSpec.describe Robot, type: :model do
  describe 'Robot::JumpableWithPrepended' do
    let(:actual) { Robot.new }

    it 'モデルのメソッドを呼ぶことができる' do
      expect(actual.robot_method).to eq('called robot method')
    end

    it 'JumpableWithIncludedのメソッドを呼ぶことができる' do
      expect(actual.jump).to eq('jump!')
    end

    it 'モデルとprependedの外で同名のメソッドが定義されている場合は、Concernのメソッドが使われる' do
      expect(actual.same_method_as_jumpable).to eq('ジャンプします')
    end

    it 'モデルとprependedの中で同名のメソッドが定義されている場合も、モデルのメソッドが使われる' do
      expect(actual.same_method_as_jumpable_prepended).to eq('jump prepended')
    end

    it 'prependedにて定義されたコールバックが使える' do
      expect(actual.note).to eq('jumpable')
    end
  end
end

 
その結果、

  • prependedの外でモデルと同名のメソッドが定義されている場合、 Concern のメソッドが使われる
  • prependedの中でモデルと同名のメソッドが定義されている場合、 モデル のメソッドが使われる

という違いを確認できました。

 

まとめ

  • モデルのコールバックなど、 include する側のコンテキストで動作させたい場合は、 includedprepended を使う
  • モデルとConcernで同名のメソッドがあり、かつ、Concernのメソッドを優先させたい場合は、 prepended の で定義する

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/13