👍

Next.jsとFirestoreを用いたmicroCMS記事の「いいね」機能の設計と実装

2023/07/11に公開
6

この記事の概要

本記事では、microCMSとFirebaseを組み合わせて、ユーザーが記事に対して「いいね」をする機能の設計と実装について書いてあります。

はじめに

いいね機能の概要

「いいね」機能は、ユーザーがmicroCMSから取得した記事に対して好意的な評価を行うことができる機能です。ログインしたユーザーが特定の記事に対して「いいね」ボタンをクリックすることで、評価が行われます。このアクションによって、Firestoreに保存されている記事(Post)データと、そのPostのサブコレクション(LikedUser)に対してデータを保存・更新・削除します。

技術スタック

技術スタック 使用目的
Next.js UI実装 (React, TypeScript, Next.jsで作っています)
Firebase Authentication ログイン認証
Firebase Cloud Firestore データベース
Firebase Cloud Storage 画像アップロード
React Hook form フォームUI作成
zod バリデーション実装
microCMS 記事データ管理

この記事では紹介してないものも載せてます。

既に実装済みなとこは省略

簡単に説明すると、Vercel上のNext.js製のWebアプリを作り、CMSはmicroCMSを利用してAPI経由でデータ(記事)を取得・表示させたWebアプリです。今回は「いいね機能」だけにフォーカスしているのでそれ以外の実装済みの部分は割愛しています。

https://zenn.dev/ryosuketter/scraps/cf116e7c28cedb

https://zenn.dev/ryosuketter/scraps/79b52460dc527b

記事に関する処理や表示に関して

簡単に説明します

  • microCMSを使ってAPI経由でデータを取得
  • 主に記事の一覧ページと詳細ページがある構成
  • getStaticPathsとgetStaticPropsをつかってSGしてます
  • ルーティングはDynamic Routingを用いています

参考

https://blog.microcms.io/microcms-next-jamstack-blog/

ディレクトリ

「いいね」機能を実装するにあたって、関連するディレクトリとファイルを以下に示します。

ディレクトリ構成の図解
.
├── src
│   ├── components
│   │   ├── Auth
│   │   │   ├── index.tsx
│   │   │   └── style.module.scss
│   │   ├── Blog
│   │   │   ├── LikeButton
│   │   │   └── index.tsx
│   ├── features
│   │   ├── hooks
│   │   │   ├── useLogin.ts
│   │   │   └── useUpdateUser.ts
│   ├── lib
│   │   ├── client.ts
│   │   ├── firebase
│   │   │   ├── auth.ts
│   │   │   └── client.ts
│   ├── pages
│   │   ├── blog
│   │   │   ├── [slug].tsx
│   │   │   └── index.tsx
│   │   ├── index.tsx
│   │   └── user
│   │       └── index.tsx
│   └── types
│       ├── post.ts
│       ├── projects.ts
│       └── user.ts

  • components/Blog/LikeButton: 「いいね」ボタンに関連するコンポーネント
  • components/Auth: ユーザー認証に関わる
  • features/stores/context/auth.tsx: ユーザーの認証状態を管理
  • lib/firebase: Firebaseと連携処理
  • types/: 型定義

Firestoreの設計

Firestoreは、データの組織化と管理を簡単に行うための柔軟なデータベースシステムです。今回は、Firestoreを使用して「いいね」機能を含むアプリケーションの設計について解説します。

設計概要

ベースとなるコレクション

アプリケーションの核となるのは、Firestoreの2つの主要なコレクションである「ユーザー(User)」と「記事(Post)」です。これらはそれぞれ、ユーザーの詳細情報や公開されている記事などのデータを保持しています。

「いいね」機能の実装

各ユーザーが特定の記事に「いいね」をした際の情報を保存する方法について考えます。

サブコレクションを利用したデータ管理

「いいね」の情報を、各「記事(Post)」ドキュメントのサブコレクション(LikedUser)として持つことで、この機能を実現します。

