🍍

KANNAを支える通知基盤を支えるFirestoreの話

KANNA開発のお手伝いをさせて頂いているoubakiouです。

アルダグラムの技術スタックについて
https://zenn.dev/aldagram/articles/a44f041eedf5ef
でも触れられている通りKANNAのバックエンドではGraphQLサーバーが動いていて、Ruby on RailsのAPIモードGraphQL Rubyの組み合わせが使われています。

このGraphQLサーバーでは主にMySQLを扱っていますが高いWrite負荷が発生する一部の機能、具体的にはチャット機能や通知基盤(Push通知、メール通知、デスクトップ通知、アプリ内お知らせを統合的に扱うAPI)ではFirestoreCloud Functionsを利用しています。

KANNAにおいて通知を発生させる様々なアクション、例えば報告の追加などはGraphQLのMutationを介してRailsが処理をしていますが、その事を知らせるpush通知などはRailsからCloud Functions(TS)上の通知APIへ依頼されます。そして依頼を受けたfunctionsはFirestore上の通知キューにチケットを追加します。


(Built with Firebase)

通知キューにチケットが追加されるとそれをトリガーとしてチケットを元にした実際の配信処理(Push通知、メール通知)が行われる、というのが一連の流れです。

なお通知基盤が提供する機能のうちアプリ内お知らせ表示とデスクトップ通知に関しては、Railsやfunctionsといったバックエンドを介さずにブラウザやアプリが直接Firestoreを読んでいます。これはRDB上のデータを元にした複雑な権限チェックが絡むチケットの作成とは異なり、firestore.ruleのみで権限チェックが可能な機能だから実現出来ている事です。

このようにKANNAを安定して(しかも安価に)支えてくれている素晴らしいFirestoreですが弱点が無いわけではありません。


1. GraphQLやgRPCのような型サポートが標準では提供されていない

KANNAのフロントエンドで利用しているReact(Next.js)やReactNativeのようなTypeScriptクライアントからFirestoreを快適に扱える型サポートが標準では存在しません。Firestoreがそもそもスキーマを持たないDBなので自然な話ではあるのですが、スキーマ設定できるオプション機能やHasuraのようなお手軽ソリューションも個人的には欲しくなる所です。

標準で存在しなければ自力でどうにかする必要があるというわけで

// Firestoreドキュメントから取得したオブジェクトを元にインスタンスを生成して返す
export const fromData = (
  data: unknown
): Notification | undefined => {
  if (!dataIsNotificationData(data)) throw new Error('unknown NotificationData')

  const createdAt = fromUnknownTimestamp(data.createdAt)
  if (!createdAt) {
    throw new Error('unknown NotificationData.createdAt')
  }

  return new Notification({
		firebaseUid: data.firebaseuid
    createdAt: createdAt
  })
}

// type guard
export const dataIsNotificationData = (
  data: unknown
): data is NotificationData => {
  const d = data as NotificationData
  if (typeof d?.firebaseUid !== 'string') return false
  return true
}
export type NotificationData = ReturnType<Notification['toHash']>

// entity
export class Notification {
  readonly firebaseUid: string
  readonly createdAt: KNDate // Firestoreの生タイムスタンプとjsのdateを扱う独自の日付クラス

  // 各種メソッド中略
  getUnreadCount = (): number => this.getUnreadMessages().listLength

	// Firestoreに保存する形式でオブジェクトを生成して返す
  toData = <OT>(dateToOuterTimestamp: (date: Date) => OT) => ({
    firebaseUid: this.firebaseUid,
	  createdAt: dateToOuterTimestamp(this.createdAt.date),
  })
}

のようなコード(これは簡略化したイメージです)を書き、プライベートnpmパッケージ化してweb版KANNA、アプリ版KANNA、通知基盤というFirestoreを触る3リポジトリ間で共有しているのが現状です。

将来的にFirestore以外の何かへ移行する可能性も考慮し、npmパッケージとしてはfirebaseパッケージfirebase-adminパッケージに依存しないよう書いているせいで余計に冗長になっている部分もありますが、これは中々骨が折れます。

(とはいえ2022現在であれば生成・保存周りはwithConverterのインターフェースに合わせたほうが良い)

2. firestore.ruleの記述が非直感的で分かりにくい

クライアントサイドから直接Firestoreへアクセスする場合の権限やバリデーションについてはJavaScript風DSLでfirestore.ruleファイルに記述する事になりますが、これは読みにくく書き辛いと感じています。

特にDcoument内Array(DocumentのCollectionではなく)に対するバリデーションを記述する際などはかなりトリッキーな書き方になります。

そしてこれもまたFirestoreがそもそもスキーマを持たないDBなので自然な話ではあるのですが、1でスキーマを記述した上でそのスキーマから自明なバリデーションルールであっても自力で記述する必要があるのはちょっと不毛感があります。

3. 全文検索のための機能が標準では提供されていない

Firestoreは自身では全文検索のための機能を提供していません。自力でN-Gramインデックスを作成してそれを利用するという手法も無くはないのですが、ドキュメントでは外部サービスの利用勧めています。

現代のMySQLは(昔と違って)標準で全文検索が可能でかなりカジュアルに扱う事ができるので、それと比較するとFirestoreにも欲しくなる所です。

なおKANNAではチャットの検索機能にはElastic Cloud(ElasticSearch)を利用しています。

4. データ設計にRDBとは異なるノウハウが必要になる

これは1、2、3に比べるとどうしようもない部分ではありますが、RDBとは色々な部分で明確に違うため独特なノウハウが必要になります。例えばデータ設計に影響を与える最も有名であろう制限としては「1つのドキュメントに対しては1秒間に約1回しか更新できない」というものがあります。

また基本的には良い話なのですがFirestoreはRead数やWrite数等による明朗会計のため、RDBに比べると設計段階でかなり細かくランニングコストを試算する事ができます。

そしてランニングコストを下げるチューニングをしようとするとRDBで言う所の非正規化のような手法も選択肢に入ってきますが、これをするとやはりデータ整合性のための追加コストがコードベース上に発生するなど将来の拡張性に影響を与え得ます。

その結果「この機能のランニングコストを1/10に下げるために拡張性を犠牲にしたとして将来トータルでペイできるのか?『早すぎる最適化』になってはいないか?」というような意思決定の場面にたびたび遭遇する事になります。

これは不確実性を含んだとても難しい問題です。(楽しい問題でもあります)


KANNAを支えてくれているFirestoreについて紹介しました。

今回は弱点ばかりを上げていますが、これらは不満な点というよりも今後のアップデートに期待している要望リストであって、現時点でも用途がマッチすれば他に代替が無いぐらい素晴らしいサービスだと私は思っています。

アルダグラム Tech Blog

Discussion