Cloud Firestoreで「いいね」機能を実装するときの勘所

Tuesday, December 31, 2019

何かしらのサービスを作る際に、ユーザー同士のコミュニケーションを促進させる機能の一つに、
TwitterやInstagramなどのサービスでおなじみの「いいね」機能があります。 機能としては

  • 投稿等に「いいね」をつけることができる(♡だったり☆だったりシンボルは様々)
  • 「いいね」がどれだけついたか、その数がわかる
    • (最近だとInstagramがいいねの数の表示をなくしましたね🤔)
  • 投稿等に「いいね」をしたユーザーの一覧が見れる
  • 自分が「いいね」をした投稿等の一覧が見れる

といったものが挙げられます。

このような機能ををFirebaseCloud Firestore(以下Firestore) を使って実装する場合、
どのように実装するのか、どのような設計が良いのか、
逆にどのような設計だとまずいのか、セキュリティルールをどう書くべきか、、 深堀りして書いてみようと思います。やや長めの記事になります。

また、記事の後半でも改めてお伝えしますが、この記事で触れている内容は

  • フォロー・フォロワー機能
  • 友達機能

といった機能にも応用可能です。

前置き

基本的にはJavaScript(TypeScript)でコード例を出します。

説明にあたって登場するモデル

今回はユーザーがコンテンツを投稿し、それを他のユーザーが「いいね」をつけることができ、
「いいね」のついた数がわかり、投稿に「いいね」したユーザーの一覧及びユーザーが「いいね」した投稿の一覧がみれる機能を想定し、 次のようなモデルを定義します。説明をシンプルにするために、最低限のフィールドのみ持たせることにします。(非公開機能的なものは想定から外します)

// path: /users/{user_id}

interface User {
  name: string // ユーザー名
  createTime: Timestamp // モデルの作成日時
  updateTime: Timestamp // モデルの更新日時
  likePostCount: number // いいねした投稿の数
}
// path: /users/{user_id}/posts/{post_id}
interface Post {
  title: string // タイトル
  body: string // 本文
  author: DocumentReference // 投稿者(User)のDocumentの参照
  createTime: Timestamp // モデルの作成日時
  updateTime: Timestamp // モデルの更新日時
  likeCount: number // 投稿にいいねしたユーザーの数
}
// path: /users/{user_id}/posts/{post_id}/likedUsers/{liked_user_id}
interface LikedUser {
  id: string // いいねをつけたユーザーのID(liked_user_idと一致)
  createTime: ServerTimestamp
}
// path: /users/{user_id}/likedPosts/{liked_post_id}
interface LikedPost {
  id: string // 自分がいいねをつけた投稿のID(liked_post_idと一致)
  postRef: DocumentReference
  createTime: ServerTimestamp
}

留意すべき点は、

  • PostUserドキュメントのサブコレクション
  • LikePostUserドキュメントのサブコレクション
  • LikeUserPostドキュメントのサブコレクション

です。サブコレクションを活用して階層化しています。 図に示すと次のようになります。

以前はなるべくフラットに階層浅く定義するほうが良いケースが多かったのですが、現在ではCollection-Groupクエリもあり、 サブコレクションを活用しやすくなりました。

ユーザーを作成する・投稿する

この後の流れをわかりやすくするために順を追って説明していきます。 まずは何かしらの認証方法にてユーザーをサービスにログインさせ、auth.user.uidをドキュメントIDとするUserドキュメントを作成します。

  • ユーザーの作成
await auth.signInAnonymously()

const uid = auth.currentUser.uid

const userRef = firestore.collection('userd').doc(uid)

await userRef.set({
  name: 'John',
  createTime: FieldValue.serverTimestamp(),
  updateTime: FieldValue.serverTimestamp(),
  likePostCount: 0
})
  • 投稿する
const postRef = userRef.collection('posts').doc()

await postRef.set({
  title: 'How to use Cloud Firestore',
  body: '...',
  author: userRef.path,
  createTime: FieldValue.serverTimestamp(),
  updateTime: FieldValue.serverTimestamp(),
  likeCount: 0
})

ちなみに、今回はUserコレクションのサブコレクションとしてPostドキュメントを生成しているので、
ユーザー横断で全てのPostドキュメントを取得する場合は、collectionGroupクエリを使います。

// 投稿した時刻の降順で、横断的にPostドキュメントの一覧を取得する
const postsSnapshot = await firestore.collectionGroup('posts')
  .orderBy('createTime', 'desc')
  .get()

より複雑なクエリで投稿の一覧を取得したい場合は、本記事では触れませんが、Algoliaの使用も検討しても良いでしょう。