同様に、「ユーザー(User)」ドキュメントのサブコレクション「likePosts」には、各ユーザーが「いいね」をした記事の情報が保存されています。

「いいね」したユーザーの情報

「いいね」したユーザーの情報(LikedUser)を保存します。この情報には、「UID(ユーザーID)」、「アイコン」、「名前」など、そのユーザーを識別するための情報が含まれます。この情報を保持することで、「いいね」したユーザーの詳細を追跡することが可能になります。

この設計のデメリットとその解決策

デメリットはデータの整合性が取れない可能性があることです。

整合性が取れない可能性がある

microCMSとFirebaseの間にデータの整合性が取れないリスクが存在します。しかし、microCMSの記事IDを変更させないという運用ルールを設けることで、このリスクは抑えられます。

また、記事IDが変更される可能性がある場合は、microCMSとFirebaseの同期処理を実装することで、この問題を解決することができます。

microCMSとFirebaseの同期処理は例えば以下の方法があります。

  • Cloud Functionsを使用する方法
  • microCMSのWebhookを使用する方法

同期処理は一般にコストと複雑さが伴うため、必要に応じて同期の頻度や範囲を制限するなど、プロジェクトの要件とリソースに応じて適切な戦略を選択することが重要です。

同期処理以外の方法

適切なセキュリティルールを設定し、クライアントサイドジョインを利用したデータ取得を行えば、複雑な同期処理を避けつつデータの一貫性を保つ実装も可能です。ただし、パフォーマンスへの影響は考慮したいところです。

データの整合性やセキュリティリスクに配慮し、再設計・実装したスクラップもあるので参考にしてください。

https://zenn.dev/ryosuketter/scraps/9c2a3697e1cdbb

実装

「いいね」をクリックしたらLikedUserに保存、再度クリックしたらLikedUserから該当データを削除

UIの作成

ハートマークのFont Awesomeのアイコンを使います。

