Rails: 私の好きなコード(2)フラクタルな旅(翻訳)
フラクタルとは、より小さなスケールに進んでも同じようなパターンが繰り返される現象です。私にとって、優れたコードはフラクタルなコードです。すなわち、抽象化レベルが高い場所でも低い場所でも品質が同じに保たれている様子が観察できるということです。
これは別段驚くような話ではありません。優れたコードとは理解がたやすいコードのことであり、複雑さに対処するベストなメカニズムは、すなわち抽象化の構築です。インターフェイスを私たち人間が理解しやすいものにするために、抽象化の構築によって複雑さのやりとりが行われます。しかし抽象化によって背後に押しやられた複雑性については引き続き処理が必要です。そのために、同じ手順を全体で繰り返すことになります。詳細を隠蔽する抽象を新たに構築し、それらを処理する上位のメカニズムを提供するわけです。
私は、巨大サブシステムから内部クラスの末尾にあるprivateメソッドにいたるまで、あらゆるものを抽象化によって参照します。しかし抽象化はどのように構築すればよいのでしょうか?これは100万ドルに値する質問であり、無数の書籍の主題にもなっています。本記事では、コードを理解しやすくするために必要だと私が考える4つの性質に注目していきたいと思います。
- ドメイン駆動(Domain-Driven): 問題のドメインを言語化する
- カプセル化(Encapsulation): 非常に明確なインターフェイスを公開し、細部を隠蔽する
- 凝集性(Cohesiveness): 呼び出し元から見て1つの処理だけを行う
- 対称性(Symmetry): 同じ抽象化レベルで動作する
失礼、本記事そのものの抽象化が早くもやりすぎになりつつあるので、Basecampで使われている実際のコードでわかりやすく説明したいと思います。
Basecampは多くの場面でアクティビティタイムラインという情報を提供しています。このタイムラインは動的に更新され、ユーザーが表示中に誰かが何らかの操作を行うと即座に更新されます。
ドメインレベルでは、Basecampでユーザーが何らかの操作(ToDoを完了、ドキュメント作成、コメント投稿など)を行うと、システムは"イベント"を作成し、それらのイベントはさまざまな場所(アクティビティタイムライン、Webフックなど)に"中継"(relay)されます。このコードを見ていくことにしましょう。
最初にEvent
モデルがあり、Relaying
というconcernがinclude
されています(関係ないコードは省略しています)。
class Event < ApplicationRecord
include Relaying
end
このconcernは、relays
関連付けと、イベント作成時にイベントを非同期に中継するフックを追加します。
module Event::Relaying extend ActiveSupport::Concern
included do
after_create_commit :relay_later, if: :relaying?
has_many :relays
end
def relay_later
Event::RelayJob.perform_later(self)
end
def relay_now
...
end
end
class Event::RelayJob < ApplicationJob
def perform(event)
event.relay_now
end
end
ここで注目したいのはEvent#relay_now
メソッドです。このメソッドを見るといくつかの点に気づきます。
- このメソッド名はドメインの言葉になっている
- メソッドを呼び出すジョブ側から見て1つの処理だけを行っている
- イベントの中継に関するすべてがこの場所で隠蔽されている
このメソッドを深掘りしてみましょう。
module Event::Relaying
def relay_now
relay_to_or_revoke_from_timeline
relay_to_webhooks_later
relay_to_customer_tracking_later
if recording
relay_to_readers
relay_to_appearants
relay_to_recipients
relay_to_schedule
end
end
end
このメソッドは、より低レベルのいくつかのメソッド呼び出しを取りまとめています。呼び出されるのはすべて中継に関するメソッドなので、凝集性は保たれています。「どこに中継するか」という行き先も、ドメインに基づいたメソッド名として明確に示されています。詳細も引き続き隠蔽されています。さらに、他の抽象化レベルを見に行かなくても、このメソッドが何をしているかを理解できるようになっているので、対称性も実現されています。
以下の#relay_to_or_revoke_from_timeline
メソッドが、どうやら私たちが探しているもののようです。
module Event::Relaying private
def relay_to_or_revoke_from_timeline
if bucket.timelined?
::Timeline::Relayer.new(self).relay
::Timeline::Revoker.new(self).revoke
end
end
end
ここでも、ドメインに基づいた良い命名がなされています。あるbucket
がタイムラインに乗っているかどうかをtimelined?
でチェックし、Timeline::Relayer
オブジェクトを作成してイベントをタイムラインにrelay
しています。イベントをrevoke
するクラス名(Timeline::Revoker
)はrelay
と対になっているので、対称性も達成されています。このメソッドは中継とタイムラインに専念しているので凝集性も達成されていますし、実装の詳細も引き続き隠蔽されています。
では、そのクラスを見てみましょう。
class Timeline::Relayer def initialize(*event*)
@event = event end
def relay
if relaying?
record
broadcast
end
end
private
attr_reader :event
delegate :bucket, to: :event
def record
bucket.record Relay.new(event: event), parent: timeline_recording, visible_to_clients: visible_to_clients?
end
def broadcast
TimelineChannel.broadcast_event(event, to: recipients)
end
end
今度は純粋なRubyクラスによる抽象化であり、メソッドによる抽象化ではありませんが、同じ特徴を観察できます。
公開されている#relay
publicメソッドは詳細を隠蔽しています。中を見てみると、中継をデータベースに記録(record
)する操作と、イベントをAction Cableでブロードキャストする操作(なお、このコードはHotwire登場よりずっと前に書かれました)という2つの操作を行っています。さらに、2つの操作はどちらも1行のコードを呼び出しているだけですが、どちらもより上位レベルのメソッドとして切り出されているので、対称性も達成されています。
ついに低レベルの詳細部分に到達しました。
#record
メソッドは中継をデータベースで永続化します。中継(relay)は録画としてレコーディング可能であり、Railsのdelegated type(#39341)1の元にもなった典型的なユースケースでもあります。
#broadcast
メソッドは、イベントを受信者たちにブロードキャストする場所であり、本記事で最初に知りたいと思っていた部分です。
このコード例では、イベント作成の瞬間から、Action Cableチャネル経由でプッシュされるまでの中継ロジックをスムーズに理解できました。これが可能になったのは、コードをジャンプするたびにそこで注目すべき内容が常に「1つだけ」に保たれていたおかげです。どこにジャンプしようと責務は1つだけで、抽象化レベルをまたがらずに1つの抽象化レベルに収まっています。メソッド名やクラス名は、私たちが考えている問題を的確に反映しています。もちろん、コードが優れているかどうかは主観に基づくものですし、他にも多くの概念と関連していますが、ある程度以上の規模のシステムでコードを楽に追いかけられるように書けるスキルは、私が最も愛する特性です。
本記事は、『私の好きなコード』シリーズの記事です。
随分前の記事になりますが、take on the composed method implementation patternもご覧ください。同記事では2冊の書籍を参照していて、そこが記事で最も有用な部分です。このトピックにご興味がおありでしたら、ぜひこれらの書籍も読むことをおすすめします。
概要
原著者の許諾を得て翻訳・公開いたします。