🎨

素敵に調和するActive Record(翻訳)

2023/02/01に公開

英語記事: Active Record, nice and blended
原文公開日: 2022/12/16
原著者: Jorge Manrubia


Active Recordは、「永続化とドメインロジックをどのように分離するか?」 という従来の問題に対し次のような再提案をしています。「もし分離する必要がないとしたら?」

リレーショナルデータベースへのオブジェクトの永続化は複雑な問題です。20年前は、この問題は「プログラマーが気にしなくてもいいように永続化を抽象化する」という、究極の直交性[2]の実現という問題のように思われました。しかしそれから何年も経ち、私たちはそう単純な話ではないと確信しました。永続化は、確かに横断的関心事[3]ですが、多くの触手を持つ問題なのです。

永続化を完全に抽象化することはできないため、多くのパターンは、データベースへのアクセスを独自のレイヤーに分離し、ドメインモデルを永続性から解放しようとしています。例えば、repositoriesData MappersDAOs(Data Access Objects)などがそうです。しかしRailsは、Martin FowlerがPatterns of EAAで紹介したActive Recordという、異なるアプローチを採用しました。

(Active Recordとは)データベースのテーブルやビューの行をラップし、データベースへのアクセスをカプセル化し、そのデータにドメインロジックを追加するオブジェクトである。

Active Recordパターンの特徴は、ドメインロジックと永続化を同じクラスに統合していることであり、37signalsでもそのように用いています。一見すると、これは良いアイデアとは思えないかもしれません。分離しているものは、当然、分離したままにしておくべきではないのか?と。Fowlerでさえ、このパターンは「あまり複雑でないドメインロジック」に対しては良い選択であると述べています。しかし、私たちの経験では、RailsのActive Recordは大規模で複雑なコードベースにおいても、コードをエレガントかつ保守性の高い状態に保ちます。本記事では、その理由を明確に説明したいと思います。

インピーダンスレスマッチ

オブジェクト-リレーショナルインピーダンスミスマッチとは、オブジェクト指向言語とリレーショナルデータベースは異なる世界であり、その結果、両者の間で概念を翻訳するときに摩擦が生じるということを洒落た言い回しで表現したものです。

Active Record(パターンではなくRailsのフレームワーク)が実際にうまく機能しているのは、このインピーダンスミスマッチを最小限に抑えているからだと私は考えています。その理由は主に2つあります。

  • 細かい調整のために下位レベルに移動する必要がある場合でも、依然としてRubyのように見え、感じることができる。
  • オブジェクトやリレーショナルな永続性を扱う際に繰り返し求められるニーズに対して、素晴らしく革新的な答えが用意されている。

Rubyとの理想的な類似

HEYにおける例をお見せしましょう。これはVanilla Railsの記事で紹介したContact#designate_to(box)メソッドの内部を表したものです。このメソッドは、指定した連絡先から送られてきたメールの行き先として、ボックスを選択したときのロジックを扱っています。Active Recordが関連する行をハイライトしています[4]

module Contact::Designatable
  extend ActiveSupport::Concern

  included do
>   has_many :designations, class_name: "Box::Designation", dependent: :destroy
  end

  def designate_to(box)
    if box.imbox?
      # Skip designating to Imbox since it’s the default.
      undesignate_from(box.identity.boxes)
    else
      update_or_create_designation_to(box)
    end
  end

  def undesignate_from(box)
>   designations.destroy_by box: box
  end

  def designation_within(boxes)
>   designations.find_by box: boxes
  end

  def designated?(by:)
    designation_within(by.boxes).present?
  end

  private
    def update_or_create_designation_to(box)
      if designation = designation_within(box.identity.boxes)
>       designation.update!(box: box)
      else
>       designations.create!(box: box) 
      end
    end
end

永続化の部分は自然で追いやすくなっています。コードは雄弁かつ簡潔で、Rubyのように読めます。「ビジネスロジック」と「永続化の責務」の間に認知的な隔たりがありません。私にとって、この特徴は画期的なものです。

永続化の要求に対する答え

Active Recordは、オブジェクト指向のモデルをテーブルに永続化するための多くのオプションを提供しています。オリジナルのActive Recordパターンを提示する際、Fowlerは次のように主張しています。

ビジネスロジックが複雑な場合、オブジェクトの直接的な関係やコレクション、継承などをすぐに使いたくなるでしょう。これらはActive Recordに簡単にマッピングされるわけではありませんし、バラバラに追加すると非常に乱雑になります。

