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は十分に豊かである(翻訳)』記事は『私の好きなコード』シリーズの(4)に含まれました。