こんにちは。リサーチ・アンド・イノベーションの中村(konk303)と申します。
いわゆる「railsおじさん」的な立場で、主にサーバーサイドの開発をしています。
Introduction
本稿ではQiitaのイミュータブルデータモデルと webアプリケーションにおける現実解にインスパイアされて、弊社でのイミュータブルデータへの取り組み(とその苦しみ)を紹介したいと思います。 qiita.com
イミュータブルデータモデルとは?
まるっと引用。
イミュータブルデータモデルと webアプリケーションにおける現実解 - Qiita
詳細はリンクに譲りますが、「履歴を全て残すようなデータ設計にし、 UPDATE を廃することで情報の追跡可能性を確保、堅牢な設計にする」モデリング手法です。 原則この手法に従うと、そうそう汚いモデルにはならないという優れもの(雑)
です。イベントが起こる度に新規レコードを積む方式。
実現したいこと
あるユーザーの特定イベント発生当時の属性情報を保存・閲覧可能にする
弊社のアプリCODEにはレシート・バーコードのスキャンを使った買物登録機能が、
またアンケートプラットフォームのMycommentにもアンケートに回答する機能があるのですが、
どちらも 「買物登録・アンケート回答当時のユーザー属性情報を使ってデータ分析をしたい」というオーダーが強くあります。
あるユーザーの2年前の買物は、現在の年齢でなく2年前当時の年齢でグループ化して分析したい、と。
これの実現のために、「ユーザー属性の情報を変更の度に履歴として保存する」仕組みにしています。
あまり重要視していないメリット
一方でこの辺はあまり意識していません。
UPDATEが廃止でき、レコードのsaveが素直になる
これが本来一番のメリットなのですが、
rails (というかrailsについてくるどんなテーブルでもPKはid:intに決まり
な割り切り)
に身を任せていると、普段それほどツラくないところではあります。
履歴が全て残せる
これも大きなメリットではあるのですが、
結局履歴が全部ある
が役に立つ場面はそれ程出てこない印象です。
ユーザーサポートで、特定のユーザーがいつからいつまで問題のあったバージョンのアプリを使っていたか
みたいな時に使われる程度。
実装
今回は単純にユーザーと住所(都道府県)の関係で説明します。
よく出てくるオーダーとしては
- 特定イベント(買物登録・アンケート回答)に紐づくイベント当時の住所を使ってデータ分析をしたい (使用量多い)
今現在東京に住むユーザーを抽出したい
(該当ユーザーにメッセージを表示したり) (使用量非常に多い)- 特定ユーザーの属性遷移履歴を確認したい (たまにのオーダー)
履歴を保持する必要は絶対にあるのですが、「大抵の場合必要なのは最新値のみ」も一方では現実で、
どう両立させるかを試行錯誤しています。
使用用途が分析なので、sqlレベルで解決したい
という気持ちもあります。
住所1カラムならUserにcurrent_prefecture
をキャッシュ値として保存してしまえば良いのですが、
実際の属性は何項目もあってキリがない状態です。
パターン1: まじめにイミュータブルにする・「最新の状態」はsubqueryで取得
subqueryを使って該当ユーザーの現在の住所
を取得するassociationを追加で定義しています。
user.current_address
でユーザーの現在の住所が取得できます。
modelの使い勝手としては非常に良いのですが、 データ数が増えると加速度的に遅くなるという重大な問題を抱えています (実際困ってます 😓 )。
class User < ActiveRecord::Base # id: integer has_many :user_addresses has_one :current_address, -> { latests }, class_name: 'UserAddress' has_many :purchase_events end class UserAddress < ActiveRecord::Base # id: integer # user_id: integer # prefecture: string belongs_to :user scope :latests, -> { where(id: unscoped.group(:user_id).select(arel_table[:id].maximum)) } end class PurchaseEvent < ActiveRecord::Base # id: integer # user_id: integer # user_address_id: integer belongs_to :user # strictにやるなら `belongs_to :user, through: :user_address`かも belongs_to :user_address end
お題: 現在東京に住むユーザー一覧
User.joins(:current_address).where(user_addresses: { prefecture: 'tokyo' }) => "SELECT `users`.* FROM `users` INNER JOIN `user_addresses` ON `user_addresses`.`user_id` = `users`.`id` AND `user_addresses`.`id` IN (SELECT MAX(`user_addresses`.`id`) FROM `user_addresses` GROUP BY `user_addresses`.`user_id`) WHERE `user_addresss`.`prefecture` = 'tokyo'"
お題: 特定の買物登録の登録時のユーザー住所
PurchaseEvent.find(12345).user_address.prefecture
パターン2: 「最新の状態」と履歴でテーブルを分ける・最新の状態更新時に履歴側も書き込み
これは全然イミュータブルじゃないですね…。
ですが、履歴を残す
こそが実現したいことの場合は、結局コレが一番見通しがよく感じてます。
after_saveのcallbackにロジックを隠せたことで 「実際のビジネスロジックに履歴保存にまつわるノイズを残さない」と 「間違いなく1transaction内でデータの更新と履歴の登録を行う」が実現できてます 😎。
class User < ActiveRecord::Base # id: integer has_one :user_address has_many :user_address_logs, through: :user_address has_many :purchase_events end class UserAddress < ActiveRecord::Base # id: integer # user_id: integer # prefecture: string belongs_to :user has_many :user_address_logs after_save :create_log! private def create_log! user_address_logs.create!(slice(:prefecture)) if changed? end end class UserAddressLog < ActiveRecord::Base belongs_to :user_address has_one :user, through: :user_address end class PurchaseEvent < ActiveRecord::Base # id: integer # user_id: integer # user_address_id: integer belongs_to :user # strictにやるなら `belongs_to :user, through: :user_address_log`かも belongs_to :user_address_log end
お題: 現在東京に住むユーザー一覧
User.joins(:user_address).where(user_addresses: { prefecture: 'tokyo' }) => "SELECT `users`.* FROM `users` INNER JOIN `user_addresses` ON `user_addresses`.`user_id` = `users`.`id` WHERE `user_addresss`.`prefecture` = 'tokyo'"
お題: 特定の買物登録の登録時のユーザー住所
PurchaseEvent.find(12345).user_address_log.prefecture
まとめ
全然イミュータブルデータモデルの話じゃなく 「履歴を保存しつつ現在の状態をサクッと取得するのにどう苦労しているか」の話になってしまいましたが、 良い設計とパフォーマンスとのバランスを取るべく毎日試行錯誤しています。
リサーチ・アンド・イノベーションでは、railsを乗りこなしてやりたいことをサクッと実現していくエンジニアと
「そんなのは不毛だ・イミュータブルこそ未来だ・今すぐclojureとdatomicに移行しよう」と
熱く説得してくれるエンジニアの両方を募集しています 😏。
こちら からご応募お待ちしております。