💬

[Next.js]firestoreを使っていいね機能を実装する

2022/02/18に公開

Next.js と firestore で簡易ないいね機能を実装したのでその時のメモ。
カウントなどの正確性よりもコスト面と実装面を重視しているのでご注意ください。

このいいね機能で出来ないこと

  • 誰が誰のいいねをしたかを辿ること。
  • いいねされたユーザーのいいね全体カウント数を計算すること。

どちらも追加実装で可能かもしれないが、その場合は素直にいいねしたユーザー id といいねした記事 id を 1 つずつ格納するタイプに切り替えたほうがいいと思われる。

サブコレクションの設計

記事を格納しているpostsコレクションとは別にサブコレクションlikesを作成。
likes直下に以下のドキュメントを持たせる。

likes

type LikesType = {
  likedUsers: string[];
};

ドキュメント id は記事 id と共通化する。
likedUsersはいいねをしたユーザー id を格納する。
いいねの数はlikedUsers.lengthで取得する。

posts

type PostsType = {
  ...
  title: string
  body: string
  likes: LikesType  // ここ
  ...
}

記事を格納しているpostslikes を格納できる場所を作っておく。

仕組み

いいねされると配列に uid を格納し、likesコレクションに登録。
登録すると functions 側でトリガー関数が発動し、postsコレクションにサブコレクションの内容をアップデートする。

functions トリガー関数

likesコレクションの write がトリガーされた時に発動する。
postsと共通 id なので、snapshot から id を取得できる。
素直にコレクション設計に postId を含めても良いと思う。

setDocument()は当該postsのデータに firestore のsetDoc()関数にて{merge:true}で上書きし、likes だけをアップデートしている。

const Ref = functions.firestore.document("likes/{likeID}");

export const onUpdate = Ref.onUpdate(async (change, context) => {
  const likes = snap.data();
  const setData = {
    likes: likes,
  };
  await setDocument(db, setData, "posts", change.after.id);
});

firestore rules

バリデーションを Rules で済ませている。
サイズの確認、key が全て存在し、likedUsers が配列。
更新の場合は前回のカウント数より 1 のみ多い、かつリクエストしてきたユーザーの id が likedUsers に含んでいる。
または、前回のカウント数より 1 のみ少ない場合を許可条件とした。

作成の場合はカウント数が 1 である事、
更新時と同じくリクエストしてきたユーザーの id が likedUsers に含んでいる事を許可条件としている。

match /likes/{likeID} {
  function requestUserId() {
    return request.auth.uid;
  }
  function getLikes(likeID) {
    return get(/databases/$(database)/documents/likes/$(likeID)).data
  }
  function hasLikes(likeID) {
    return exists(/databases/$(database)/documents/likes/$(likeID))
  }
  function isValidLikesData(data, likeID) {
    let dataKeys = data.keys();
    let beforeCount = getLikes(likeID).likedUsers.size();
    let count = data.likedUsers.size();
    return dataKeys.size() == 1 &&
      ['likedUsers'].hasAll(dataKeys) &&
      data.likedUsers is list &&
      (
        (
          hasLikes(likeID) &&
          (
            (
              beforeCount + 1) == count &&
              data.likedUsers.hasAny([requestUserId()]
            ) ||
            (beforeCount - 1) == count
          )
        ) ||
        (
          count == 1 &&
          data.likedUsers.hasAny([requestUserId()])
        )
      );
  }
  allow read;
  allow write: if isAuthenticated() &&
    isValidLikesData(incomingData(), likeID);
}

フロント面の実装

postsドキュメントに含まれるlikesから、いいねしたユーザー id を格納する配列を読み込む。
そしてその数を表示する。

問題はログインユーザーがいいねしている時とそうでない時で計算が異なること。
既に閲覧者がいいねしている場合は、いいねボタンをいいね済みに変更し、また押した時の処理も変更が必要。

let result = 0;
const count = likes.likedUsers.length;

// 前回いいねしていたか
if (beforeIsLiked) {
  result = isLiked ? count : count - 1;
} else {
  result = isLiked ? count + 1 : count;
}

上記のような形で計算する。

const [isLiked, setIsLiked] = useState<boolean>(beforeIsLiked);
return isLiked ? <Liked /> : <NotLiked />;

ボタンは isLikedのフラグを立て、それに沿ってアイコンを変更するのみ。

問題点と注意点

user id 検証

user id の検証は deliked な場合は不可能な為、問題があるかもしれない。
この問題を解決したい場合、ログインユーザーの id を含むコレクションパス(/users/$(userID)/likes/$(likeID)のような感じ)で行えば良い。
こうする事で、ユーザーが誰にいいねしたのか、という機能も追加できるようになる。

ただ、この場合 public ではなくなるので、別途 public 用コピーコレクションを作る必要性が出る。
実装コストをどこまで許容するかによって判断を変更する事も可能だと思う。

更新頻度の問題

また、注意点としては、同時にいいねされた場合に firestore の 1 秒に 1 回以上の更新が出来ない規則に引っかかって失敗する可能性がある。
これを解決するには分散カウンタと呼ばれる実装が必要になる。

ただし、Firestore の分散カウンタにも限界があり、非常に負荷の高い状態に陥った場合にデータの一貫性が保てないらしい。
Firestore のトランザクション分離レベルは整合性に難があるREAD COMMITTEDであるとのこと。
https://qiita.com/1amageek/items/2eff436fb69bea5875ea

つまりアプリの人気が一定以上に到達すると、実装面で補えないので firebase から移行する選択肢が入ってくるという事なのだろう。

Discussion