ポリモーフィック関連を活用し、森羅万象の「いいね」を実現する手法

こんにちは!メディアプロダクト開発部マーケティングサービス開発グループ (msdev) のなどやま (@pndcat) です。業務では、クックパッドの広告の開発・運用や、新規サービスの開発をしています。本業の推し活動では、今年の夏はたくさんのイベントに参加するため、推し活動もがんばっていく予定です。

今週は msdev week として Techlife を更新しており、この記事は2日目になります。
1日目は三條さんによる「クックパッドの toB 向け事業における ChatGPT API の活用事例紹介」の投稿でした。
本記事も、メーカーズタウンに関するブログなので、ぜひこちらの記事もご覧ください!

はじめに

この記事では、Kaigi on Rails 2022 森羅万象に「いいね」するためのデータ構造 というタイトルで発表をした、Rails を用いたデータ構造のリファクタリングについて紹介します。ポリモーフィック関連を用いたリファクタリングにより、テーブルやコードの重複を排除し、メンテナンス性や拡張性を向上させることができました。データ構造をリファクタリングする背景や、具体的な手法、そしてなぜその変更を行うことができたのかについてを詳しく説明します。

目次

  • データ構造の初期設計
    • 新機能の「いいね」を実装したい
    • 最初のデータ構造
    • リリースをして1年... どうなったか...
  • ポリモーフィック関連を使ったデータ構造に変更
    • 新しいデータ構造: likes と anonymous_likes
    • リファクタリングの手順
      1. 新旧いいねのテーブルに書き込む (Write)
      2. 旧いいねを新いいねにマイグレーションする (バッチ)
      3. 新いいねを使う (Read)
      4. 旧いいねのモデルとテーブルを削除する (Delete)
  • まとめ

データ構造の初期設計

新機能の「いいね」を実装したい

msdev では、中小の食関連メーカーとユーザーをつなぐコミュニケーションプラットフォーム「メーカーズタウン」を開発しています。メーカーズタウンでは、メーカーはトピックスの投稿や商品の登録を行い、ユーザーはコメントやクチコミを投稿したり、いいねをすることができます。

リリースのタイミングで、「いいね」機能を導入することになりました。要件は、以下の3つでした。

  • 何に「いいね」ができるか? → トピックスと商品
  • 1つの対象に何回「いいね」ができるか? → 1回
  • 「誰が」いいねをすることができるか? → 誰でも (ログインユーザーと、未ログインユーザー)

特に、最後の要件をどう実現するのかについて悩みました。メーカーズタウンでは、ログインユーザーと未ログインユーザーのデータの扱いが異なるため、「いいね」の種類を分ける必要がありました。

ここまでをまとめると、「いいね」は以下の4種類に整理することができます。

  • ログインユーザーの商品のいいね
  • ログインユーザーのトピックスのいいね
  • 未ログインのユーザーの商品のいいね
  • 未ログインのユーザーのトピックスのいいね

最初のデータ構造

データ構造の案として、以下の3つの方法を検討しました。

  • 対象別とユーザー区分別で、4つのテーブルを作成する
  • ポリモーフィック関連
  • STI

結果として、対象別とユーザー区分別のテーブルを作成することに決めました。将来的に「いいね」の種類が増える可能性があったため、ポリモーフィック関連やSTIを用いたテーブル構造を選択することもできましたが、サービスが使用され続ける中で仕様が変わる可能性があるため、必要に応じてデータ構造を改善する方針としました。

リリースをして1年…どうなったか?

1年後、コメント機能やクチコミ機能が増え、「いいね」の対象が4つに増えました。これに伴い、テーブルが8個になったことで2つの問題が生じました。

  • 「いいね」の対象が増えるたびに、テーブル・モデル・コントローラーを毎回追加するのでつらい
  • 新しい対象について、いいね数の集計バッチを追加することを忘れ、集計漏れが発生した

テーブルを毎回追加することはできますが、新しいテーブルが追加されるたびに、集計バッチの修正を行うことを意識するのは難しいことです。

一方で、「いいね」の対象が4つまで増えたことで、「いいね」の仕様はすべて共通であり、今後も「いいね」対象別の振る舞いはなさそうということがわかりました。

ポリモーフィック関連を使ったデータ構造に変更

ポリモーフィック関連は、複数のオブジェクトを関連付ける場合に適しています。今回の場合は、商品、トピックス、コメント、クチコミに「いいね」をつける必要があるため、ポリモーフィック関連を用いて実現することができます *1

サービスを1年間運用した結果、商品、トピックス、コメント、クチコミの「いいね」に関する仕様や振る舞いが共通していることがわかり、ポリモーフィック関連に移行することを決定しました。移行に際して、チームで以下の2つの制約に合意しました。

  • 今後も「いいね」の仕様を変更しない
  • if 文を書くと破綻するため、if 文を絶対に書かない (=異なる振る舞いはさせない) *2

新しいデータ構造:likes と anonymous_likes

新しいデータ構造は、likes (いいね) と anonymous_likes (未ログインのユーザーのいいね) の2種類にまとめました。 ログインユーザーには UserID がありますが、未ログインユーザーには UserID はありません。そこで、初めのデータ構造と同じように、ログインユーザーと、未ログインユーザーは、2種類のテーブルに分けることにしました。anonymous_likes は、UserID の代わりに like_identifier というカラムを作りました *3