簡単に、ここまでの状態でのセキュリティールールを次に示します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userID} {
      allow read: if isAuthenticated();
      allow create:
        if isUserAuthenticated(userID)
        && validateName()
        && incomingData().likePostCount == 0
        && isRequestedTime(incomingData().createTime)
        && isRequestedTime(incomingData().updateTime);
      }

      function validateName() {
        return validateString(incomingData().name, 1, 16);
      }

      match /posts/{postID} {
        allow read: if isAuthenticated();
        allow create:
          if isUserAuthenticated(userID)
          && validateTitle()
          && validateBody()
          && incomingData().author == documentPath(['users', userID])
          && incomingData().likeCount == 0
          && isRequestedTime(incomingData().createTime)
          && isRequestedTime(incomingData().updateTime);

        function validateTitle() {
          return validateString(incomingData().title, 1, 25);
        }

        function validateBody() {
          return validateString(incomingData().body, 1, 1000);
        }
      }
    }
  
    match /{path=**}/posts/{postID} {
      allow list: if isAuthenticated();
    }

    function documentPath(paths) {
      return path([
        ['databases', database, 'documents'].join('/'),
        paths.join('/')
      ].join('/'));
    }

    function isAuthenticated() {
      return request.auth != null;
    }

    function isUserAuthenticated(userID) {
      return request.auth.uid == userID;
    }

    function isRequestedTime(time) {
      return time == request.time;
    }

    function incomingData() {
      return request.resource.data;
    }

    function validateString(text, min, max) {
      return text is string
        && min <= text.size()
        && text.size() <= max;
    }
  }
}

(余談)今回セキュリティルールで使う便利関数たち

今回の説明の中で出てくる、セキュリティルールで使う便利関数たちを紹介しておきます。
普段僕が携わっているプロジェクトでもこれらの関数をよく使っています。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // stringの配列からドキュメントのpathを作る
    function documentPath(paths) {
      return path([
        ['databases', database, 'documents'].join('/'),
        paths.join('/')
      ].join('/'));
    }

    // ユーザーが認証済みかどうか
    function isAuthenticated() {
      return request.auth != null;
    }

    // 認証済みユーザのuidとuserIDが一致するかどうか
    function isUserAuthenticated(userID) {
      return request.auth.uid == userID;
    }

    // request.resource.dataを返す
    function incomingData() {
      return request.resource.data;
    }

    // resource.dataを返す
    function existingData() {
      return resource.data;
    }

    // get関数で得られた結果のうち、dataのみを返す
    function getData(path) {
      return get(path).data;
    }

    // getAfterで得られた結果のうち、dataのみを返す
    function getAfterData(path) {
      return getAfter(path).data;
    }

    // timestamp型の変数の値が、オペレーション実行時の時刻と一致するかどうか
    // (FieldValue.serverTimestamp()を使うことで一致する)
    function isRequestedTime(time) {
      return time == request.time;
    }

    // 文字列のバリデーション。型チェックと文字数チェックを行う
    function validateString(text, min, max) {
      return text is string
        && min <= text.size()
        && text.size() <= max;
    }

    // updateのルール内で、変更前後で値が変わらないかどうか
    function isNotChanged(key) {
      return incomingData()[key] == existingData()[key];
    }

    // updateのルール内で、変更前後で指定したdataのフィールドの値がいくつ増えたかをチェックする
    function isIncremented(after, before, key, number) {
      return after[key] == before[key] + number;
    }

    function isIncrementedField(key, number) {
      return isIncremented(incomingData(), existingData(), key, number);
    }
  }
}

セキュリティルールはVSCodeの拡張機能があるものの、typoしやすかったり、似たような処理を書くことが多くなるので、 関数を定義して再利用できるようにしておくと、スッキリ書くことができます。

投稿にいいねをつける

ここからが本題です。他のユーザーが投稿したコンテンツにいいねをつける機能を設計、実装していきます。

リストかサブコレクションか

投稿に「いいね」をつける機能を実装するとなると、その操作を行ったことがわかるデータをリストとして保持する必要が出てきます。

Firestoreを使う場合は、ドキュメントのフィールドにリストとしてデータを持たせるか、ドキュメントのサブコレクションとしてデータを持たせることで実現することができます。
ドキュメントのフィールドにリストをもたせる場合はこのようになります。

interface Post {
  // 前略
  likedUserIDs: string[] //いいねをしたユーザのIDのリスト
}

サブコレクションを使う場合は、記事の前半で示した図のようになります。

