Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: 私の好きなコード(5)永続化とロジックを絶妙にブレンドするActive Record(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。なお、以下の『素のRailsは十分に豊かである(翻訳)』記事は『私の好きなコード』シリーズの(4)に含まれました。

素のRailsは十分に豊かである(翻訳)

Rails: 私の好きなコード(5)永続化とロジックを絶妙にブレンドするActive Record(翻訳)

本記事は37signals dev blogで最初に公開されました

リレーショナルデータベース内でのオブジェクトの永続化は、複雑な問題です。かれこれ20年ほど前、この問題は究極の直交性問題のように思われていました。すなわち、永続化を抽象化することで、プログラマーが永続化を気にしなくてよいようにするというものです。それから随分経ちましたが...話はそれほど単純ではありませんでした。永続化は、実際のところ分野横断的な関心事(concern)ですが、多くの触手を生やしています。

永続化は完全な形では抽象化できないので、データベースへのアクセスを独自レイヤに分離して、ドメインモデルを永続化から解放することを目的とする、以下のような多くのパターンがあります。

しかしRailsは別の道を進みました。Martin Fowlerの『Patterns of Enterprise Application Architecture(PofEAA)』で導入されたパターン名でもある、Active Recordです。

データベースのテーブルやビューの行をラップし、データベースアクセスをカプセル化し、そのデータのビジネスロジックを追加するオブジェクトのこと。

パターン名としてのActive Recordパターン独自の特徴は、ドメインロジックと永続化を同じクラス内で組み合わせていることであり、37signalsでも実際にそのように使っています。

このパターンは一見すると筋が悪いように思われるかもしれません。「別々のものなら分けておくべきではないか?」という具合にです。かのMartin Fowlerですら、Active Recordパターンが適しているのは「ドメインロジックが複雑すぎない」場合であると指摘しています。

しかし私たちの経験から申し上げると、RailsのActive Recordは、大規模で複雑なコードベースでもエレガントかつメンテしやすいコードを維持できています。本記事では、その理由を明確に述べてみたいと思います。

🔗 インピーダンスミスマッチが発生しない

オブジェクト--リレーショナルの"インピーダンスミスマッチ"は、「オブジェクト指向言語の世界とリレーショナルデータベースの世界が異なっているので、一方の概念を他方に変換しようとすると軋轢が生じる」ことを気の利いた言い回しで表したものです。

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

  • 見た目も手触りもRubyに似ていて、低レベルで微調整を行う必要がある場合でもRubyらしく対応できる
  • オブジェクトとリレーショナルな永続化を扱うときに何かと必要になる、素晴らしく革新的な回答が用意されている

🔗 Rubyとの完璧な相性

HEYで使われている例をお目にかけましょう。これは、例の素のRails記事で参照されているContact#designate_to(box)メソッドの内部を示したものです。

このメソッドは、指定の連絡先リストからのメール送信先をセレクトボックスで選ぶときのロジックを扱います。Active Recordに関連する行をハイライト表示してあります。

▶上と同じコード(クリックすると展開します)
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らしく読めます。あってはならない"concernsのごった煮"のような感じもありません。「ビジネスロジック」と「永続化作業」を行ったり来たりするような認知上の飛躍も見当たりません。私にとって、この特性はゲームチェンジャーです。

🔗 永続化のニーズに応える

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

ビジネスロジックが複雑になると、やがてオブジェクトの直接的なリレーションシップ、コレクション、継承といったものを使いたくなるだろう。これらをActive Recordパターンと対応付けるのは容易なことではなく、無計画に追加しているとコードがひどく乱雑になってしまう。

RailsのActive Recordは、それらに対する回答以上のものを提供しています。以下はほんの一部です。

Railsの関連付けを何が何でも避けることを勧めている人が一部にいますが、理解に苦しみます。関連付けはActive Recordの優秀な機能のひとつであると私は思っていますし、私たちのアプリでも広く用いられています。オブジェクト指向プログラミングを勉強すると、オブジェクト間の関連付けは継承と同様に基本的な構成要素になっています。

同じことが、リレーショナルデータベースの世界におけるテーブル間のリレーションシップについても言えます。両者の変換をコードでサポートし、重たい処理をフレームワークに肩代わりできれば良いとは思いませんか?

関連付けの例を見てみることにしましょう。
HEYでは、メールのスレッドが1個のTopicモデルに多数のエントリ(Entry)があるような形になっています。
一部のシナリオでは、スレッド内で参照されている連絡先の個数やブロックされたトラッカーの個数を得るために、トピックに含まれるエントリに基づいてシステムがトピックレベルで集約データにアクセスする必要が生じることがあります。

私たちは以下のように、大半を関連付けで実装しています。

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ではさまざまな種類のボックスをモデリングするのにSTIを使っています。

class Box < ApplicationRecord
end

class Box::Imbox < Box
end

class Box::Trailbox < Box
end

class Box::Feedbox < Box
end

Basecampのチェックイン機能では、繰り返されるスケジュールの保存にシリアライズ属性を使っています。

class Question < ApplicationRecord
  serialize :schedule, RecurrenceSchedule
end

HEYでさまざまな種類の連絡先(Contact)をモデリングするために、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

ここで1つ注意があります。Active Recordが提供する機能をフル活用するには、開発者であるあなたがデータベーススキーマをしっかり掌握しておくことが要求されます。それができれば、リッチで複雑なオブジェクトモデルをシームレスに永続化するスキルこそが、複雑な大規模コードベースでこのパターンが有効に働くための鍵となります。

🔗 カプセル化しやすくなる

Active RecordはRubyと非常によく調和しているので、標準的なRubyの機能を用いて詳細を隠蔽できます。これによって、永続化への関心を自然にカプセル化したコードが書けるようになり、データアクセスを分離するという儀式的な定形作業に追われることもなくなります。

たとえば前述のContact::Designatableコードをご覧ください。Active Recordのコードが普通のprivateメソッドでラップされています。しかも、ドメインロジックと永続化に関するあらゆるものが、#designate_toメソッドの背後に隠蔽されています。例の素のRails記事でも解説したように、これはシステム境界から見たときに望ましい自然なインターフェイスの一部になります。

このように、永続化がmix-inされているにもかかわらず、よく整理され、カプセル化もできています。シナリオがもっと複雑になっても、複雑さを隠蔽するオブジェクトを作成できなくなって困るようなことはありません。

たとえばBasecampでは、あるユーザーのアクティビティタイムラインを表示するためにTimeline::Aggregatorというクラスを使っています。これは、関連するイベントリストの配信を担当するPORO(Plain Old Ruby Object)です。このクラスは、クエリのロジックを以下のようにカプセル化します。

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

    # 直近のレコーディングをフェッチすることで
    # 巨大なレコーディングセットのクエリを著しく最適化する
    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のパフォーマンス最適化でスコープが使われている事例も見ることができます。

参考: 37signals Dev — Faster pagination in 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によってMySQLがトピックスをinvolvingより前に読み出すようになる
      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

🔗 永続化とドメインロジックの分離問題を問い直す

理論的には、永続化とドメインロジックを厳格に分離するのはよい考えのように思えます。しかし実用上は、2つの大きな困難を伴います。

第1に、どのような手法で分離したとしても、データアクセスの抽象化をアプリの永続化モデルの数だけ追加し、かつそれらをオーケストレーションしなければなりません。これでは儀式的な定形処理も複雑さも増えてしまいます。

第2に、リッチなドメインモデルの構築が難しくなります。ドメインモデルが永続化への関心から自由になったら、データベースアクセスの必要なビジネスロジックをどのように実装すればよいのでしょうか?たとえばDDD(ドメイン駆動設計)の場合は、repositoryやaggregate(集約)といったドメインレベルの要素を追加することになります。すると、調整する要素が3つになり、 純粋なドメインエンティティは永続化について一切関知しなくなります。これらの要素はどうやって互いにアクセスするのでしょうか?どのように協調動作すればよいのでしょうか?

このシナリオでは、あらゆるものをオーケストレーションさせるサービスを作りたくなります。しかし皮肉にも、このベストプラクティス設計を目指す実装の多くがドメインモデル貧血症問題で苦しむはめになっているのは、驚くことではありません。そうした実装のエンティティは、ほとんどが何の振る舞いも持たない単なるデータホルダーです。

永続化とドメインロジックを分離したいと望むことには、一定の理由があります。両者がマージされると、一緒になってはならないコードまで一緒になりやすくなり、言い換えればメンテが難しくなります。これは生SQLを直接使う場合に顕著になりますが、ほとんどのORMライブラリも永続化のみに注力しているので、同じようなことが起きます。

しかしActive Recordは永続化とドメインロジックが同居するという前提で設計されており、それに基づいて20年近くこのアイデアが繰り返し用いられています。

ごく最近、Kent Beckによる以下の洞察を目にして驚きました。

参考: Cohesion - by Kent Beck - Software Design: Tidy First?

ある要素の凝集性(cohesion)は、そのサブ要素との結合度合いと同程度になる。凝集性が完全な要素では、すべてのサブ要素が同時に変更される必要がある。

データベースを利用するアプリケーションでは、ドメインロジックと永続化は強固に結合します。果たして永続化の分離は、適切なORM(Object Relational Mapping)技術が使われていない場合の目標または緩和策になりえるのでしょうか?

私の視点から申し上げるなら、「もしも」ORMがホスト言語と完璧にブレンドされ、「もしも」オブジェクトモデルを永続化する優れたソリューションが存在し、「もしも」優れたカプセル化メカニズムが存在するのであれば、元の質問をこんなふうに言い換えます。
すなわち「どうすれば永続化をドメインロジックから切り離せるか?」という問いではなく、「なぜ自分はそうまでして切り離したがるのか?」という問いに言い換えるのです。

🔗 まとめ

私は随分前から、本記事で解説したようなActive Recordの活用方法が実際の現場でうまくいくことに気づいていました。自分がこれまで手がけたどのRailsアプリにも永続化用の単独レイヤはありませんでしたが、欲しいと思ったことは一度もありません。

素のRails記事でも似たような話を書きましたが、「Active Recordはプロトタイプを短期間で作成するにはよくても、どこかの時点でドメインロジックから永続化を切り離す別の手段を講じなくてはならなくなる」と主張する人々がいます。しかし私たちはそうしたことを経験していません。

私たちは、本記事で解説したActive Recordの活用法をBasecampでもHEYでも用いており、どちらも数百万のユーザーを擁するかなり大規模なRailsアプリケーションです。これらのアプリケーションで行われているあらゆる処理の中心にはActive Recordが存在しており、私たちは今も絶え間なく進化を繰り返しているのです。

この種の記事によくある注意事項を申し上げておきます。Active Recordはあくまでツールです。言うまでもありませんが、Active Recordを使ってもメンテ不可能なひどいコードを書くことはいくらでも可能です。

特に、concernsの場合でもそうだったように、Active Recordを使ったからといって、適切なシステム設計が不要になるわけでもなければ、適切なシステム設計方法を知らない人がシステムを設計できるようになるわけでもありません。Active Recordを使っていてメンテナンス上の問題を抱えているのであれば、それはおそらくツールのせいではなく、コードをメンテ可能にする数千もの手法をあなたがまだ身に付けていない可能性があります。

私は、Active Recordは昔ながらの「永続化をドメインロジックから厳密に分離すべき理由」が不要になるほど優れていると信じています。これは、厳密に分離しないことを正当化する選択ではなく、堂々と受け入れるべき機能であると私は考えています。


jorgemanrubia.com
本記事は、『私の好きなコード』シリーズの記事です。

関連記事

Rails: 私の好きなコード(1)大胆かつ的確なドメイン駆動開発(翻訳)

Rails: 私の好きなコード(2)フラクタルな旅(翻訳)

Rails: 私の好きなコード(3)"正しく書かれた" concerns(翻訳)

素のRailsは十分に豊かである(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。