今までの product_likes テーブルは product_id, user_id の2カラムでしたが、ポリモーフィック関連を利用すると下図の likes テーブルの likable_id, likable_type, user_id の3カラムで表現します (他の *_likes テーブルも同様)。

では、モデルはどうなるかというと、今までは、4つのモデル (ProductLike、TopicLike、CommentLike、KuchikomiLike) があり、それぞれに対応するテーブルが存在していました。しかし、ポリモーフィック関連を利用することで、1つの Like モデルで表現できるようになりました。現在は対象が4つしかない「いいね」ですが、10個でも100個でも「いいね」の実装ができる、森羅万象の「いいね」のデータモデルが完成しました🌲🌳

※ before / after を見やすくするために、コードを画像にしています。

リファクタリングの手順

データの移行は、以下の手順で行っています。移行手順は特別な手法ではありませんが、Rails のポリモーフィック関連のマイグレーションの一例として、紹介します。

  1. 新旧の「いいね」テーブルに書き込む (Write)
  2. 旧いいねを新いいねにマイグレーション (バッチ)
  3. 新しい「いいね」を使う (Read)
  4. 旧いいねのモデルとテーブルを削除 (Delete)

1. 新旧のいいねテーブルに書き込む (Write)

これは、コントローラーの create メソッドで実行されます。上部分では、既存の ProductLike モデル (旧いいね) を作成し、下部分で Like モデル (新いいね) も作成します。1つのメソッド内で「いいね」がされた場合、新旧のいいねにそれぞれ書き込まれるようにします。

削除も同様に、旧いいねを削除しつつ、新いいねも削除します。ただし、この時点で必ずしも旧いいねと対応する新いいねが存在するわけではありません。Like が見つからないときのために、Safe Navigation Operator の & を destroy の前に書く必要があります。

2. 旧いいねを新いいねにマイグレーションする (バッチ)

次に、データのマイグレーションについて説明します。まず、product_likes テーブルのデータを likes テーブルに合わせたハッシュをつくります。
Rails 6からは、upsert_all メソッドが導入され、ハッシュをそのまま渡すことでデータを生成することができます。バッチを冪等に実行することができるため、今回のようなマイグレーションを行いたい場合は、upsert_all メソッドをぜひ活用してください。

新いいねと、旧いいねの ID (primary key) の順番がばらばらになることが気になるかもしれませんが、likes テーブルは、product_likes や topic_likes などの4つのテーブルを1つにマージするためのテーブルであり、ID の順序は意味を持ちません。重要なのは、「いつ」「誰が」「何に」に対して「いいね」を行ったのかというデータを正しくマイグレーションすることです。ID の順序については気にする必要はありませんが、「いつ」の情報をコピーするために、created_at を忘れないように注意してください。

また、「1. 新旧のいいねテーブルに書き込む」の段階では、旧いいねと対応する新いいねが必ずあるわけではなかったため、新いいねの削除に Safe Navigation Operator を付けていましたが、データ移行が完了したため、Safe Navigation Operator を外します。

3. 新いいねを使う (Read)

新いいねにデータが入ったので、アソシエーションを以下のように変更します。これからは、product.product_likes ではなく、product.likes を使います *4

「新しい対象について、いいね数の集計バッチを追加することを忘れる」という問題もありましたが、今回の変更により、1クエリで「いいね」を合算することができます。

さらに、「ある時刻以降の Like を求める」「あるユーザーの Like を求める」というケースのクエリも簡単に書くことができるようになりました。

4. 旧いいねのモデルとテーブルを削除 (Delete)

すべてのコードで新いいねを参照したら、旧いいねの書き込みを削除します。最後に、旧いいねのモデルとテーブルを削除することができたら、ポリモーフィック関連への移行が完了です 🎉

まとめ

本ブログでは、似ているけどちょっと違う「いいね」の設計の紹介をしました。最初は、変更に耐えられるようなデータ構造にし、実装が複雑になったタイミングで、データ構造を見直しました。今回は、ポリモーフィック関連へのリファクタリングをしました。

ポリモーフィック関連を適用できるケースは多くないと思いますが、場合によってはとても強力なデータ構造の手法であり、特に Rails ではフレームワークレベルでのポリモーフィック関連付けの支援があるため、テーブルやコード量を減らし、かつ、わかりやすいコードに置き換えることが可能です。
ポリモーフィック関連を利用したことがない人や、ポリモーフィック関連は SQL アンチパターンでよく挙げられているから抵抗があるという人も多いと思いますが、今回のブログを通して、今後のデータ構造でポリモーフィック関連を選択肢の一つに入れてもらえると嬉しいと思います。

クックパッドでは、toB 事業をやりたい!データ構造の話をたくさんしたい!リファクタリングに興味がある!というエンジニアを募集しています。以下のリンクからのご応募をお待ちしています!

cookpad.careers

*1:ポリモーフィック関連の具体的な実装に関しては、Railsガイド を参照してください

*2:Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしました の「ポリモーフィック関連で if 文を書くと破綻するの例は?」 に詳細を書いています

*3:Techlife: Kaigi on Rails 2022 にて『森羅万象に「いいね」するためのデータ構造』の発表をしました の「匿名いいねの like_identifier ってなに?」を詳細を書いています

*4:grep をしやすいように、product.likes ではなく product.product_likes の書き方をしていました。product_likes は likes に書き換える必要があります