ドキュメントのフィールドにリストとして持たせる場合は、そのドキュメントを取得すればその中に内容あるいはIDの一覧が含まれてくるのでリクエスト回数を考えても一見よさそうにも見えます。
しかし、このリストに多量のデータを持つとなると、ドキュメントひとつあたりのデータサイズ(byte)が増加してしまうのと、ひとつのドキュメントでは**1MiB(1024KB)**までしかデータを持てない制限があるため、
仮に数十万ほどいいねがついた時に1MiBの容量制限に達してしまう可能性が出てきます。
それだけ肥大化したドキュメントが出来てしまうと、データを取得するときの通信量も増加するためサービスとしてもユーザーとしてもフレンドリーではなくなります。 (もし最大で数個~十数個程度しかリストにデータが格納されないのであればリストでも問題ないです。)

リストを使う方が楽ですが、どれだけの個数を持つ可能性があるのかをよく考えてリストを使うべきか、サブコレクションを使うべきか考えるべきです。

…話を戻します。今回の場合は、「いいね」がかなりの数つくと仮定して設計しようと思うのでサブコレクションを使う想定で話を進めます。
サブコレクションを使う場合、さらに次のように設計の方法が分かれます。

  • サブコレクションに、参照元となるドキュメントのIDやDocumentReferenceを持つドキュメントを作成する
  • サブコレクションに、参照元のデータの実体を持ったドキュメントを作成する

それぞれ説明していきます

サブコレクションに、参照元となるドキュメントのIDやDocumentReferenceを持つドキュメントを作成する

ユーザーが投稿にいいねをつけた時に、 次のようなドキュメントをPostコレクションのサブコレクション配下に作成して配置します。

// path: /users/{user_id}/posts/{post_id}/likedUsers/{liked_user_id}
interface LikedUser {
  id: string // いいねをつけたユーザーのID(liked_user_idと一致)
  createTime: ServerTimestamp
}

この場合は、一度サブコレクションを取得しそのドキュメントに書き込まれている情報から、参照元のドキュメントの情報を更に取得する必要があります。 この操作をクライアント側で行うことを「client-side-join(クライアントサイドジョイン)」と呼んだりすることもあります。 サブコレクションの取得数が小さければそこまで問題にはなりませんが、数が大きくなると、readのオペレーションのコストがあがります。いわゆる「N+1」問題が起こります。

…と話すと使わない方がよさそうにも聞こえますが、 サブコレクションを取得する時にlimitといったクエリを活用して一度に取得する数を調整してあげればそこまで問題にはならないと思います。

const postRef = ...

// サブコレクションを取得
const likedUsersSnapshot = await postref
  .collection('likedUsers')
  .orderBy('createTime', 'desc')
  .limit(20)
  .get()

const likedUsersSnapshot = await Promise.all(
  likedUsersSnapshot.docs
    .map(doc => firebase.collection('users')
      .doc(doc.data().id)
      .get()
    )
)

クライアント側ではクエリを活用し、一覧を出す時はページングで実装すると良いでしょう。(下までスクロールしたら追加読み込みをする)

ページングについてはいくつかアプローチの方法があるので、以下の動画を見ると参考になるかと思います。

サブコレクションに、参照元のデータの実体を持ったドキュメントを作成する

対してこちらの方法ではサブコレクションに書き込む際に、参照元のデータを合わせて書き込む方法になります。
次のようなドキュメントをPostコレクションのサブコレクション配下に作成して配置します。

// path: /users/{user_id}/posts/{post_id}/likedUsers/{liked_user_id}
interface LikedUser {
  id: string // いいねをつけたユーザーのID(liked_user_idと一致)
  createTime: ServerTimestamp

  name: string // ユーザー名
}

今回Userドキュメントにname程度しか持たせていないので分かりづらいので、「ユーザーがいいねを付けた投稿の一覧」の場合も例を出してみあす。

// path: /users/{user_id}/likedPosts/{liked_post_id}
interface LikedPost {
  id: string // 自分がいいねをつけた投稿のID(liked_post_idと一致)
  postRef: DocumentReference  // 自分がいいねをつけた投稿の参照
  createTime: ServerTimestamp
  
  title: string // タイトル
  body: string // 本文
  author: DocumentReference // 投稿者(User)のDocumentの参照
}

このようにサブコレクションにドキュメントを作成して追加する時に予めデータを書き込んでおくことで、
このサブコレクションからドキュメントを取得した時点で所望するデータが含まれているので、クライアントサイドジョインを避けることができます。

ただ、「参照元のデータが更新された場合は…?」 という問題があります。 その場合は、参照元のデータが更新されたことをCloud FunctionsのFirestoreトリガーで検出し、参照元のデータを持つドキュメントを各ドキュメントのサブコレクションから抽出し、データを更新します。 この更新処理を実行する場合、以前は該当のドキュメントを抽出する処理が大変だったのですが、
現在はcollectionGroupクエリを使うことで抽出しやすくなりました。 ポイントとしては、参照元のデータを書き込む時に、合わせて参照元のドキュメントIDも書き込んでおくことです。 抽出する際に、次のようなクエリで探索できそうにも見えるのですが、これは間違いで正しく取得できません。

