はじめに
この記事はSTORES Advent Calendar 2023 20日目の記事です。
こんにちは。STORES 予約 のweb開発をしていますosdです!
今年の10月に STORES 予約 では顧客詳細画面のリニューアルしました。
そこで今回は顧客詳細画面の中の予約/来店履歴の設計とアプローチについて書いてみようと思います。
背景
今回のリニューアルでは、対象の顧客の来店の予定や履歴を確認できるように予約とチェックイン(対象の顧客に対して予約がない状態で来店のみ記録する機能)の情報を統合してみせる要件がありました。
表示に必要な情報のtable構造は以下のようになっており、2つの Reservation
/ Checkin
tableに跨る情報をチェックイン時間で降順にソートしpaginationを実装する必要があります。
案1: 愚直にcustomerに関連するreservationとcheckinを取得してsortして表示する
pros
- 現在存在しているtableのみで実装できるので実装工数としては少ない手順で実装できる
cons
- 可読性のある状態でクエリを効率化することが難しい
案2: 新しいtableを作成し、Reservation / Checkin のtableと同期するcache tableを作成する
pros
- クエリ効率を上げることができる
- 可読性の高い状態で実装ができる
cons
- 実装と同期処理に一定実装工数がかかる
- callbackでレコードを同期するのでcallbackをskipする様なmethodでレコードを操作する際に考慮する事項が増える
- cacheレコードが作成できなかった際に予約やチェックインが行えなくなるリスクが存在する(after_save callbackで実装しているので、例外が吐かれた際にはReservation / Checkinの変更自体もrollbackされる)
実際に行ったアプローチ
顧客詳細リニューアルでは、顧客分析の観点から多くのアクセスがある点や一人の顧客に対して1000件以上のレコードを持つパターンも存在するためパフォーマンスの観点から開発段階で実装工数を使って案2の方針で進めることが決定しました。
consとして、callbackでのレコードの同期に課題はありますが
- 基本的にvalidationをskipする様な更新を行うケースは少なく、対象となるイレギュラーなオペレーションに関してはドキュメントやbatch処理が存在するのでそちらで別途考慮した処理を行うことで回避できる
- cacheレコードの保存が行えない場合は基本的にないので例外として監視して発生時に気づける仕組みが存在する
などの理由から案2に倒しました。
実際に行ったアプローチとしては以下となりました。
- reservation / checkinに対して予約来店履歴を表すcache tableを作成
- reservation / checkinのmodelにafter_saveのcallbackでcacheのレコードを複製する処理の実装
- 過去分のcacheレコードを作成するbatchを作成
ここからはそれらの内容をより詳しく見ていきたいと思います!
table設計
今回cache tableとして作成したtableは以下のようになっています
polymorphic関連でReservationとCheckinを関連にもつ構造になっており、追加のカラムとしてはチェックイン時間でsortしたいのでoriginal_checkin_atをカラムに持つような設計になっています
また、複合indexを customer_id original_checkin_at id
に対して貼っています
after_saveでReservation/Checkin更新時にcacheのレコードを作成する
次は実際にcacheのレコードを作成する処理の実装です
それぞれ Reservation/Checkin のmodelでafter_save callbackで update_reservations_history_cache
のようなmethodを作成して対象のcacheを作成/更新する処理を実装しました。
def update_reservations_history_cache! # status: drifting, holding は記録しない if customer.blank? || drifting? || holding? # この状態の時は同期したくないのでdestroyをする self.customer_reservation_and_check_in_history_cache&.destroy! return end reservation_history_cache = customer_reservation_and_check_in_history_cache || build_customer_reservation_and_check_in_history_cache reservation_history_cache.update!(customer:, original_checkin_at: self.checkin) end
実は実装当初はupsertを用いて CustomerReservationAndCheckInHistoryCache
を作成していたのですが、以降予約が入る際にupsertによってDB waitがそこそこ取られていたので愚直にupdate!で書き換えた修正が入りました。(↓実装当初の形)
def update_reservations_history_cache # 明示的にstatus: drifting, holdingの時に同期しない if self.customer.blank? || self.drifting? || self.holding? self.customer_reservation_and_check_in_history_cache&.destroy! return end attributes = { original_id: self.id, original_type: self.class.name, customer_id: self.customer.id, original_checkin_at: self.checkin, } CustomerReservationAndCheckInHistoryCache.upsert(attributes) end
差分としては、upsert
はvalidationをskipするのでattributesの値をそのまま詰める形になりますが、 update!
はvalidationを通り落ちた場合にはraiseが走りますが基本的に落ちることはない設計になっているのでそのままupdateで書き直す修正が入っています。
Reservation
が更新された際
明示的に特定のstatus(予約をするフローの途中で作成されるstatusなど)では同期しないような処理が入っています
これらのstatusは確定した予約ではなく、確定していない予約になっています。それらの情報を予約/来店履歴に出したくないので同期段階で切り落とすことでクエリで不必要なreservationなどを省く処理などが必要なく customer.customer_reservation_and_check_in_history_caches
のみで表示するレコードが取得できる設計にしました。
Checkin
が更新された際
Reservationと同様に、予約に紐づくcheckinは予約自体のstatusとしてチェックイン済みが存在しその存在と重複するので切り落としています。
過去履歴をsyncするbatchの実装
batchの実装も同じようにReservation全件とCheckin全件に対してfind_in_batchesでcash recordをinsertする処理を書きbatchを回しました。
Reservation .where.not(customer_id: nil) .where.not(checkin: nil) .includes(:customer) .find_in_batches do |reservations| attributes = reservations.map do |reservation| { original_id: reservation.id, original_type: reservation.class.name, customer_id: reservation.customer.id, original_checkin_at: reservation.checkin, } end CustomerReservationAndCheckInHistoryCache.insert_all(attributes) end
apiでの取得ロジック
ということで、簡潔なクエリで表示対象のデータをpaginationを用いて引いてこれるようになりました!
customer_reservations_and_checkins = @customer .customer_reservation_and_check_in_history_caches .preload(original: %i[resource course customer_visit options customer_memos staff]) .order(original_checkin_at: :desc, id: :asc) .page(params[:page] || 1) .per(per_page)
↑の画像にもある様に、月でグルーピングしてカーソルページネーションで表示する実装も面白かったので次の機会に......
まとめ
今回は顧客詳細画面の二つのtableに跨るレコード数の多いデータを取得する際に、可読性とクエリ効率を高く保ちながら実装をするアプローチについて書いてみました! 今後も実装方針に関しては、その場その場で適切な判断ができるように精進していきたいです!