度々DHHがConcernの素晴らしさをアピールする時に貼っつけるコードのスクショにRecordingというのがあった
DHHファンの私は、この狂気じみたMixinに興奮しつつも「ところでRecordingってなんすか...?」っていう感情を募らせていた
何か記録するものなんだろうが、これに関連してるであろうRecordableというConcernがBasecamp(プロジェクト管理のWebサービス)のほとんどあらゆるモデルにmixinされてるという話を聞き、なんなんだそれは...となっていた
これに関するアンサー的な動画が37singalsのdevチャンネルで公開され、ファンは一同釘つけになった
私もその一人である
拙い英語力と翻訳機を駆使してここでいってることをある程度把握できたと思うのでメモ代わりに記事にしようと思う
何をするものなのか
やはり履歴管理が目的らしい
Basecampはそこで管理しているドキュメントなどの変更履歴を細かく追うことができ、さらにそのバージョンにいつでも戻せるといった機能があるらしい
これをあらゆるモデルに対して可能にしていて、その履歴管理のために長年苦心して編み出したのが現在のパターンだという話らしい
謎解き
まず動画内のキーワードを拾っていくと
- delegated typeを用いる(1:43あたり)
- 各記録可能なモデル(Documentとか)はimmutable (9:40あたり)
- 実際の履歴の追跡はeventモデルが行う (11:45あたり)
- Recordingはimmutableではない (12:49あたり)
- Recordingは各記録可能なモデルのメタ情報が入っている (timestampやcreator_id) (03:53)
- recordableなモデルに対応するURLのidは実際にはrecordingのid (14:08)
となっている
DocummentやCommentといった記録可能(recordable)なモデルは不変といっているのでUI上の削除は実際の削除ではないのであろうと読み解ける
また、動画内でちらっと
events.all? { |e| e.recording&.deleted? }
という実装が写ったのでrecordingモデルも変更されこそ削除はされないのであろう
動画中では明かされなかったがおそらくdeleted_atみたいなカラムがあることが推測される
またeventはrecordingにアクセスできてるっぽいので両者にはなんらかのassociationがあるのだろう
recordingには
delegated_type :recordable, types: Recording::Types, inverse_of: :recordings
みたいな処理も見れて動画内の発言との整合性がとれた
ざっくり聞いた感じとこの辺を考慮するとこんな感じかなというのが見えてくる
多分こんな感じ?
migration
recording
- 各記録可能なモデルと関連づけなきゃいけないのでpolymorphicな感じになる
- 削除されないが削除状態を扱う必要があるのでdeleted_atがある
- (動画ではcreater_idみたいなのがありそうな雰囲気あったけど割愛)
bin/rails g model recording recordable:references{polymorphic} deleted_at:datetime
移譲型でrecordable_typeに入るモデルを定義、ここでは一旦話をシンプルにしたくてDocumentだけを対象にしている
クエリ削減目的でinvers_ofも指定していた
(動画内ではRecordingがコピーされることで一つのドキュメントが複数に紐づくこともあるよねってことで複数系だったがちょっとややこしくなるので1:1と考える)
class Recording < ApplicationRecord
delegated_type :recordable, types: %w[Document], inverse_of: :recording
has_many :events
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
def deleted?
deleted_at.present?
end
end
recordableなデータ(ex: Document, Comment, ...)
とくにテーブル構造を変にする必要はない
bin/rails g model document title:string body:text
recordable modelとrecordingの関連づけ
recordable modelは不変なのでrecording経由でdeletedの判定をしちゃう
module Recordable
extend ActiveSupport::Concern
included do
has_one :recording, as: :recordable, autosave: false
scope :active, -> { joins(:recording).merge(Recording.active) }
end
end
class Document < ApplicationRecord
include Recordable
end
event
- recordingを引っ張れる
- 対象となるrecordableなモデルを引っ張れる
- recordableなモデルの変更履歴を持つため、create/update/destroyedみたいな操作内容を持つフィールド(action_type)が必要
というところを考えるとこれもpolymorphicな感じになるのかな
bin/rails g model event action_type:string recordable:references{polymorphic} recording:references
class Event < ApplicationRecord
belongs_to :recording
belongs_to :recordable, polymorphic: true
validates :action_type, presence: true
end
関係性まとめ
Recording (id, recordable_type, recordable_id, deleted_at)
├─ belongs_to :recordable (Document など)
└─ has_many :events
Document (id, title, body)
└─ has_one :recording, as: :recordable
Event (id, action_type, recording_id, recordable_type, recordable_id)
├─ belongs_to :recording
└─ belongs_to :recordable (Document, Comment ...)
routing部分
recordableモデルのルーティングのidはrecordingになるという話なのでちょっと名前をいじる
resources :documents, param: :recording_id do
# 変更履歴表示側
resources :events, only: [:index, :show], module: :documents
end
controller部分
そしてcontroller部分、DocumentController /documents/:recording_id となるので基本はrecordingを一本釣りしてそこからrecordable経由でrecordable modelを取得する流れになる
そしてrecordable modelは不変なのでupdateは新規recordable modelの作成、削除はrecordingのdelete_atを記入と置き換わる
そして、都度その情報を記録したイベントを作成していくという形になるという想定でこんな感じだろうか
class DocumentsController < ApplicationController
before_action :set_recording, only: [:show, :edit, :update, :destroy]
before_action :set_document, only: [:show, :edit]
# GET /documents
def index
@recordings = Recording.active.documents.order(updated_at: :desc).limit(50)
end
# GET /documents/:recording_id
def show
end
# GET /documents/new
def new
@document = Document.new
end
# POST /documents
def create
@document = Document.new(document_params)
ApplicationRecord.transaction do
@document.save!
@recording = Recording.create!(recordable: @document)
Event.create!(recording: @recording, action_type: :created, recordable: @document)
end
redirect_to document_path(@recording.id), notice: 'Document was successfully created.'
rescue ActiveRecord::RecordInvalid
render :new, status: :unprocessable_entity
end
# GET /documents/:recording_id/edit
def edit
end
# PATCH/PUT /documents/:recording_id
def update
@document = Document.new(document_params)
ApplicationRecord.transaction do
@document.save!
@recording.update!(recordable: @document)
Event.create!(recording: @recording, recordable: @document, action_type: :updated)
end
redirect_to document_path(@recording.id), notice: 'Document was successfully updated.'
rescue ActiveRecord::RecordInvalid
render :edit, status: :unprocessable_entity
end
# DELETE /documents/:recording_id
def destroy
@document = @recording.recordable
ApplicationRecord.transaction do
Event.create!(recording: @recording, action_type: :destroyed, recordable: @document)
@recording.update!(deleted_at: Time.current)
end
redirect_to documents_url, notice: "Document was successfully destroyed."
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed
render :show, status: :unprocessable_entity
end
private
def recording_id
params[:recording_id]
end
def set_recording
@recording = Recording.find(recording_id)
end
def set_document
@document = @recording.recordable
end
def document_params
params.expect(:document, [:title, :body])
end
end
で、履歴側はこんな感じ
class Documents::EventsController < ApplicationController
before_action :set_recording
before_action :set_event, only: [:show]
# GET /documents/:document_recording_id/events
def index
# @recording に紐づく全てのイベントを取得
# 最新の変更が上に来るように降順でソート
@events = @recording.events.order(created_at: :desc)
end
# GET /documents/:document_recording_id/events/:id
def show
@document = @event.recordable
end
private
def recording_id
params[:document_recording_id]
end
def set_recording
@recording = Recording.find(recording_id)
end
def set_event
@event = @recording.events.find(id)
end
実際こんな感じでつくって適当にview用意したら履歴管理はちゃんとできるようになった
適当にドキュメントを作って3回ほど更新をかけて動きをみてみる
documents#show
documents::events#index
documents::events#show 先頭(最新のもの)
documents::events#show 2つめのもの
documents::events#show 3つめのもの
documents::events#show 末尾(作成時のもの)
今はスナップショットを出してるが一つ前のeventからrecordable(つまりdocument)を取り出してdiffyとかで差分を出せば冒頭のbasecampのような差分表示も可能になると思う
きっとこんな感じなのかな〜〜
最後に
動画中でもちょくちょく話されてるが少し小難しい実装になっています
37signalsもこのパターンを常に使ってるわけではなくrecordingが重要なアプリケーションでだけで使っています
ただ、込み入った業務アプリケーションなどを作る際はこのような履歴管理、元のバージョンへの復帰機能、差分表示などこういう要件がでてくることは多いのでそういった時の参考にできればいいですね