// これは間違い
const likedPostsSnapshot = await firestore
  .collectionGroup('likedPosts')
  .where(firestore.FieldPath.documentId(), '==', postRef.path)
  .get()

正しくはこのようにして探索します

const likedPostsSnapshot = await firestore
  .collectionGroup('likedPosts')
  .where('id', '==', postRef.id)
  .get()

なので、サブコレクションには参照元のデータに加え、参照元のドキュメントのIDを持たせておくと更新がしやすくなります。 collectionGroupクエリを使って得られたドキュメントに、変更後のデータを反映しWriteBatchなどを使って更新します。

const batch = firestore.batch()

likedPostsSnapshot.docs.forEach(doc => {
  batch.update(doc.ref, { ...(新しいデータ)})
})

await batch.commit()

余談ですが、バッジ処理やトランザクション処理を行う場合は、一度に500件までの制限があるので、それを超える場合は500件ずつ区切って行う必要があります。

ここまでの説明で見てわかるとおり、readのオペレーションコストは抑えられるかわりに、
データが更新された場合のwriteのオペレーションコストと、Cloud Functionsで関数を実行するという実装コストが、前者よりも高くなります。

どちらが良いのか

どちらが良いかは一概には言えず、参照元のデータの更新頻度と、サブコレクションを読み出す頻度を見て判断することになります。

例えば、一度投稿したらその投稿が削除以外更新されることがほぼないのであれば、参照元のデータをサブコレクションに書き写す方が良いでしょう。 一方でUserモデルのようにユーザー名やアイコンの画像の情報が頻繁に変わる場合は参照だけサブコレクションに持たせるようにして、
適切にクエリを活用してページング実装してクライアントサイドジョインする方が良いこともあります。

なので、参照元となるデータの更新頻度と、そのサブコレクションの読み出しされる頻度を考え、実装の複雑さ・コストも考えた上で、
readにコストをかけるかwriteにコストをかけるか判断すると良いと思います。

この記事の続きでは、readにコストを寄せることにし、サブコレクションには参照元のidなどを持つことにします。

サブコレクションに書き込む

前置きが長くなりましたが、実際にユーザーがUI上でボタンを押した時に、その投稿にいいねをつける場合の処理を次に示します。 実装としては、

  • Postドキュメント以下、likedUsersサブコレクションにドキュメントを追加
  • Userドキュメント以下、likedPostsサブコレクションにドキュメントを追加

を同時に行います。これで、「投稿にいいねをつけた人の一覧」と「ユーザーがいいねをつけた投稿の一覧」のデータを作ることが出来ます。

const postRef = ...
const anotherUserRef = ...
// 追加
const batch = firestore.batch()

batch.set(
  firestore
    .doc(postRef.path)
    .collection('likedUsers')
    .doc(anotherUserRef.id),
  {
    id: anotherUserRef.id,
    createTime: FieldValue.serverTimestamp()
  }
)

batch.set(
  firestore
    .doc(anotherUserRef.path)
    .collection('likedPosts')
    .doc(postRef.id),
  {
    id: postRef.id,
    postRef: postRef,
    createTime: FieldValue.serverTimestamp()
  }
)

await batch.commit()

// 削除
const batch = firestore.batch()

batch.delete(
  firestore
    .doc(postRef.path)
    .collection('likedUsers')
    .doc(anotherUserRef.id)
)

batch.delete(
  firestore
    .doc(anotherUserRef.path)
    .collection('likedPosts')
    .doc(postRef.id)
)

await batch.commit()

ドキュメントIDは、Firestoreで自動生成するIDではなく、それぞれの参照元のドキュメントIDで作成します。

ポイントなのは、PostドキュメントのサブコレクションであるlikedPostslikedUsersのドキュメントをバッジ処理を用いて同時に作成し書き込むことです。 これによって、片方だけ書き込み・削除が成功してもう一方が失敗してデータがちぐはぐしてしまうのを防ぐことができます。

