Railsには Concern
と呼ばれる関心事を分離する機能があります。
我々はConcernsとどう向き合うか - おもしろwebサービス開発日記
そのConcernをモデルに組み込もうとした時、
- モデルで
include
するかprepend
するか - Concernで
included
を定義するかprepended
を定義するか、それとも何も定義しないか
などを考えることがありそうでした。
そこで、
- モデルやConcernに定義してあるメソッドを使う
- Concernでモデルのコールバックを使う
- モデルとConcernで名前が重複したメソッドがあるときの挙動を確認する
を試しながら、Concernをモデルに組み込んでみたときのメモを残します。
目次
- 環境
- includedやprependedがないConcernをincludeする
- includedのあるConcernをincludeする
- prependedのあるConcernをprependする
- まとめ
- ソースコード
環境
- Rails 7.0.4.2
なお、今回Concernを追加するモデルは Robot
とします。また、マイグレーションは以下のような感じで、 name
と note
の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
について、RailsのAPIドキュメントを見ると
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
する側のコンテキストで動作させたい場合は、included
やprepended
を使う - モデルと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