素敵に調和するActive Record(翻訳)
英語記事: Active Record, nice and blended
原文公開日: 2022/12/16
原著者: Jorge Manrubia
Active Recordは、「永続化とドメインロジックをどのように分離するか?」 という従来の問題に対し次のような再提案をしています。「もし分離する必要がないとしたら?」
リレーショナルデータベースへのオブジェクトの永続化は複雑な問題です。20年前は、この問題は「プログラマーが気にしなくてもいいように永続化を抽象化する」という、究極の直交性[2]の実現という問題のように思われました。しかしそれから何年も経ち、私たちはそう単純な話ではないと確信しました。永続化は、確かに横断的関心事[3]ですが、多くの触手を持つ問題なのです。
永続化を完全に抽象化することはできないため、多くのパターンは、データベースへのアクセスを独自のレイヤーに分離し、ドメインモデルを永続性から解放しようとしています。例えば、repositories、Data Mappers、DAOs(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 attributes、delegated 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は迅速なプロトタイプには有効だが、ある時点で、ドメインロジックと永続化を分離するための代替手段が必要になると主張する人もいます。しかし私たちの経験上そんなことはありません。私たちはBasecampとHEYという数百万人が利用する大規模なRailsアプリケーションで、本記事で説明したようにActive Recordを使用しています。これらのアプリケーションは、Active Recordを中心に据えて、進化を続けているのです。
この手の記事にありがちな注意事項があります。Active Recordはツールであるため、当然それを用いて乱雑で保守性の低いコードを書くこともできます。特に、concernsの場合と同様に、システムを適切に設計する必要性は依然としてありますし、適切な設計方法を知らなければ、あまり助けにはならないでしょう。Active Recordを使用していてコードのメンテナンスに問題があるとしたら、それはActive Recordのせいではなく、コードをメンテナンスしやすくするための他の幾千の事柄を正しく理解できていないからかもしれません。
私は、Active Recordはとても優れているため、永続化とドメインロジックを厳密に分離すべきだという従来の主張を覆すと考えています。私にとってActive Recordは、弁明の必要などない、堂々と採用すべき機能です。
この記事は、Code I likeというRailsの設計技法に関する連載記事に属しています。
-
その他翻訳記事:
第一弾: Domain driven boldness
第二弾: Fractal journeys
第三弾: Good concerns
第四弾(TechRachoさん): Vanilla Rails is plenty ↩︎ -
(訳注)直交性とは「ある種の独立性、あるいは結合度の低さ」「2つ以上のものごとで、片方を変更しても他方に影響を与えない」ような性質のこと(David Thomas, 達人プログラマー(第2版), 50ページより一部引用) ↩︎
-
(訳注)横断的関心事とは、単一のモジュール構造にカプセル化できず、複数のモジュールにおいて本質的に同じ実装が繰り返し必要となるような関心事のこと。ロギングやキャッシング、永続化など。(参考:Robert C.Martin, Clean Code, 217ページ、Cross-cutting concern (Wikipedia)) ↩︎
-
(訳注)原文ではただのハイライトがされていますが、純粋な行のハイライト機能が無かったためdiffの機能で代用しています。強調以外の意図はありません。ご容赦ください。 ↩︎
Discussion