また、ここまでの実装で必要なセキュリティールールは次の通りになります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userID} {
      allow read: if isAuthenticated();
      allow create:
        if isUserAuthenticated(userID)
        && validateName()
        && incomingData().likePostCount == 0
        && isRequestedTime(incomingData().createTime)
        && isRequestedTime(incomingData().updateTime);

      function validateName() {
        return validateString(incomingData().name, 1, 16);
      }

      match /posts/{postID} {
        allow read: if isAuthenticated();
        allow create:
          if isUserAuthenticated(userID)
          && validateTitle()
          && validateBody()
          && incomingData().author == documentPath(['users', userID])
          && incomingData().likeCount == 0
          && isRequestedTime(incomingData().createTime)
          && isRequestedTime(incomingData().updateTime)

        function validateTitle() {
          return validateString(incomingData().title, 1, 25);
        }

        function validateBody() {
          return validateString(incomingData().body, 1, 1000);
        }

        match /likedUsers/{likedUserID} {
          allow read: if isAuthenticated();
          allow create:
            if isUserAuthenticated(likedUserID)
            && incomingData().id == likedUserID
            && isRequestedTime(incomingData().createTime)
            && !exists(likedPostPath(postID))
            && getAfterData(likedPostPath(postID)).id == postID;

          allow delete:
            if isUserAuthenticated(likedUserID)
            && exists(likedPostPath(postID))
            && !existsAfter(likedPostPath(postID));

          function postPath() {
            return documentPath(['users', userID, 'posts', postID]);
          }

          function userPath() {
            return documentPath(['users', likedUserID]);
          }

          function likedPostPath(postID) {
            return documentPath(['users', likedUserID, 'likedPosts', postID]);
          }
        }
      }

      match /likedPosts/{likedPostID} {
        allow read: if isAuthenticated();
        allow create:
          if isUserAuthenticated(userID)
          && incomingData().id == likedPostID
          && isRequestedTime(incomingData().createTime)
          && !exists(likedUserPath(userID, incomingData()))
          && getAfterData(likedUserPath(userID, incomingData())).id == userID;

        allow delete:
          if isUserAuthenticated(userID)
          && exists(likedUserPath(userID, existingData()))
          && !existsAfter(likedUserPath(userID, existingData()));

        function likedUserPath(userID, data) {
            return documentPath(['users', get(getData(data.postRef).author).id, 'posts', likedPostID, 'likedUsers', userID]);
          }
      }
    }

    match /{path=**}/posts/{postID} {
      allow list: if isAuthenticated();
    }

    match /{path=**}/likedPosts/{likedPostID} {
      allow list: if isAuthenticated();
    }

    match /{path=**}/likedUsers/{likedUserID} {
      allow list: if isAuthenticated();
    }

    function documentPath(paths) {
      return path([
        ['databases', database, 'documents'].join('/'),
        paths.join('/')
      ].join('/'));
    }

    function isAuthenticated() {
      return request.auth != null;
    }

    function isUserAuthenticated(userID) {
      return request.auth.uid == userID;
    }

    function isRequestedTime(time) {
      return time == request.time;
    }

    function incomingData() {
      return request.resource.data;
    }

    function existingData() {
      return resource.data;
    }

    function getData(path) {
      return get(path).data;
    }

    function getAfterData(path) {
      return getAfter(path).data;
    }

    function validateString(text, min, max) {
      return text is string
        && min <= text.size()
        && text.size() <= max;
    }
  }
}

サブコレクションへの追加をWriteBatchを使って行う場合のルールを書く時は、getAfterexistsAfterといった関数が役に立ちます。
getAfterがどういうものかは、以前に書いたこちらの記事を御覧ください

getAfter関数を活用し、同時に書き込むドキュメント同士がセキュリティルールの検証を突破した場合に正しく書き込まれていることを保証することができます。
likedUsersコレクションのcreateのルールを見てみると

&& !exists(likedPostPath(postID))
&& getAfterData(likedPostPath(postID)).id == postID

といった形で、書き込み前には同時に書き込むはずのlikedPostsコレクションのドキュメントが存在しておらず、書き込みが成功した場合に有効なデータが存在していることを確かめています。
deleteのルールはその逆で、削除後にlikedPostsコレクションのドキュメントも合わせて削除されたかを確認しています。

バッジ処理、トランザクション処理、それらのデータ検証(ルール)の方法については公式のドキュメントも参考になるかと思います。

いいねの数を記録する

サブコレクションに書き込まれたドキュメントの数を集計する場合、以前は分散カウンタ的ものを構築する必要がありました。(今でも使われることはあると思います)

最近だと、「Firebase Extensions」の登場により、「Distributed Counter」というものが用意されているのでサクッと導入することができます。

別の方法として、現在はドキュメントのフィールドに、FieldValue.incrementを使って書き込むことで比較的簡単に記録することができます。 性能としては 「最大書き込み速度が1秒間に1回」 ではありますが、トランザクション処理により確実に値を増減させることができるため、
一度に数万も値を増減させるようなオペレーションが発生しない限りは分散カウンタを使うよりは手軽そうです。

このFieldValue.incrementを使って数を記録するには、Cloud Functionsを使うパターンと、Firestoreのみで完結させるパターンがあります。