RailsのActive Recordは、これらと、さらに多くのことに対する答えを提供しています。いくつか例に挙げると、関連付け(associations)単一テーブル継承(single table inheritance)serialized attributesdelegated typesなどがあります。

Railsの関連付けは何が何でも避けるべきだと主張する人がいますが、これは理解に苦しみます。私は、関連付けはActive Recordの最高の機能の一つだと思いますし、私たちのアプリではあらゆる所で用いています。オブジェクト指向プログラミングを勉強していると、オブジェクト間の「関連付け」は、継承と同様に基本的な構成要素だと分かります。リレーショナルな世界におけるテーブル間のリレーションシップと同じです。Railsフレームワークが、これらをコードに変換するための直接的なサポートなど、大変な仕事をすべてやってくれるのですから、何を避ける必要があるのでしょう?

関連付けの例をお見せしましょう。HEYにおけるメールスレッドは、内部的には多くのエントリー(Entry)を持つTopicモデルのように見えます。あるシナリオでは、システムは、スレッド内のアドレス指定された連絡先やブロックされたトラッカーなどが含まれるエントリーに基づいて、トピックレベルで集約されたデータにアクセスする必要があります。私たちは、これらの大部分を関連付けで実装しています。

class Topic
  include Entries

  #...
end

module Topic::Entries
  extend ActiveSupport::Concern

  included do
    has_many :entries, dependent: :destroy
    has_many :entry_attachments, through: :entries, source: :attachments
    has_many :receipts, through: :entries
    has_many :addressed_contacts, -> { distinct }, through: :entries
    has_many :entry_creators, -> { distinct }, through: :entries, source: :creator
    has_many :blocked_trackers, through: :entries, class_name: "Entry::BlockedTracker"
    has_many :clips, through: :entries
  end

  #...
end

他にも、リッチなRubyのオブジェクトモデルを永続化するために、Active Recordが直接提供する機能をふんだんに用いています。例えば、HEYにおける様々な種類のボックスをモデル化するために単一テーブル継承を用いています。

class Box < ApplicationRecord
end

class Box::Imbox < Box
end

class Box::Trailbox < Box
end

class Box::Feedbox < Box
end

また、Basecampにおけるチェックインの再帰的なスケジュールの詳細を保存するためにserialized attributesを用いています。

class Question < ApplicationRecord
  serialize :schedule, RecurrenceSchedule
end

さらに、HEYの様々な種類の連絡先をモデル化するために、delegated typesを用いています。

class Contact < ApplicationRecord
  include Contactables
end

module Contact::Contactables
  extend ActiveSupport::Concern

  included do
    delegated_type :contactable, types: Contactable::TYPES, inverse_of: :contact, dependent: :destroy
  end
end

module Contactable
  extend ActiveSupport::Concern

  TYPES = %w[ User Extenzion Alias Person Service Tombstone ]

  included do
    has_one :contact, as: :contactable, inverse_of: :contactable, touch: true
    belongs_to :account, default: -> { contact&.account }
  end
end

class User < ApplicationRecord
  include Contactable
end

class Person < ApplicationRecord
  include Contactable
end

class Service < ApplicationRecord
  include Contactable
end

ここで注意すべきことは、Active Recordが提供するものを完全に活用するためには、データベーススキーマをきちんと管理する必要があるということです。この場合、リッチで複雑なオブジェクトモデルを円滑に永続化することが、大規模で複雑なコードベースにおいてActive Recordパターンを機能させる鍵となります。

カプセル化への配慮

Active RecordはRubyと非常によく調和するため、Rubyに標準で備わっている素敵なものを活用して詳細を隠蔽することができます。このため、データアクセスレイヤーをわざわざ別で用意しなくとも、自然に見えるコードで永続化をカプセル化することができます。

例えば、先ほどのContact::Designatableのコードを見てください。Active Recordのコードは、素のprivateメソッドでラップされています。また、全て(ドメインロジックと永続化)が#designate_toメソッドに隠されています。これは、こちらの記事で説明したように、システム境界の視点から見て自然なインターフェースの一部となっています。つまり、永続化は混ぜ合わされていますが、うまく整理され、カプセル化されています。

