Cloud Firestore で作る Web プランニングポーカーアプリ

「[Applibot Advent Calendar 2022](https://qiita.com/advent-calendar/2022/applibot)」 19日目の記事になります。
前日は @16cho の [Open Match チュートリアルをminikubeでやる場合の落とし穴](https://qiita.com/16cho/items/6f51bba2b7527e58e107) という記事でした。


# はじめに

今年私の所属するチームでは、開発にスクラムを取り入れる試みがありました。
参考: [Applibot Advent Calendar 2022 10日目: スクラム開発を取り入れてみた話](https://zenn.dev/fxxxxxrest/articles/842ff9d2121ba7)

リモートワーク中の見積もりのため、無料で使用できるプランニングポーカーのWebツールを使用していたのですが、使う中でいくつか課題も出てきました。その際、このくらいであればと Firestore を使って雑にツールを作成したのですが、これがわりと好評でした。

↓ こんな感じのツールです

![scrum-poker-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191059/92a7fe13-17e8-5b8b-af8a-a121d5ff72af.gif)

Firestore はオンラインで画面同期するタイプの Web アプリのバックエンドとして、特に個人用途などの簡単なアプリ開発において非常に優秀だと思っています。今回のような要件には特にピッタリです。

そこで、折角なのでその際に検討したデータ設計などについて備忘録代わりにまとめたいと思います。何か他のアプリを作る際の参考になれば幸いです。

※実は私は２年前のアドカレでも 「[Cloud Firestore でさくっとオンライン対戦ゲームを作ろう](https://qiita.com/ref3000/items/866cb2fab2c6e3605455)」 と第して Firestore を利用した Web アプリ開発の流れを紹介しています。今回の記事はよりデータ設計に注目して、オンラインで画面同期するタイプの Web アプリで気をつける点などを紹介したいと思います。


# Cloud Firestore

![スクリーンショット 2020-12-05 1.37.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191059/92596ee0-7926-913a-d016-def584edb370.png)

そもそも Cloud Firestore とは何かという話ですが
Cloud Firestore は Google の提供するクラウドデータベースの事です。
Firebase という mBaaS の一部となっています。
[以下のような特徴](https://firebase.google.com/docs/firestore?hl=ja#key_capabilities)があり、オンラインで画面同期するタイプの Web アプリを作るにあたり重宝します。

- リアルタイムアップデート
    - ドキュメントの追加や更新を監視する機能
    - → オンラインでの画面同期が簡単に実装できる

- Firebase Authentication との統合
    - 認証やデータ検証の機能
    - → サーバ側の実装無しに Web フロント完結で作業できる

ただし[Cloud Firestore のベストプラクティス](https://firebase.google.com/docs/firestore/best-practices?hl=ja)に従うと、以下のような制約があるため気をつける必要があります。

- 同一ドキュメントの更新は秒間 1 回までにする
- クライアントに push するドキュメントのレートを 1 ドキュメント/秒未満にする

高頻度の同期を伴う Web アプリには向いていないので注意しましょう。


# 実例：Web プランニングポーカーアプリ

早速実際にアプリを開発する流れを見ていきましょう。
データ設計における注意点なども、流れの中で見るとイメージも湧きやすいかなと思います。


## 機能要件

プランニングポーカー自体の説明は[英語版ウィキペディアの解説](https://en.wikipedia.org/wiki/Planning_poker)に譲るとして、Web アプリとしてどういう機能が必要か列挙してみます。

- 自由に部屋を作成できる
    - 部屋名を自由に付けられる
- 部屋毎にプランニングポーカーをプレイできる
    - 誰が何のカードを提出したかわかる
      - 名前を自由に設定できる
      - 出来ればプロフィール画像が設定できると嬉しい
    - 一斉にカードを開いたり裏返したりできる
      - 画面上で見えなくなっていれば十分（悪意を持てば裏返してあるカードの中身は見れてしまうが許容）

## データモデル考察

機能要件を出したので、これを満たすデータモデルを考えてみましょう。

### 案1: 最初のモデル

```
- rooms (コレクション)
  - 'room1' (ドキュメントID)
    - 'プランニングポーカーの実装' (name)
    - false (isOpen) // カードがオープンされているか
    - cards (Array)
      - 0 (index)
        - 'user1' (user)
        - '３' (value)
        - '2022年12月19日 12:00:20' (updatedAt)
      - 1 (index)
        - 'user1' (user2)
        - '４' (value)
        - '2022年12月19日 12:00:40' (updatedAt)
      ...
  - 'room2'
    ...

- users (コレクション)
  - 'user1' (ドキュメントID)
    - 'refpuyo' (name)
    - 'https://example.com/image/refpuyo' (photoURL)
  - 'user2'
    ...
```

部屋の情報を rooms コレクションの一つのモデルにまとめてみました。
またユーザは複数の部屋に参加する事があるので users コレクションとして分けて正規化してあります。

……さて、この設計ではどのような事が考えられるでしょうか？

#### 考察

まず考えるべきは Firestore などの NoSQL において、正規化は本当に正しい選択なのかどうかという事です。
Firestore では一般的な RDB と異なり、結合の操作は提供されていません。
つまりこの設計で画面を描画しようとする場合、カードの数と同じだけ users コレクションのドキュメントを取得する必要があります。
（クライアントサイド Join を行う必要があるということです。）

NoSQL におけるデータ設計では、パフォーマンスのための非正規化は有効な選択肢として考える事が出来ます。

### 案2: 非正規化を行う

```
- rooms (コレクション)
  - 'room1' (ドキュメントID)
    - 'プランニングポーカーの実装' (name)
    - false (isOpen) // カードがオープンされているか
    - cards (Array)
      - 0 (index)
        - 'refpuyo' (name)
        - 'https://example.com/image/refpuyo' (photoURL)
        - '３' (value)
        - '2022年12月19日 12:00:20' (updatedAt)
      - 1 (index)
        - 'ref3000' (name)
        - 'https://example.com/image/ref3000' (photoURL)
        - '４' (value)
        - '2022年12月19日 12:00:40' (updatedAt)
      ...
  - 'room2'
    ...
```

非正規化を行い room1 のドキュメント一つで画面が描画出来るようにしてみました。

#### 考察

次に、更新の衝突がいつ起きるのかという事を考えてみます。
Firestore ではコレクションやドキュメントに対してリスナーを設定する事ができます。
更新の最小単位は基本的にドキュメントです。
現状はユーザがカードを同時に提出した場合、衝突が起きそうです。

### 案3: 更新の単位でドキュメントを分離する

```
- rooms (コレクション)
  - 'room1' (ドキュメントID)
    - 'プランニングポーカーの実装' (name)
    - false (isOpen) // カードがオープンされているか
    - cards (サブコレクション)
      - 'card1' (ドキュメントID)
        - 'refpuyo' (name)
        - 'https://example.com/image/refpuyo' (photoURL)
        - '３' (value)
        - '2022年12月19日 12:00:20' (updatedAt)
      - 'card2' (ドキュメントID)
        - 'ref3000' (name)
        - 'https://example.com/image/ref3000' (photoURL)
        - '４' (value)
        - '2022年12月19日 12:00:40' (updatedAt)
      ...
  - 'room2'
    ...
```

更新の単位であるカードを（サブ）コレクションとする事で衝突が起きないようにしました。
（非正規化したのに結局カードの数と同じだけのドキュメント取得が必要になってしまいましたが、トレードオフとして受け入れる必要があります。）

#### 考察

次に、セキュリティルールについて考えてみます。
Firestore では Firebase Authentication と連携してルールベースで認証の実装を簡単に行う事が出来ます。
このセキュリティルールにおいて、アクセス制御はドキュメント単位での設定になります。
（つまり**ドキュメント内の機密レベルは揃える必要があります**。）

また認証にはユーザの uid を利用します。
カードとユーザは１対１の関係なので、これをドキュメント ID として利用する事にしましょう。

### 案4: ドキュメント ID に uid を利用する

```
- rooms (コレクション)
  - 'room1' (ドキュメントID)
    - 'プランニングポーカーの実装' (name)
    - false (isOpen) // カードがオープンされているか
    - cards (サブコレクション)
      - 'MC54bpcuV1WBIdocHYAxy1VrFXd2' (ドキュメントID)
        - 'refpuyo' (name)
        - 'https://example.com/image/refpuyo' (photoURL)
        - '３' (value)
        - '2022年12月19日 12:00:20' (updatedAt)
      - 'dcZPMgXLvde6WkP3C5b8BYGlqEb2' (ドキュメントID)
        - 'ref3000' (name)
        - 'https://example.com/image/ref3000' (photoURL)
        - '４' (value)
        - '2022年12月19日 12:00:40' (updatedAt)
      ...
  - 'room2'
    ...
```

card のドキュメント ID に uid を利用してみました。
これにより、セキュリティルールを以下のようにシンプルに設定することができます。

```js:セキュリティルール
match /rooms/{roomId}/cards/{cardId} {
  allow read: if request.auth != null;
  allow write: if cardId == request.auth.uid; // 作成者以外の更新を禁止
}
```

ちなみに Firestore では[ドキュメント ID として辞書順で近い値を採用すると、ホットスポットが発生する可能性があります](https://cloud.google.com/firestore/docs/best-practices?hl=ja#designing_for_scale)。
その意味でも良い選択と言えるでしょう。
あわせて room のドキュメント ID も [add()](https://firebase.google.com/docs/reference/js/v8/firebase.firestore.CollectionReference#add) を利用して Cloud Firestore に ID を自動生成させると良いでしょう。

```
- rooms (コレクション)
  - '30n3b6kFtCgEtyFrcStM' (ドキュメントID)
    - 'プランニングポーカーの実装' (name)
    - false (isOpen) // カードがオープンされているか
    - cards (サブコレクション)
      - 'MC54bpcuV1WBIdocHYAxy1VrFXd2' (ドキュメントID)
        - 'refpuyo' (name)
        - 'https://example.com/image/refpuyo' (photoURL)
        - '３' (value)
        - '2022年12月19日 12:00:20' (updatedAt)
      - 'dcZPMgXLvde6WkP3C5b8BYGlqEb2' (ドキュメントID)
        - 'ref3000' (name)
        - 'https://example.com/image/ref3000' (photoURL)
        - '４' (value)
        - '2022年12月19日 12:00:40' (updatedAt)
      ...
  - 'YlP8PPmoW8ITsJFuSPxx'
    ...
```

#### 考察

かなり良さそうなデータ設計となってきました。
最後に削除について考えてみます。

部屋の状態をリセットしたい場合は、どのようにすれば良いでしょう。
サブコレクションである cards のドキュメントを全て削除すれば良いのでしょうか……？

これには、以下のような問題があります。

- [クライアントがアトミックにコレクションを削除する操作は提供されていない](https://firebase.google.com/docs/firestore/manage-data/delete-data?hl=ja)
  - どうしても必要であれば Cloud Functions を利用して削除を実施する必要がある
  - その場合でもドキュメントは１つずつの削除になるのでパフォーマンスの問題がある
- 愚直な実装では、カードを作成者以外が削除する必要性が生まれる

そこで、room 側に状態初期化用のカラムを追加してみます


### 案5: 初期化への対応

```
- rooms (コレクション)
  - '30n3b6kFtCgEtyFrcStM' (ドキュメントID)
    - 'プランニングポーカーの実装' (name)
    - false (isOpen) // カードがオープンされているか
    - '2022年12月19日 12:00:30' (clearedAt) // この値以前の更新日時のカードは非表示とする
    - cards (サブコレクション)
      - 'MC54bpcuV1WBIdocHYAxy1VrFXd2' (ドキュメントID)
        - 'refpuyo' (name)
        - 'https://example.com/image/refpuyo' (photoURL)
        - '３' (value)
        - '2022年12月19日 12:00:20' (updatedAt)
      - 'dcZPMgXLvde6WkP3C5b8BYGlqEb2' (ドキュメントID)
        - 'ref3000' (name)
        - 'https://example.com/image/ref3000' (photoURL)
        - '４' (value)
        - '2022年12月19日 12:00:40' (updatedAt)
      ...
  - 'YlP8PPmoW8ITsJFuSPxx'
    ...
```

room に clearedAt というカラムを追加してみました。
画面表示の際には、この値以前の更新日時のカードは非表示とする実装にします。
すると、カード自体を削除せずとも部屋を初期化できるようになりました。

必要であればオンラインアップデートリスナーの条件としても利用する事で、通知自体をフィルタすることも出来ます。

メリット・デメリットあるとは思いますが、基本的には削除はクライアントからは許可しない方針とするのが丸いように考えています。


## 完成

というような試行錯誤を経て完成しました。Web プランニングポーカーアプリです。
完成品はこちら。UI は雑の極みです……

![scrum-poker-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191059/92a7fe13-17e8-5b8b-af8a-a121d5ff72af.gif)

https://ref3000.github.io/scrum-poker/?id=iva0hPQlglRIEAZ9NAVd
ソースコード: https://github.com/ref3000/scrum-poker

# おわりに

というわけで、あくまで一例ではありますが Web プランニングポーカーアプリを Firestore で 開発する流れでした。
実際のデータ設計と開発の流れに合わせて自分の考えを紹介しましたが、いかがでしたでしょうか。

設計はトレードオフであるため、どの選択にも一長あれば一短もあります。
特に NoSQL におけるデータ設計は、未だベストプラクティスと呼べるものが広く認知されていないように感じます。
是非みなさんも自分なりに設計してみて下さい。

以上「[Applibot Advent Calendar 2022](https://qiita.com/advent-calendar/2022/applibot)」 19日目の記事でした！
明日は @hasebe_takumi さんです