Cloud Functionsを使う場合

Cloud Functionsを使っていいねの数を記録する場合は、Cloud Firestoreトリガーを使います。 次に実装の例を示します。

import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'

export const postLiked = functions
  .firestore.document('users/{userID}/posts/{postID}/likedUsers/{likedUserID}')
  .onCreate((snapshot, context) =>
    admin
      .firestore()
      .collection('users')
      .doc(content.params.userID)
      .collection('posts')
      .doc(context.params.postID)
      .update({ likeCount: admin.firestore.FieldValue.increment(1) })
  )

export const postUnliked = functions
  .firestore.document('users/{userID}/posts/{postID}/likedUsers/{likedUserID}')
  .onDelete((snapshot, context) =>
    admin
      .firestore()
      .collection('users')
      .doc(content.params.userID)
      .collection('posts')
      .doc(context.params.postID)
      .update({ likeCount: admin.firestore.FieldValue.increment(-1) })
  )

export const likePost = functions
  .runWith({ memory: '1GB' })
  .firestore.document('users/{userID}/likedPosts/{likedPostID}')
  .onCreate((snapshot, context) =>
    admin
      .firestore()
      .collection('users')
      .doc(content.params.userID)
      .update({ likePostCount: admin.firestore.FieldValue.increment(1) })
  )

export const unlikePost = functions
  .firestore.document('users/{userID}/likedPosts/{likedPostID}')
  .onDelete((snapshot, context) =>
    admin
      .firestore()
      .collection('users')
      .doc(content.params.userID)
      .update({ likePostCount: admin.firestore.FieldValue.increment(-1) })
  )

ポイントとしては、サブコレクションにドキュメントが追加/削除されたタイミングをトリガーに、
親ドキュメントのカウントを増減させる処理を実行します。 値を減少させるときは、decrementという関数がないので、incrementに負数を渡します。

Ckoud Functionsを使うことで、クライアント側にいいねの数を増減させるロジックを持たせなくて済み、セキュリティルールに悩むこともありません。 セキュリティールールで、countの値を書き換えることができないようにしておきましょう。

便利であるものの、ドキュメントの書き込みや削除をトリガーにして処理が行われるため、値の増減のタイミングが若干ずれることになります。
(特にCloud Functionsが寝ていてCold Startする場合なんかは…)

Cloud Firestoreのみで完結させる場合

先述のWriteBatchを使った書き込み時に合わせて値を増減させればよいので、実装自体は楽で、サブコレクションへの追加と値の増減のタイミングがずれることもありません。

ただ、firestore.rulesのルールで不正な値の増減が行われないように防ぐことがとても難しいです。

updateのルールに関して

  • 投稿者自身は、いいねの数を書き換えできないようにする
  • 投稿者意外は、いいねの数を適切に増減させつつ、タイトルや本文といったデータを書き換えできないようにする

といった形で投稿者かそうでないかで条件を分けつつ、「いいねの数を適切に増減させたか?」というのを判断しなければなりません。 これが非常に難しいところになります。

まずは、Postコレクションのupdateの条件をこのように書いてみます。

match /posts/{postID} {
  allow update:
    if (isUpdatingByAuthor() || isUpdatingByVisitor())
    && isNotChanged('author')
    && isNotChanged('createTime');
  
  function isUpdatingByAuthor() {
    return [投稿者の場合の更新の条件を書く]
  }
  
  function isUpdatingByVisitor() {
    return [閲覧者の場合の更新のルールを書く(いいねの数の更新)]
  }
}

// 便利関数を定義しておく
function isNotChanged(key) {
  return incomingData()[key] == existingData()[key];
}

これで、updateのルールに関して、投稿者か、そうでないかで条件をfunctionで分けることができるので見通しが良くなります。 まずは投稿者の場合の条件を書きます。

function isUpdatingByAuthor() {
  return isUserAuthenticated(userID)
    && validateTitle()
    && validateBody()
    && isRequestedTime(incomingData().updateTime)
    && isNotChanged('likeCount');
}

投稿者の場合は、createのルールと同様に書き込むコンテンツのバリデーションをした上で、自身がlikeCountを書き換えできないようにします。isUpdatingByVisitorの条件は次のようになります。

それ以外のユーザーの場合は、likeCountのみ、正しく増減させつつlikedPosts,likedUsersコレクションへのドキュメント作成や削除と合わせて同時に書き込めているかを検証することにします。

