kakudooo docs

RailsにおけるConcernの活用

RailsのConcernについて調べた内容を整理しておく。 また、この記事ではモデルのConcernについてのみ扱うことにする。

Concernについて

RailsにおけるConcernとは、モデルやコントローラの関心事をまとめ、Mixinとして組み込むRubyモジュールである。通常のRubyモジュールとして実装できるが、includedやクラスメソッドの定義、モジュール間の依存関係を扱う場合にはActiveSupport::Concernを利用できる。 app/models/concerns ディレクトリが存在するが、Concernの配置・運用方針についてまとまった公式ガイドはない。 そのため、実装者によってConcernの解釈や使い方が分かれることも多く、統制をとるのが難しい部分でもある。

Concernの全体像としては、以下の記事がよくまとまっているので参考にするとよい。

参考: Concerns in Rails: Everything You Need to Know

前述したように、Concernの配置・運用方針についてまとまった公式ガイドはないが、DHHの発言や37signalsのブログ記事、once-campfireなどから使用方針を読み取ることができる。それらを参考にしながら使用方針や活用する上での考え方について書いていくことにする。

Concernを使う上での考え方

Concernの分類

37signalsのブログ記事once-campfireからConcernは以下2つに分けて運用されていることがわかる。

よくある誤解

モデル共通のConcern

モデルクラス共通の汎用性のある関心事を配置する。 特定モデルのConcernが複数のモデルから参照されるようになったものや、汎用性のある技術的な関心事が配置される。

特定モデルのConcernが複数のモデルから参照されるようになったもの

HEYでの例をDHHが紹介している。

module Eventable
  extend ActiveSupport::Concern

  included do
    has_many :events, as: :eventable, dependent: :destroy
  ...

class Access < ApplicationRecord
  include Eventable

class Clip < ApplicationRecord
  include Eventable

この例では、モデルの関連を切り出し、複数モデルでMixinしている。(技術的な関心事であるとも言えるかもしれない) DHHの説明では、技術的な関心事に加えて

上記のようなユースケースにも、モデル共通のConcernを活用すると言っている。

複数モデルに共通する技術的な関心事

AOPが技術的な関心事の切り出し方の参考になる。例えば、ロギングやパフォーマンス計測、トランザクション処理、例外処理などが挙げられる。

RailsのConcernでは、モデルのコールバックに共通処理を登録する使い方がある。例えば、複数モデルに共通するイベント記録や監査ログをConcernにまとめ、Active Recordのコールバックとして登録できる。

class Todo < ApplicationRecord
  # Other todo implementation
  # ...

  include TodoEventTracking
end
module TodoEventTracking
  extend ActiveSupport::Concern

  included do
    has_many :events
    before_create :track_creation
  end

  private

  def track_creation
    Rails.logger.info("Create todo event. ID: #{id}")
  end
end

参考: Concerning のサンプルコード

特定モデルのConcern

モデルクラスをドメイン上の役割ごとに分ける

once-campfireを参考にする。 以下は、Userモデルが担うアバターに関する役割を、Concern(User::Avatar)として分離した例である。

app/models/user.rb

class User < ApplicationRecord
  include Avatar, Bot, Mentionable, Role, Transferable
  ...
end

app/models/user/avatar.rb

module User::Avatar
  extend ActiveSupport::Concern

  included do
    has_one_attached :avatar
  end

  class_methods do
    def from_avatar_token(sid)
      find_signed!(sid, purpose: :avatar)
    end
  end

  def avatar_token
    signed_id(purpose: :avatar)
  end
end

DDDで扱われる「文脈によってモデルの意味が限定される」という観点から、特定モデルのConcernを理解してみる。

たとえば顧客という用語ひとつとっても、複数の意味があるはずだ。ユーザーがカタログを見ているとき「顧客」とユーザーが注文をするときの「顧客」とは違った意味になる。 なぜか。カタログを見ているときの「顧客」は、まだ商品を購入する前の段階だ。固定客集めや取扱商品数、割引、発送方法などを考えるコンテキストにいる。一方、注文時点での顧客は、その意味が限定される。例えば、名前・発送先・請求先・注文額・支払い方法などが関わってくるだろう。

引用: 実践ドメイン駆動設計

Userモデルは、アバターを持つ主体、Botとしての振る舞い、メンションの対象など、利用される文脈によって異なる役割を担う。User::AvatarUser::BotUser::MentionableなどのConcernは、Userが文脈ごとに担う役割をコード上で明示する単位として解釈できる。

ここでは、各ConcernをDDDにおける境界づけられたコンテキストそのものとして扱うのではなく、単一のモデルが担う文脈依存の役割をまとめる単位として捉えている。役割ごとにインターフェースや処理をまとめることで、モデルが提供するAPIを保ちながら可読性を向上させている。

委譲と組み合わせる

Code I like (III): Good concerns より、37signalsではクラスによる委譲とConcernを対立的なものではなく、うまく組み合わせることで責務は切り分けつつドメイン指向でシンプルなコードを実現しているとのこと。

以下は上記のブログのコードを引用。

class Account < ApplicationRecord
  include Closable
end
module Account::Closable
  def terminate
    purge_or_incinerate if terminable?
  end

  private

  def purge_or_incinerate
    eligible_for_purge? ? purge : incinerate
  end

  def purge
    Account::Closing::Purging.new(self).run
  end

  def incinerate
    Account::Closing::Incineration.new(self).run
  end
end

Account::Closing::Purging, Account::Closing::Incinerationなどの複雑なロジックはクラスを活用してカプセル化や継承による再利用をしつつ、terminateメソッドだけを呼び出し側に提供する。

そうすると、アカウントの停止について、以下のようなインターフェースを実現することができる。

account.terminate # アカウントを停止する意図が分かりやすい

Serviceオブジェクトを作成するよりも、よりドメイン指向なインターフェースとなる。

AccountTerminationService.new(account).run # やや冗長

つまり、委譲による責務の分離によりアカウントの停止についてのロジックを扱うFatなモデルとなることを避けつつ、Concernをglueとして使うことでドメイン指向で簡潔なインターフェースをモデルに実装することができる。

参考