より複雑なシナリオにおいては、その複雑さを隠すためにオブジェクトを作成することも容易にできます。例えば、Basecampでは、あるユーザのアクティビティタイムラインを表示するために、Timeline::Aggregatorというクラスが使われており、これは関連するイベントを提供するPOROです。このクラスはクエリのロジックをカプセル化します。

class Reports::Users::ProgressController < ApplicationController
  def show
    @events = Timeline::Aggregator.new(Current.person, filter: current_page_by_creator_filter).events
  end
end

class Timeline::Aggregator
  def initialize(person, filter: nil)
    @person = person
    @filter = filter
  end

  def events
    Event.where(id: event_ids).preload(:recording).reverse_chronologically
  end

  private
    def event_ids
      event_ids_via_optimized_query(1.week.ago) || event_ids_via_optimized_query(3.months.ago) || event_ids_via_regular_query
    end

    # Fetching the most recent recordings optimizes the query enormously for large sets of recordings
    def event_ids_via_optimized_query(created_since)
      limit = extract_limit
      event_ids = filtered_ordered_recordings.where("recordings.created_at >= ?", created_since).pluck("relays.event_id")
      event_ids if event_ids.length >= limit
    end

    def event_ids_via_regular_query
      filtered_ordered_recordings.pluck("relays.event_id")
    end

    # ...
end

私たちは、クエリにはスコープをよく用います。これらを関連付けや他のスコープと組み合わせることで、複雑なクエリを自然に見えるコードで表現することができます。例えば、HEYのコレクションを表示する場合、代理の連絡先がアクセスできるコレクション内のすべてのアクティブなトピックを取得する必要があります。HEYでは、選択したフィルタに応じて異なる "代理" ユーザを持つことができます。関連するコードは以下のようなものです。

class Topic < ApplicationController
  include Accessible
end

module Topic::Accessible
  extend ActiveSupport::Concern

  included do
    has_many :accesses, dependent: :destroy
    scope :accessible_to, ->(contact) { not_deleted.joins(:accesses).where accesses: { contact: contact } }
  end

  # ...
end

class CollectionsController < ApplicationController
  def show
    @topics = @collection.topics.active.accessible_to(Acting.contact)
    # ...
  end
end

これは少しエッジケースかもしれませんが、Donalがこちらの記事で説明しているように、HEYにおけるパフォーマンス最適化の一つとしてもスコープを使用していることがお分かりいただけると思います。

module Posting::Involving
  extend ActiveSupport::Concern

  DEFAULT_INVOLVEMENTS_JOIN = "INNER JOIN `involvements` USE INDEX(index_involvements_on_contact_id_and_topic_id) ON `involvements`.`topic_id` = `postings`.`postable_id`"
  OPTIMIZED_FOR_USER_FILTERING_INVOLVEMENTS_JOIN = "STRAIGHT_JOIN `involvements` USE INDEX(index_involvements_on_account_id_and_topic_id_and_contact_id) ON `involvements`.`topic_id` = `postings`.`postable_id`"

  included do
    scope :involving, ->(contacts, involvements_join: DEFAULT_INVOLVEMENTS_JOIN) do
      where(postable_type: "Topic")
        .joins(involvements_join)
        .where(involvements: { contact_id: Array(contacts).map(&:id) })
        .distinct
    end

    scope :involving_optimized_for_user_filtering, ->(contacts) do
      # STRAIGHT_JOIN ensures that MySQL reads topics before involvements
      involving(contacts, involvements_join: OPTIMIZED_FOR_USER_FILTERING_INVOLVEMENTS_JOIN)
        .use_index(:index_postings_on_user_id_and_postable_and_active_at)
        .joins(:user)
        .where("`users`.`account_id` = `involvements`.`account_id`")
        .select(:id, :active_at)
    end
  end
end

永続化-ドメインロジック分離問題の再定義

理論的には、永続化とドメインロジックを厳密に分離することは、良い考えに聞こえます。しかし、実際には二つの大きな問題があります。

第一に、どのようなアプローチをとるにせよ、アプリ内の永続化されるモデルの数だけ、データアクセスの抽象化を追加・管理することが必要になります。これは、儀式と複雑さの増加につながります。

