イミュータブルデータモデルへの取り組み with Ruby on Rails

こんにちは。リサーチ・アンド・イノベーションの中村(konk303)と申します。
いわゆる「railsおじさん」的な立場で、主にサーバーサイドの開発をしています。

Introduction

本稿ではQiitaのイミュータブルデータモデルと webアプリケーションにおける現実解にインスパイアされて、弊社でのイミュータブルデータへの取り組み(とその苦しみ)を紹介したいと思います。 qiita.com

イミュータブルデータモデルとは?

まるっと引用。
イミュータブルデータモデルと webアプリケーションにおける現実解 - Qiita

詳細はリンクに譲りますが、「履歴を全て残すようなデータ設計にし、 UPDATE を廃することで情報の追跡可能性を確保、堅牢な設計にする」モデリング手法です。 原則この手法に従うと、そうそう汚いモデルにはならないという優れもの(雑)

です。イベントが起こる度に新規レコードを積む方式。

実現したいこと

あるユーザーの特定イベント発生当時の属性情報を保存・閲覧可能にする

弊社のアプリCODEにはレシート・バーコードのスキャンを使った買物登録機能が、
またアンケートプラットフォームのMycommentにもアンケートに回答する機能があるのですが、
どちらも 「買物登録・アンケート回答当時のユーザー属性情報を使ってデータ分析をしたい」というオーダーが強くあります。
あるユーザーの2年前の買物は、現在の年齢でなく2年前当時の年齢でグループ化して分析したい、と。

これの実現のために、「ユーザー属性の情報を変更の度に履歴として保存する」仕組みにしています。

あまり重要視していないメリット

一方でこの辺はあまり意識していません。

UPDATEが廃止でき、レコードのsaveが素直になる

これが本来一番のメリットなのですが、 rails (というかrailsについてくるどんなテーブルでもPKはid:intに決まりな割り切り) に身を任せていると、普段それほどツラくないところではあります。

履歴が全て残せる

これも大きなメリットではあるのですが、 結局履歴が全部あるが役に立つ場面はそれ程出てこない印象です。

ユーザーサポートで、特定のユーザーがいつからいつまで問題のあったバージョンのアプリを使っていたか みたいな時に使われる程度。

実装

今回は単純にユーザーと住所(都道府県)の関係で説明します。

  1. ユーザーは、会員登録時に自分の都道府県を登録する
  2. ユーザーは、(引っ越しにより) 自分の都道府県を随時変更する


よく出てくるオーダーとしては

  1. 特定イベント(買物登録・アンケート回答)に紐づくイベント当時の住所を使ってデータ分析をしたい (使用量多い)
  2. 今現在東京に住むユーザーを抽出したい (該当ユーザーにメッセージを表示したり) (使用量非常に多い)
  3. 特定ユーザーの属性遷移履歴を確認したい (たまにのオーダー)


履歴を保持する必要は絶対にあるのですが、「大抵の場合必要なのは最新値のみ」も一方では現実で、 どう両立させるかを試行錯誤しています。 使用用途が分析なので、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を乗りこなしてやりたいことをサクッと実現していくエンジニアと
「そんなのは不毛だ・イミュータブルこそ未来だ・今すぐclojuredatomicに移行しよう」と
熱く説得してくれるエンジニアの両方を募集しています 😏。
こちら からご応募お待ちしております。

参考