yarn add @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons @fortawesome/fontawesome-svg-core
追加される
package.json
...
  "dependencies": {
    "@fortawesome/fontawesome-svg-core": "^6.4.0",
    "@fortawesome/free-solid-svg-icons": "^6.4.0",
    "@fortawesome/react-fontawesome": "^0.2.0",
...
  • @fortawesome/react-fontawesome: ReactでFont Awesomeのアイコンを使うためのコンポーネント
  • @fortawesome/free-solid-svg-icons: さまざまなアイコンを含むFont Awesomeのパッケージです。これはソリッド(塗りつぶされた)スタイルのアイコンを提供
  • @fortawesome/fontawesome-svg-core: Font Awesomeの中核となるライブラリで、基本的な機能と設定を提供
追加する処理やUI
src/components/Blog/LikeButton/index.tsx
import { faHeart } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { deleteDoc, doc, getDoc, onSnapshot, setDoc } from 'firebase/firestore'
import { FC, useCallback, useEffect, useState } from 'react'

import { db } from '@/lib/firebase/client'
import { auth } from '@/lib/firebase/client'

import styles from './style.module.scss'

interface LikeButtonProps {
  postId: string
}

export const LikeButton: FC<LikeButtonProps> = ({ postId }) => {
  // ユーザーIDの取得
  const userId = auth.currentUser?.uid
  // 「いいね」の状態管理
  const [isLiked, setIsLiked] = useState<boolean | null>(null)

  // 投稿の「いいね」状態の監視
  useEffect(() => {
    if (!userId) return

    const postRef = doc(db, 'posts', postId)
    const likedUserRef = doc(postRef, 'LikedUsers', userId)

    const unsubscribeLikedUser = onSnapshot(likedUserRef, (doc) => {
      setIsLiked(doc.exists())
    })

    return () => {
      unsubscribeLikedUser()
    }
  }, [userId, postId])

  // 「いいね」ボタンのクリックイベント
  const handleClick = useCallback(async () => {
    if (!userId || isLiked === null) return

    const postRef = doc(db, 'posts', postId)
    const likedUserRef = doc(postRef, 'LikedUsers', userId)

    const userDoc = doc(db, 'users', userId)
    const userSnapshot = await getDoc(userDoc)
    const userData = userSnapshot.data()
    const lastName = userData?.lastName

    const userLikePostRef = doc(userDoc, 'likePosts', postId)

    if (isLiked) {
      await deleteDoc(likedUserRef)
      await deleteDoc(userLikePostRef)
    } else {
      await setDoc(likedUserRef, { userId, lastName })
      await setDoc(userLikePostRef, { slug: postId })
    }
  }, [userId, postId, isLiked])

  // レンダリング
  if (!userId) return null
  if (isLiked === null) return null

  return (
    <div className={styles.buttonWrapper}>
      <button onClick={handleClick} className={styles.likeButton}>
        <FontAwesomeIcon icon={faHeart} color={isLiked ? 'red' : 'gray'} size="2x" />
      </button>
    </div>
  )
}
追加するCSS
src/components/Blog/LikeButton/style.module.scss
.buttonWrapper {
  margin-bottom: var(--spacing-xs); // 下側に余白を追加
}

.likeButton {
  border: none;      // ボタンのボーダーを削除
  background: none;  // ボタンの背景を削除
  cursor: pointer;   // マウスカーソルがこのボタン上に来たとき、カーソルをポインターに変更
}

いいねボタンをクリックすれば、該当するユーザーの likedUsers が生成され、再度クリックしたら削除されます。

用いたFirebaseのメソッド

  • deleteDoc: 指定したドキュメントの削除
  • doc: ドキュメントへの参照を取得する
  • getDoc: 指定したドキュメントのデータを取得
  • onSnapshot: ドキュメントのリアルタイムアップデートの監視用
    • ドキュメントへの参照を引数として受け取り、ドキュメントの変更があるたびに呼び出されるコールバック関数を設定できる
  • setDoc: ドキュメントにデータをセット(書き込む)するために使用
    • この操作は非同期で行われ、完了時にPromiseを返します

https://firebase.google.com/docs/reference/js/firestore_?hl=ja#functions

処理

ユーザーIDの取得

Firebase Authから現在ログインしているユーザーのIDを取得します。

「いいね」の状態管理

ユーザーが投稿を「いいね」したかどうかを管理しています。

投稿の「いいね」状態の監視

  • useEffectを使用して、ユーザーが投稿を「いいね」したかどうかを監視
  • onSnapshotを用いて、ドキュメントが変更されるたびにコールバック関数を呼び出す
    • ドキュメントが存在すればisLikedをtrueに設定し、存在しなければfalseに設定させている
  • useEffectのクリーンアップ関数(unsubscribeLikedUser)
    • コンポーネントがアンマウントされる際に、onSnapshotによって設定されたリスナーをクリーンアップしています
      • 理由:不要なメモリリークを防ぎ、パフォーマンスを最適化するため

「いいね」ボタンのクリックイベント

  • 「いいね」ボタンがクリックされると、handleClick関数が実行
  • まずuserIdが存在するかどうかをチェック
    • 次に、投稿とユーザーの参照を作成
    • そして、isLikedの状態に基づいて、「いいね」のデータを追加または削除する
      • isLikedがtrueであれば、deleteDocでドキュメントを削除
      • isLikedがfalseであれば、setDocで新しくドキュメントを作成

レンダリング

ユーザーがログインしており、isLikedがnullでない場合にのみ、コンポーネントをレンダリングします。このチェックにより、必要なデータが利用可能でないときにエラーが発生するのを防ぎます。

Firebastoreのセキュリティルール

コードの中でFirestoreに対する操作が行われています。

ユーザーがいいねを追加または削除する操作

    const handleClick = async () => {
    ...
    const userLikePostRef = doc(userDoc, 'likePosts', postId)
    ...
    if (isLiked) {
      await deleteDoc(likedUserRef)
      await deleteDoc(userLikePostRef)
    } else {
      await setDoc(likedUserRef, { userId, lastName })
      await setDoc(userLikePostRef, { slug: postId })
    }
  }

これらの操作はセキュリティルールの次の部分に対応しています

match /users/{uid} {
  ...
  match /likePosts/{postId} {
    allow read, write: if request.auth != null && request.auth.uid == uid;
  }
}

これはユーザーが自分自身のlikePostsサブコレクションを読み書きできることを意味します。

handleClick関数内では、setDocとdeleteDocを用いてこのコレクションにドキュメントを追加または削除しています。

投稿が「いいね」されたかどうかを確認する操作

  useEffect(() => {
    ...
    const unsubscribeLikedUser = onSnapshot(likedUserRef, (doc) => {
      setIsLiked(doc.exists())
    })
    ...
  }, [userId, postId])

この操作はセキュリティルールの次の部分に対応しています。

match /posts/{postId} {
  ...
  match /LikedUsers/{userId} {
    allow read: if request.auth != null;
    allow read, write: if request.auth != null && request.auth.uid == userId;
  }
}

これは全てのユーザーが任意の投稿のいいね情報(LikedUsersサブコレクション)を読むことができ、自分自身の情報を読み書きできることを意味します。useEffect内では、onSnapshotを用いて投稿がいいねされているかどうかをリアルタイムに監視しています。

「いいね」数の表示

UIの作成

src/components/Blog/LikeButton/index.tsx
src/components/Blog/LikeButton/index.tsx
import { faHeart } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+ import { collection, deleteDoc, doc, getDoc, onSnapshot, setDoc } from 'firebase/firestore'
import { FC, useCallback, useEffect, useState } from 'react'

import { db } from '@/lib/firebase/client'
import { auth } from '@/lib/firebase/client'

import styles from './style.module.scss'

interface LikeButtonProps {
  postId: string
}

export const LikeButton: FC<LikeButtonProps> = ({ postId }) => {
  const userId = auth.currentUser?.uid

  const [isLiked, setIsLiked] = useState<boolean | null>(null)
  // 「いいね」数の状態管理
+ const [likeCount, setLikeCount] = useState<number>(0)

  useEffect(() => {
    if (!userId) return

    const postRef = doc(db, 'posts', postId)
    const likedUserRef = doc(postRef, 'LikedUsers', userId)

    const unsubscribeLikedUser = onSnapshot(likedUserRef, (doc) => {
      setIsLiked(doc.exists())
    })
    
        // 「いいね」数の監視 & データ更新
+   const likedUsersRef = collection(db, `posts/${postId}/LikedUsers`)
+   const unsubscribeLikedCount = onSnapshot(likedUsersRef, (snapshot) => {
+     setLikeCount(snapshot.size)
+   })

    return () => {
      unsubscribeLikedUser()
+     unsubscribeLikedCount()
    }
  }, [userId, postId])

  const handleClick = useCallback(async () => {
    if (!userId || isLiked === null) return

    const postRef = doc(db, 'posts', postId)
    const likedUserRef = doc(postRef, 'LikedUsers', userId)

    const userDoc = doc(db, 'users', userId)
    const userSnapshot = await getDoc(userDoc)
    const userData = userSnapshot.data()
    const lastName = userData?.lastName

    const userLikePostRef = doc(userDoc, 'likePosts', postId)

    if (isLiked) {
      await deleteDoc(likedUserRef)
      await deleteDoc(userLikePostRef)
    } else {
      await setDoc(likedUserRef, { userId, lastName })
      await setDoc(userLikePostRef, { slug: postId })
    }
  }, [userId, postId, isLiked])

  if (!userId) return null
  if (isLiked === null) return null

  return (
    <div className={styles.buttonWrapper}>
      <button onClick={handleClick} className={styles.likeButton}>
        <FontAwesomeIcon icon={faHeart} color={isLiked ? 'red' : 'gray'} size="2x" />
+       <p>{likeCount}</p> {/* likeCountを表示します。 */}
      </button>
    </div>
  )
}

用いたFirebaseのメソッド

collection: このメソッドはFirestoreのコレクションへの参照を取得するために使用されます。引数としてコレクションのパスを受け取り、そのコレクションへの参照(CollectionReference)を返します。参照を使うと、そのコレクション内のドキュメントにアクセスしたり、新たなドキュメントを追加したり、コレクションに対するクエリを実行したりできます。

https://firebase.google.com/docs/reference/js/firestore_?hl=ja#functions

処理

Firestoreのcollection関数を使って、「いいね」されたユーザーのリストを参照するためのlikedUsersRefを作成しています。

const likedUsersRef = collection(db, `posts/${postId}/LikedUsers`)

ここで指定されるパスは、投稿IDを含むため、特定の投稿に対する「いいね」のリストを参照します。

他の記述は、前に書いた処理から推測できると思うので割愛します。

Firebastoreのセキュリティルール

追加されたコードの一部は「いいね」の総数をリアルタイムに監視し、表示する機能を持っています。

  const likedUsersRef = collection(db, `posts/${postId}/LikedUsers`)
  const unsubscribeLikedCount = onSnapshot(likedUsersRef, (snapshot) => {
    setLikeCount(snapshot.size)
  })

ここで行われている操作は、「いいね」情報(LikedUsersサブコレクション)を読むことです。

これはFirestoreのセキュリティルールの次の部分に対応しています。

match /posts/{postId} {
  ...
  match /LikedUsers/{userId} {
    // 全てのユーザーがいいね情報を読むことを許可
    allow read: if request.auth != null;
  }
}

このルールは全てのユーザーがいいね情報を読むことを許可しています。上記のコードは、各投稿のLikedUsersサブコレクションのサイズ(すなわち、「いいね」の総数)を取得し、それをlikeCountステートに設定しています。

<p>{likeCount}</p> // likeCountを表示します。

「いいね」機能をどう活用するか

今の所、主に以下の2つの観点から利用する想定で開発しました。

  • 自分が「いいね」した記事をリストとして表示
    • これにより、過去に気に入った記事を参照することが可能になります
  • 記事を「いいね」したユーザーをリストとして表示
    • これにより、どのユーザーがその記事を評価したのかを確認できます

これらの利用法を実装するためには、適切なデータ取得方法を用いることが重要です。

データ取得の実装例

自分が「いいね」した記事をリストとして表示

const getLikedPosts = async (userId: string): Promise<string[]> => {
  // 特定のユーザーのドキュメントを取得
  const userDoc = doc(db, 'users', userId)
  // 特定ユーザーの「いいね」した投稿のコレクションを取得
  const likedPostsCollection = collection(userDoc, 'likePosts')
  // 取得したコレクションの全ドキュメントのスナップショットを非同期に取得
  // この関数はPromiseを返すため、awaitキーワードを使用して非同期処理を待つ
  const likedPostsSnapshot = await getDocs(likedPostsCollection)
  // ユーザーが「いいね」したすべての投稿のslugの配列を作成
  const likedPosts = likedPostsSnapshot.docs.map((doc) => doc.data().slug)
  return likedPosts
}

上記の getLikedPosts 関数で取得したslugの配列を基に、microCMSのAPI経由で記事データを取得します。このとき、microCMSのAPIは一度のリクエストで一つのslugに対応する記事データのみを取得できる設計となっているため、複数のslugに対応する記事データを取得するためには、各slugごとに個別にAPIリクエストを行う必要があります。

https://document.microcms.io/content-api/get-list-contents

そのため、各slugに対してAPIリクエストを並列に行うために、Promise.all を活用します。

コードの例
const fetchPostData = async (slug: string): Promise<PostData> => {
  const res = await fetch(`https://example.microcms.io/api/v1/posts/${slug}`)
  const postData = await res.json()
  return postData
}

const fetchLikedPostsData = async (likedSlugs: string[]): Promise<PostData[]> => {
  const requests = likedSlugs.map(fetchPostData) // 各slugに対するリクエストを作成
  const postsData = await Promise.all(requests) // 全リクエストを並列に処理
  return postsData
}

記事を「いいね」したユーザーをリストとして表示

// 仮
interface LikedUserData {
  userId: string;
  lastName: string;
}

const fetchLikedUsers = async (postId: string): Promise<LikedUserData[]> => {
  // 特定の投稿(postId)の「いいね」したユーザー(LikedUsers)のコレクションへの参照を取得
  const likedUsersCollection = collection(db, `posts/${postId}/LikedUsers`)
  // 「いいね」したユーザー全員を取得
  // この関数はPromiseを返すため、awaitキーワードを使用して非同期処理を待つ
  const querySnapshot = await getDocs(likedUsersCollection)
  // 特定の投稿(postId)に「いいね」したユーザー全員を取得
  const likedUsers = querySnapshot.docs.map((doc) => doc.data())
  return likedUsers
}

今後の改善点と検討事項

その他の改善点

本記事では、「いいね」機能の実装方法を簡単に示しましたが、実際のプロダクション環境で使用するにはさらに改善と考慮が必要です。

  • 関数分離 現状のコードは一部分に集中していますが、これを分散させることで、コードの可読性が向上し、テストが容易になります
  • エラーハンドリングの追加 本記事ではエラーハンドリングについては触れていませんが、実際のアプリケーションでは、ネットワークエラーやデータベースエラーなど、非同期操作で発生する可能性のあるエラーを適切に処理する必要があります
  • ユーザーフィードバックの改善 現状、「いいね」ボタンがクリックされたときに、ユーザーへのフィードバック処理が存在しません。ユーザー体験を向上させるためには、ローディングスピナーの表示や、操作が成功したかどうかの通知などが必要になる場合があります
  • テストコードの追加 本記事ではテストコードについては触れていませんが、テストコードの作成は必要です。しかし、必要な振る舞いをモック化するとボリュームが大きくなるため、この記事では割愛します

より実用的な「いいね」機能を開発するための参考にしていただければ幸いです。

chot Inc. tech blog

Discussion

aliyomealiyome

素人質問で恐縮ですがいいね数を取得する部分はコレクション全部を一度取得してその数を数えている、という理解で合っているでしょうか?例えばサービスが成長して、1000件のいいねが付いたときにどういう動作をするんだろう?と気になりました。

志水 亮介 (Ryosuke Shimizu)志水 亮介 (Ryosuke Shimizu)

ありがとうございます。

質問1

いいね数を取得する部分はコレクション全部を一度取得してその数を数えている、という理解で合っているでしょうか?

合っていると思います。

質問2

1000件のいいねが付いたときの動作について

仮に1,000件「いいね」が付いた場合でも、Firestoreの動作は、それら全てのドキュメントを一度に取得します。

補足(コスト面とパフォーマンス面が課題になります)

Firestoreの読み取りの料金は取得したドキュメント数に基づいているため、大量のドキュメントを取得すると料金が高くなります。

https://firebase.google.com/docs/firestore/pricing?hl=ja

また、パフォーマンスにも影響を与えると思います。

kontikunkontikun

あと、いいねで2つのドキュメントを保存することになっているので、バッチを使ったトランザクションを採用した方が安全だと思います。

const batch = writeBatch(getFirestore())
batch.delete(likedUserRef)
batch.delete(userLikePostRef)
await batch.commit()

こうしておくと、commitが実行されない限り片方だけ保存されるみたいなことが起きません。

バラバラで書いて申し訳ありません!