みかんのレビューサイトを作っている話

# 要約

- みかんのレビューができるウェブサイト「[柑これ](https://tofuchic.github.io/kancolle/)」を少人数（2-3 人）で作っています
  - ソースコード管理は GitHub、デザインは Figma、タスク管理は Notion で行なっています
  - フロントエンドは React / Typescript で開発し、GitHub Pages にデプロイしています
    - DB への Write アクセスをユーザに意識させないようにしました
  - バックエンドは実装せず、クライアントは Firebase Auth と Cloud Firestore の API を直接呼び出しています
    - 不正な DB アクセスを防ぐためにセキュリティルールを設定しました
  - 次のような課題があります
    - 誤操作による DB Write アクセスを防ぐ
    - 集計対象とするデータを見分ける

# はじめに

現在、担当しているクラウドネイティブ案件で SRE として活動している、とうふです。
SRE として活動していると言っても、まだリリースされていないプロダクトのため、開発アジリティやセキュリティに加え可観測性、それから後々の運用負荷などといったシステムの信頼性のことを考えながら以下のようなトピックに関わる作業をしています。

- IaC ツールと CI/CD パイプラインによるインフラ・アプリのデプロイ自動化
- オンプレ環境とも接続するようなエンタープライズ向けシステムのネットワーク構成
- IDaaS を利用したバックエンド API の認証・認可
- E2E テスト用のスクリプトを流用したパフォーマンステスト
- SLI・SLO とエラーバジェット

非機能面の課題がたくさん残っている都合上インフラ屋さんに専念しているのですが、フロントエンドにも携わりたいと常々思っていました。
そんなところに、ちょうど良い題材が降ってきたのが今年の 10 月です。
私の中学からの友人が[柑橘サブスクリプション「かんみ」](https://shop.kanmi.co.jp/)というサービスを始めたのでした。
みかんは好きなので（もちろん旧友のよしみというのもありますが）通年でサブスクライブしたのですが、全部で 80 種類以上の柑橘が届くので、食べ比べて感想を残せるようなウェブサイトを作ることにしました。
お陰様で一応無事にリリースできているのですが、手伝ってくださった方々に改めて感謝を述べたいと思います。
ありがとうございます！

すみません、私の悪い癖で長々と前提を述べてしまいました。
本記事では、みかんのレビューサイトを作るにあたり検討・工夫したことや現在抱えている課題について説明します。

# アプリの紹介

「[柑これ](https://tofuchic.github.io/kancolle/)」というウェブサイトを公開しています。
2022 年 12 月現在、Twitter 認証でログインすることで[柑橘サブスクリプション「かんみ」](https://shop.kanmi.co.jp/)で届く柑橘のレビューを行うことができます。

<blockquote class="twitter-tweet" data-partner="tweetdeck"><p lang="ja" dir="ltr">かんみで届くみかんのレビューができるサイトを作ったので、是非サブスクライブしてる人たちに使ってほしいです。<br>味と食感、それからメモを残せるようになってます。<a href="https://t.co/yVS3l1pBFl">https://t.co/yVS3l1pBFl</a><a href="https://twitter.com/hashtag/%E3%81%8B%E3%82%93%E3%81%BF?src=hash&amp;ref_src=twsrc%5Etfw">#かんみ</a> <a href="https://t.co/XJCeJwXRvT">pic.twitter.com/XJCeJwXRvT</a></p>&mdash; とうふめんたるちきんはーと（62.0） (@BlueTofuchic) <a href="https://twitter.com/BlueTofuchic/status/1596796194294202370?ref_src=twsrc%5Etfw">November 27, 2022</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

「日本のみかんの消費量を増やす」ことが Vision なので、将来的には[「かんみ」](https://shop.kanmi.co.jp/)とは関係なく、任意の柑橘のレビューが出来るようにしたり、購入ページへのリンクを紐づけられたりできるようにして柑橘の販促に繋げたいと考えています。

# アーキテクチャ設計

まず、現在の案件では React / Typescript で SPA を扱っています。ソースコード管理には Azure Repos、CI/CD パイプラインは Azure Pipelines を使っています。
他の案件メンバがやっているような開発を体験したいというのが私のモチベーションなので、同じく React / Typescript によるフロントエンドを実装することとし、それから開発効率や運用コストを鑑みてバックエンドや DB を決めることにしました。
逆に、インフラ屋さんとして散々扱っている Azure Repos や Azure Pipelines はもうお腹一杯なので、GitHub および GitHub Actions を使うことにしました。
大前提として、できるだけシンプルに開発でき、かつ、計算機リソースの利用コストがかからないように、というのを意識しました。

フロントエンドを無料でホスティングできるサービスはいくつかあると思いますが、「やりたいことが実現できなかったら他のサービスを検討しよう」くらいに緩く考えていたので、追加のアカウント登録が不要な GitHub Pages を使うことにしました。
（機能面も併せて考えると、本当は PR のパイプライントリガ時などに PR 環境を動的に用意できる Azure Static Web Apps が良かったのですが、アカウント登録の際のクレジットカードや住所の登録が面倒になって辞めてしまいました。）

続いてバックエンドと DB についてですが、[Firebase](https://firebase.google.com/) の Cloud Firestore に[クライアントライブラリ](https://firebase.google.com/docs/firestore/client/libraries?hl=ja)と呼ばれる、文字通り「クライアントが直接 DB アクセスするためのライブラリ」があるため、バックエンドは用意しないことにしました。
クライアントが DB に無制限にアクセスできないように、通常だとユーザの認証認可やリクエスト処理を担うバックエンドを実装すると思います。今回は、Firebase Auth によってユーザを認証した上で Firebase セキュリティルールによって予めユーザが DB に対してどんなクエリを実行できるかを設定しておくことで、バックエンド無しに安全な DB アクセスを実現するという構成を取りました。

今後の運用負荷を考えて Firebase 関連のリソースは、IaC ファイルを GitHub で管理しパイプラインで Terraform などを自動実行することでデプロイや編集を管理したかったのですが、コンソールにログインせずに行うのは難しい[^1]ようで断念しました。
[^1]:https://blog.ojisan.io/fire-fire/

# ウェブデザイン

当時よく名前を見かけたので Figma を使うことにしました。
使うと言っても、雑にお絵描きをしているだけなので、そんなに活用できている気はしません。
今回「[柑これ](https://tofuchic.github.io/kancolle/)」を作るにあたって、アイコンを作ってくれた @kinakome さんや、一緒にコーディングしてくれた @ychNext9 さんと認識等を共有するために使っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105953/da86a4b8-2f55-c410-23e1-21cbf20c90bc.png)

# 開発フロー

Notion でタスク管理をして、優先度が高い順に並べ替えています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105953/2cd06f37-242c-cf3e-4958-cc43c02d9668.png)

タスクごとに PR を作り main ブランチに merge していくのですが、ハマったところや難しかった点などがあった場合はタスク内に詳細を記載しています。
タスク単位で振り返ることができるので、本記事を記載するのにも役立っています。

# 工夫したこと

## DB への Write アクセスをユーザに意識させない

これは私のただの拘りなのですが、「送信ボタン」などを押さなくてもレビューしたデータを DB に格納できるようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105953/ee544927-c419-7a73-28c3-98077b6ada4a.png)

スライドバーをドラッグしたり、メモを書いたらユーザが何もせずとも DB に書き込まれるようになっています。
最初は `Slider` の `onChangeCommited` に DB アクセスするような関数を直接仕込んでいたのですが、サイドバーの表示・非表示を制御する際などにレンダリングが実行され `onChangeCommited` がその度にトリガされていました。

```typescript
<Slider
  // 中略
  onChangeCommitted={mikanTasteChangeCommitted}
  // 中略
/>
```

```typescript
function mikanTasteChangeCommitted(
  _event: React.SyntheticEvent | Event,
  value: number | number[]
): void {
  // DBへのWriteアクセス
}
```

たとえば、7 つのみかんを同時にレビューするページであれば、値を変更していなくともレンダリングの度に 7 回の Write アクセスが無駄に発生してしまいます。Cloud Firestore のクォータ制限に引っ掛かる[^2]ことが懸念されたので、`onChangeCommited` では現在 localStorage への書き込みを行なっています。

[^2]: 50,000 Read Access per day / 20,000 Write Access per day。実際、開発時に無限レンダリングで枯渇させたこともありました。

DB から最初に読み込んだときの値あるいは初期値を `useState` で state に保持しておき、localStorage の値と比較して差分があった場合に初めて DB に Write アクセスを行います。

```typescript
<Slider
  // 中略
  onChangeCommitted={mikanTasteChangeCommited}
  // 中略
/>
```

```typescript
function mikanTasteChangeCommited(
  _event: React.SyntheticEvent | Event,
  value: number | number[]
): void {
  // localStorageへの書き込み
}
```

```typescript
useEffect(() => {
  if (
    // stateとlocalStorageの比較
  ) {
    // DBへのWriteアクセス
  }
}, [debouncedPublicMikanReview])
```

`useEffect` のトリガ条件には、localStorage への debounce を実装しており、レビュー値を頻繁に変更してもその度に DB への Write アクセスが行われることはありません。

正直ここら辺は [useSWR](https://swr.vercel.app/) などのデータフェッチ用ライブラリを使うべき[^3]なんでしょうけど、独自実装してみることでその有難さを感じてみました。

[^3]: https://zenn.dev/kazuma1989/articles/a30ba6e29b5b4c

## 不正な DB アクセスを防ぐためのセキュリティルール

[公式ドキュメント](https://cloud.google.com/firestore/docs/security/get-started?hl=ja)が個人的には難読だったのですが、シミュレーションが行えるのでなんとかなりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105953/05b252d4-65c4-016c-fbb9-5c0cadcb2b38.png)

「[柑これ](https://tofuchic.github.io/kancolle/)」で DB に格納するデータには以下 3 種類のものがあります。

- みかんの基本データ
  - 名前や、「みんなの評価」として集計した値など
- 各ユーザが入力する、パブリックなレビューデータ
  - 味や食感といった数値化されたデータで、「みんなの評価」として集計する対象
- 各ユーザが入力する、プライベートなレビューデータ
  - メモ欄に記載されたテキストで、特に公開されない

これらのデータに対してクライアントがそれぞれどんな権限を持つべきかを表にしました。

||みかんの基本データ|パブリックなレビューデータ（自分のデータ）|パブリックなレビューデータ（他者のデータ）|プライベートなレビューデータ（自分のデータ）|プライベートなレビューデータ（他者のデータ）|
|---|---|---|---|---|---|
|未ログイン|Read|-|-|-|-|-|-|
|ログイン済み|Read|Read / Write|Read|Read / Write|-|

Cloud Firestore は、コレクションとドキュメントで構成される NoSQL です。
`mikans` コレクションに各柑橘毎のドキュメントを作成し、`name` などのフィールドを格納すれば「みかんの基本データ」となります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105953/8062a02c-5d24-fce2-5fd2-7e99e36e9270.png)

ドキュメントにはサブコレクションを作ることが出来るので、`public_reviews` サブコレクションを「パブリックなレビューデータ」に、`private_reviews` サブコレクションを「プライベートなレビューデータ」としました。`taste` が味、`texture` が食感に該当するフィールドです。
`mikans` コレクション下にあったドキュメント ID と違い、レビューデータ用のサブコレクションのドキュメント ID は、ユーザ固有の文字列になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105953/e208497c-1797-91f2-78c5-498f9a2bc9a3.png)

先ほどの権限割り当てをセキュリティルールで表現したものが以下になります。

```js
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{path=**}/mikans/{mikan} {
      allow read;
    }
	match /mikans/{mikan}/public_reviews/{public_review} {
      allow read: if request.auth != null;
      allow write: if request.auth.uid == public_review;
    }
    match /mikans/{mikan}/private_reviews/{private_review} {
      allow read, write: if request.auth != null && request.auth.uid == private_review;
    }
  }
}
```

`mikans` コレクションに対する match 文の文頭に `{path=**}` がありますが、これは[コレクショングループクエリ](https://firebase.google.com/docs/firestore/query-data/queries#collection-group-query)というものを使うための設定です。
ある一つのドキュメントではなく、特定の条件に合致するようなドキュメントを複数取得する際に使います[^4]。
「[柑これ](https://tofuchic.github.io/kancolle/)」の「[みんなの評価](https://tofuchic.github.io/kancolle/#/total)」ページで各柑橘を散布図にして表現するのですが、その際に使っています。

[^4]: Read アクセスが集約されるわけではないようなので注意が必要。n 個のドキュメントが取得されると、n 回の Read アクセスとしてカウントされることを確認しました。

# 課題

## 誤操作による DB Write アクセスを防ぐ

「[DB への Write アクセスをユーザに意識させない](#db-への-write-アクセスをユーザに意識させない)」は工夫したこととして挙げましたが、一方で誤操作によるスライドバーの編集がそのまま DB 更新に繋がってしまいます。
PC でのブラウジングであれば誤操作は滅多に起きないのですが、スマホでの利用の場合はページのスクロールのつもりがスライドバーの誤操作、というケースが起きます。
「送信ボタン」や「ロック機能」を作ることも考えたのですが、諦められません。
何か良い方法をご存知の方がいれば教えていただきたいです。

## 集計対象とするデータを見分ける

いざユーザのレビューデータを集計しようと思ったところ、次のように、集計すべきでないデータがあることに気が付きました。

- ユーザはまだレビューしていないつもりだが、DB には書き込まれているデータ

......はい。
これも「[DB への Write アクセスをユーザに意識させない](#db-への-write-アクセスをユーザに意識させない)」ようにしたことが主な原因ですね。

# おわりに

ここまで読んでくださった方、ありがとうございます。
以上のように、インフラ屋さんがアプリ開発にも手を出してみました、という記事でした。

1 か月ちょいの短工期で、コーディングしているのもたった 2 名、機能もシンプルということで、実はテストを全然行なっていないというのが現状です。
今後機能追加することになれば、テストが大事になってくると思っています。
コンポーネントを Storybook で管理したり、E2E テストを書いたりしたいですね。

SRE 的には、モニタリング基盤を作ったり、SLI・SLO を設定した上でのアラートルールも実装したいです。
（無料で使える APM 製品とかないですかね）