途中からイミュータブルデータモデルにしたくなった!
はじめに
株式会社ウェイブでCoolmicのエンジニアをしている布施です。
Coolmicは日本のコミックを海外向けに配信するWEBサービスです。
今回、サイト内で販売しているコンテンツの価格改定を行うためにシステムの修正を行いました。
スキーマ設計やコードの結合度などについて考える良い機会になったので、やったことの一部を記録します。
サンプルコードはRuby(Rails)です。
背景
データベースのCRUD操作のうちUpdateとDeleteはシステムを複雑にしてしまう、というような記述を時々見かけます。
コンテンツの価格改定を考える時に最も単純な方法はcontentsテーブルのレコードのpriceカラムをUpdateすることですが、この方法だと価格改定履歴が残りません。
また、改訂前にそのコンテンツを購入したユーザーの購入履歴を集計するときに、データに不整合があるように見える可能性があるなど、他にもよくないことがありそうです。
電子コミックというコンテンツの都合上、購入したコンテンツはユーザーと(ほぼ)永久的に関連づけられます。そのため、contentsの古いレコードをサイト内で非表示にして、新しいpriceのcontentsレコードを作り直すのもイマイチです。
このような背景から、コンテンツの価格改定履歴を管理するためのcontent_pricesテーブルを作成する方法を考えました。
やりたいこと
- モデルの一カラムとして管理していたデータを、新しい専用のモデル(これがイミュータブルになる(はず))に抽出する。
- 新モデルとのリレーションが存在しない場合、元のモデルのカラムを参照するようにする。
- 元のモデルのテーブルのカラムを直接参照している箇所を修正する。☠️
イメージはこんな感じです。
contentsテーブル
| id | name | price |
|---|---|---|
| 1 | Content01 | 1.00 |
| 2 | Content02 | 2.00 |
content_pricesテーブル
| id | content_id | price | start_at |
|---|---|---|---|
| 1 | 1 | 1.11 | 2025/01/01 00:00:00 |
| 2 | 1 | 1.22 | 2025/02/01 00:00:00 |
| 3 | 2 | 2.22 | 2025/03/01 00:00:00 |
Content01のpriceは、$1.00 -> $1.11 -> $1.22と改定されていきます。
やったこと
ContentPriceモデルの作成
新規作成しまーす
以上
ActiveRecordのGetterのオーバーライド
Railsのモデルのカラムは以下のように参照することが多いと思います。
content = Content.find(21)
content.price
このような参照ができるのは、ActiveRecord::Baseが自動で各カラムのgetter/setterを定義しているからです。
今回はこのgetterメソッドをオーバーライドすることで、変更箇所を最小にしました。
Contentのpriceの参照箇所は結構多く、新規メソッドへの置換漏れのリスクもあるのでこの方法を採用しました。まあ結局全部チェックするんですが
変更イメージ
class Content < ActiveRecord::Base
has_one :latest_content_price, ->{ order(id: :desc) }, class_name: "ContentPrice", dependent: :destroy
def price
return self[:price] if self.latest_content_price.blank?
self.latest_content_price.price
end
end
ContentPriceはコンテンツの価格情報を持つモデルです。
datetimeのstart_atカラムを持たせれば、価格改定の開始タイミングもコントロールしやすくなります。
ContentPriceとのリレーションが存在しない場合はContentのpriceを参照します。
self[:price]
これで、全コンテンツのcontent_pricesテーブルのレコードを準備しなくてもよくなります。
参考
テーブルのカラムを直接参照している箇所の修正
なんかcontent_pricesをcontentsにLEFT OUTER JOINとかしていい感じにGROUP BYしてどうにかしました。
気合いです。
N+1問題の予防
コンテンツをリストとして扱う箇所もあるので、全ての該当箇所に.includes(:latest_content_price)を追加しました。
感想
今回はコンテンツの価格改定を扱うことになり、改めてデータモデリングの重要性について考えさせられました。
「価格」は「コンテンツ」の属性の一部とする考え方も自然に思えますが、独立して変更される可能性があると、別のモデルとして管理した方が良い場合もあるかもしれません。もちろんコンテキストによりますが。
「価格」というリソースではなく、「あるタイミングでこの価格が設定された」というイベントエンティティとも捉えられるということですかね。わかりません。
また、ECサイトである都合上、コード上にpriceという単語はかなり多く登場します。(それも異なるコンテキストで😇)
多少不自然になったとしても、コンテキストにあったユビキタス言語?を設定していけるとよいのかもしれません。
参考
株式会社ウェイブのエンジニアによるテックブログです。 弊社では、電子コミック、アニメ配信などのエンタメコンテンツを自社開発で運営しております! wwwave.jp/service/
Discussion