第二に、リッチなドメインモデルを構築することが難しくなります。ドメインモデルが永続化を担わない場合、データベースへアクセスする必要のあるビジネスロジックをどのように実装すればよいのでしょうか? 例えばDDDでは、repositoriesやaggregatesなどのドメインレベルの要素を追加することで解決します。これで、永続性に関して何も知らない素のドメインエンティティを調整するための、三つの要素が揃いました。しかしこれらの要素は、どのようにお互いにやり取りするのでしょうか?どのように協力するのでしょうか?これは、すべてを統括するサービスを考え出したくなるようなシナリオです。皮肉なことに、多くの実装が、最良のデザインプラクティスを取り入れようとして「ほとんどのエンティティが振る舞いを持たない単なるデータホルダーに陥る」というドメインモデル貧血症に苦しむことになりますが、これは驚くことではありません。

ドメインロジックと永続化を分離したくなるのには理由があります。両者を統合すると、同じものに属さないコード、すなわち、保守が困難なコードになりがちです。これは生のSQLを直接使用する場合に顕著です。また多くのORMライブラリも永続化にばかり焦点を当てているため、同様の問題が起こります。しかし、Active Recordは、「永続化とドメインロジックは共にあるべき」という前提で設計されており、この考えに基づいて20年近く繰り返し改良されてきたものなのです。

先日、Kent Beckの記事におけるこの洞察に驚かされました。

ある要素は、その下位要素同士の結合度が高まるほど、凝集度が高まる。完全に凝集した要素が変化する時は、そのすべての下位要素も同時に変化させる必要がある。

データベースを利用したアプリケーションでは、ドメインロジックは永続化と切っても切り離せない関係にあります。永続化を分離しようとすると、正しいORM(Object Relational Mapping)技術が使用できなくなります。私の見解では、ORMが、「もし」ホスト言語と完全に調和し、「もし」オブジェクトモデルを永続化するための良い答えを持ち、「もし」良いカプセル化の仕組みを提供するならば、最初の質問はこう言い換えることができます。「どうすればドメインロジックから永続性を切り離せるか?」ではなく、「どうしてそんなことをするのか?」と。

結論

ここで説明したActive Recordの使い方が実際にうまくいくことは、ずっと前から分かっていました。私が手がけたRailsアプリにおいて、永続化のための孤立したレイヤーが欲しくなったことは一度もありません。

素のRails(vanilla Rails)と同様に、Active Recordは迅速なプロトタイプには有効だが、ある時点で、ドメインロジックと永続化を分離するための代替手段が必要になると主張する人もいます。しかし私たちの経験上そんなことはありません。私たちはBasecampHEYという数百万人が利用する大規模なRailsアプリケーションで、本記事で説明したようにActive Recordを使用しています。これらのアプリケーションは、Active Recordを中心に据えて、進化を続けているのです

この手の記事にありがちな注意事項があります。Active Recordはツールであるため、当然それを用いて乱雑で保守性の低いコードを書くこともできます。特に、concernsの場合と同様に、システムを適切に設計する必要性は依然としてありますし、適切な設計方法を知らなければ、あまり助けにはならないでしょう。Active Recordを使用していてコードのメンテナンスに問題があるとしたら、それはActive Recordのせいではなく、コードをメンテナンスしやすくするための他の幾千の事柄を正しく理解できていないからかもしれません。

私は、Active Recordはとても優れているため、永続化とドメインロジックを厳密に分離すべきだという従来の主張を覆すと考えています。私にとってActive Recordは、弁明の必要などない、堂々と採用すべき機能です。


この記事は、Code I likeというRailsの設計技法に関する連載記事に属しています。

写真:Michael DziedzicUnsplash

脚注
  1. その他翻訳記事:
    第一弾: Domain driven boldness
    第二弾: Fractal journeys
    第三弾: Good concerns
    第四弾(TechRachoさん): Vanilla Rails is plenty ↩︎

  2. (訳注)直交性とは「ある種の独立性、あるいは結合度の低さ」「2つ以上のものごとで、片方を変更しても他方に影響を与えない」ような性質のこと(David Thomas, 達人プログラマー(第2版), 50ページより一部引用) ↩︎

  3. (訳注)横断的関心事とは、単一のモジュール構造にカプセル化できず、複数のモジュールにおいて本質的に同じ実装が繰り返し必要となるような関心事のこと。ロギングやキャッシング、永続化など。(参考:Robert C.Martin, Clean Code, 217ページ、Cross-cutting concern (Wikipedia)↩︎

  4. (訳注)原文ではただのハイライトがされていますが、純粋な行のハイライト機能が無かったためdiffの機能で代用しています。強調以外の意図はありません。ご容赦ください。 ↩︎

Discussion