function isUpdatingByVisitor() {
  return isAuthenticated()
    && ((
      isIncrementedField('likeCount', 1)
        && (!exists(likedUserPath()) && existsAfter(likedUserPath()))
        && (!exists(likedPostPath()) && existsAfter(likedPostPath()))
      )
      || (
      isIncrementedField('likeCount', -1)
        && (exists(likedUserPath()) && !existsAfter(likedUserPath()))
        && (exists(likedPostPath()) && !existsAfter(likedPostPath()))
      )
    )
    && isNotChanged('title')
    && isNotChanged('body')
    && isNotChanged('updateTime');
}

function likedUserPath() {
  return documentPath(['users', userID, 'posts', postID, 'likedUsers', request.auth.uid]);
}

function likedPostPath() {
  return documentPath(['users', request.auth.uid, 'likedPosts', postID]);
}

likeCountが1増える更新のは、likedPosts,likedUsersコレクションへのドキュメントの作成も併せて処理されることを検証し、
1減らす場合はその逆を検証します。

これに合わせて、likedUsersコレクションのcreate,deleteの条件も変更します。

match /likedUsers/{likedUserID} {
  allow read: if isAuthenticated();
  allow create:
    if isUserAuthenticated(likedUserID)
      && incomingData().id == likedUserID
      && isRequestedTime(incomingData().createTime)
      && !exists(likedPostPath(postID))
      && getAfterData(likedPostPath(postID)).id == postID
      && isIncremented(getAfterData(postPath()), getData(postPath()), 'likeCount', 1)
      && isIncremented(getAfterData(userPath()), getData(userPath()), 'likePostCount', 1);

  allow delete:
    if isUserAuthenticated(likedUserID)
      && exists(likedPostPath(postID))
      && !existsAfter(likedPostPath(postID))
      && isIncremented(getAfterData(postPath()), getData(postPath()), 'likeCount', -1)
      && isIncremented(getAfterData(userPath()), getData(userPath()), 'likePostCount', -1);

    function postPath() {
      return documentPath(['users', userID, 'posts', postID]);
    }

    function userPath() {
      return documentPath(['users', likedUserID]);
    }

    function likedPostPath(postID) {
      return documentPath(['users', likedUserID, 'likedPosts', postID]);
    }
}
match /likedPosts/{likedPostID} {
  allow read: if isAuthenticated();
  allow create:
    if isUserAuthenticated(userID)
      && incomingData().id == likedPostID
      && isRequestedTime(incomingData().createTime)
      && !exists(likedUserPath(userID, incomingData()))
      && getAfterData(likedUserPath(userID, incomingData())).id == userID
      && isIncremented(getAfterData(userPath()), getData(userPath()), 'likePostCount', 1)
      && isIncremented(getAfterData(incomingData().postRef), getData(incomingData().postRef), 'likeCount', 1);

  allow delete:
    if isUserAuthenticated(userID)
      && exists(likedUserPath(userID, existingData()))
      && !existsAfter(likedUserPath(userID, existingData()))
      && isIncremented(getAfterData(userPath()), getData(userPath()), 'likePostCount', -1)
      && isIncremented(getAfterData(existingData().postRef), getData(existingData().postRef), 'likeCount', -1);

  function userPath() {
    return documentPath(['users', userID]);
  }

  function likedUserPath(userID, data) {
    return documentPath(['users', get(getData(data.postRef).author).id, 'posts', likedPostID, 'likedUsers', userID]);
  }
}

likedPosts,likedUsersコレクションのcreate,deleteのルール側でも、post.likeCountの値の増減が正しく行われていることを併せて検証します。
また、user.likePostCount(ユーザーがいいねを付けているいいねの数)も検証しておきます。

最後に、ユーザーがつけたいいねの投稿数を正しく増減させるためのルールを同じように書いていきます。

match /users/{userID} {
  allow read: if isAuthenticated();
  allow create:
    if isUserAuthenticated(userID)
      && validateName()
      && incomingData().likePostCount == 0
      && isRequestedTime(incomingData().createTime)
      && isRequestedTime(incomingData().updateTime);
  allow update:
    if (isUpdatingMyData() || isUpdatingOnlyLikeCount());

  function isUpdatingMyData() {
    return isUserAuthenticated(userID)
      && validateName()
      && isNotChanged('likeCount')
      && isRequestedTime(incomingData());
  }

  function isUpdatingOnlyLikeCount() {
    return isUserAuthenticated(userID)
      && (
        isIncrementedField('likePostCount', 1)
        || isIncrementedField('likePostCount', -1)
      )
      && isNotChanged('name')
  }

  function validateName() {
    return validateString(incomingData().name, 1, 16);
  }
}

postsコレクションを、Userドキュメントのサブコレクションとして配置している都合上、
likePostCountの値の検証をする際にどのpostドキュメントにいいねをしたのかをupdateのルールでは判定することができません。 なのでこの部分だけ他の箇所の条件と比べてどうしても薄くなってしまいます。
が、他のコレクションで同時書き込みの検証コードを厚めに書いていることと、この部分は自身のいいねしている投稿の数であり、
仮に本人自ら数字を改ざんしたとしてもサービスとして悪影響がでるわけではないのでこのままでも問題ないかと思います。
どうしてもこの箇所もしっかり固めたい場合は先述のCloud Functionsを使う方法を組み合わせると良いでしょう。

これで投稿のいいねの数を正しく増減させるためのルールを書くことができました。
ルールを更新したら、バッジ処理でいいねをつけたり外したりする処理を次のように書き換えます。

const postRef = ...
const anotherUserRef = ...
// 追加
const batch = firestore.batch()

batch.set(
  firestore
    .doc(postRef.path)
    .collection('likedUsers')
    .doc(anotherUserRef.id),
  {
    id: anotherUserRef.id,
    createTime: FieldValue.serverTimestamp()
  }
)

batch.set(
  firestore
    .doc(anotherUserRef.path)
    .collection('likedPosts')
    .doc(postRef.id),
  {
    id: postRef.id,
    postRef: postRef,
    createTime: FieldValue.serverTimestamp()
  }
)

// 追加
batch.set(postRef, { likeCount: FieldValue.increment(1) })
batch.set(aotherUserRef, { likePostCount: FieldValue.increment(1) })

await batch.commit()

// 削除
const batch = firestore.batch()

batch.delete(
  firestore
    .doc(postRef.path)
    .collection('likedUsers')
    .doc(anotherUserRef.id)
)

batch.delete(
  firestore
    .doc(anotherUserRef.path)
    .collection('likedPosts')
    .doc(postRef.id)
)

// 追加
batch.set(postRef, { likeCount: FieldValue.increment(-1) })
batch.set(aotherUserRef, { likePostCount: FieldValue.increment(-1) })

await batch.commit()

ルールの最終形は、次のようになります。


うまく展開されない場合はこちらから見てください。

設計の応用

ここまでの設計を応用すると、いわゆる「フォロー・フォロワー機能」「友達機能」といった機能も実現できると思います。 また、Twitterのブックマーク機能のように、ユーザーが一方的にリストに入れるだけで、ツイートからブックマークに入れたユーザーの一覧や数を保持しておく必要がない場合は、
今回の設計の片側だけで済むのでより楽に実装ができると思います。

まとめ

長くなりましたが、要点をまとめます。

  • 何かしらの一覧をCloud Firestoreで管理する場合、十数個程度で済むならドキュメントのフィールドにList(配列)として持つのはアリ
  • 一方で数百、数万となることが予想される場合はサブコレクションの使用がベター。容量の大きいドキュメントを作らない
  • サブコレクションを使う場合、データの読み取りと、参照元のデータの更新頻度を見て、readにコストを割くか、writeにコストを割くか考える
  • サブコレクションのドキュメントには、参照元になるドキュメントのIDやDocumentReference、ドキュメントの作成日を書き込むと便利
  • サブコレクションの数をカウントする場合、親のドキュメントにFieldValue.incrementを使って数を記録する
  • FieldValue.incrementの実行をCloud FunctionsのCloud Firestoreトリガーを使うと楽に済む
  • セキュリティルールのみで安全に数を増減させることもできるが、セキュリティルールが複雑化する

いいね機能やフォロー・フォロワー機能などを実装する際にはこれらのことを考えた上で設計すると、
サービスが成長したときにスケールしないといったトラブルを回避できるかと思います。

ちなみに私は今まで値の増減はCloud Functionsを利用した方法で更新していたのですが、
セキュリティルールを書いてFirestoreだけでも完結することがわかったので、ルールを書くのは大変ですが徐々にそちらにシフトしていこうかなと思います。

また、雑多ではありますが

  • firestore.rules
  • Cloud Functionsの定義
  • Cloud Firestoreのみで「いいね機能」を実装した場合のfirestore.ruleの雑なテスト(動作確認用)

こちらにあげているので参考にしてみてください。テストに関してはこの記事を書くにあたって動作確認用に書いているのでかなり雑多な感じではありますが…。

あくまでも今回紹介したものは、僕が今までの開発経験を通して模索してでてきたものなので、 より良い案とかあったらマサカリを投げず優しく教えてもらえると嬉しいです。

あとは今回はセキュリティルールの例を厚めにお見せしたので書くときの参考になればいいなと思います。 ルールのコードの下の方に便利関数を固めているので参考にしてみてください。

あわせて読みたい

TechFirebaseCloud FirestoreSecurity RuleTipsCloud Functions

FlutterでProgressButtonを自作する

Cloud FunctionsのCloud Firestoreトリガーの重複発火を防ぐ、より良いアプローチ