Open15

firebaseを利用しているとき、独自のapi serverを作る必要はあるのか

みつきみつき

自分個人のタスク管理にこれまで色々なtodoアプリを使ってたが、色々使いづらいと感じたことがあったので自前で作ろうという話になった。

具体的なアプリ構成は置いといて、user検証の実装は手短にするためにgoogle identity platform(firebase authticationの拡張版みたいな感じ)を選択した。が、ドキュメントを読んでる時自分の頭の中では、自前でapi server、つまりbackendを準備するかどうかという問題が発生。

当然自前のapi serverをfirebase-admin-sdkをぶち込んでauthticationと連携し、uidだけ使って自前のuser tableでuser作って、そこで管理者や普通userなどに分けて、毎回userがログインする時に自身のuser tableと照合する管理手法もあるですが、ドキュメントによるとこれらの機能はすでにauthticationに含まれているらしく、そしてrealtime databseなども推奨されているので、今回はその試みをしてみようかと思う。

自前でdatabseとapi serverなどを用意せず、backendは全部firebaseを使用した実装を試みたい。

まだ途中だが、完全にapi serverなしのは無理っぽい、admin-sdkでしかできない操作があるので(例えばuser権限の変更とか)、でもdatabaseなどのものは自前で準備しなくていいらしい

cloud functionでほぼ全てのことができるらしい

みつきみつき

authticationは普通に検索すればclient appの実装記事がいっぱい出てくるので、割愛です。
まず実装に使うdatabseの選定ですが、今のfirebaseでは主にrealtime databsecloud firestoreの2つかと思います。
どっちも初めて触るので、ネット記事とドキュメントと勘を頼りに選ぼうかなと思う(適当

まずオフィシャルのドキュメントによると、

https://firebase.google.com/docs/database/rtdb-vs-firestore?authuser=0#which_database_does_firebase_recommend

https://techblog.kayac.com/rtdb-vs-firestore

で、比較していくのですが、

realtime database cloud firestore
modle 1つの大きいjson tree構造 collectionとdocumentを交互したtree構造
query いつもsubtree丸ごとを返す、sort OR filter sort AND filter、同時使用可
performance シングルリージョン マルチリージョン
拡張性 同時に200,000以上の接続と
毎秒1000以上の書き込みなら分ける必要ある(こっちが手動で分けるらしい)
自動スケーリング(今の拡張上限は100万の同時接続と1万回の毎秒)
価格 ストレージとトランスファーに対してのみ課金(何回読み書きをしても増えない) 主に読み書きに対して課金(ストレージ無料ではないが結構安いらしい)

筆者は初めてrealtime databaseとfirestoreを触るので、もし何か間違ったことは言ったらご指摘していただければ幸いです

みつきみつき

で、具体的に要件を考えていくのだが、soft deleteに対応したいので、伝統的になやり方はdeleted_atなどのフィルドをつけてfilterをかけるのが一般的ですが、ググってみたところ、noSQLではこのfilterするためのコードは結構ややこしくなるみたいです
firestoreでの他の解決法は1つのcollectionの中に2つのdocumentsを分けて削除したデーターと削除してないデーターを管理する方法もあるらしいだが、realtime databaseは通用するかどうかはわからない(そもそもrealtime databaseは1つのjson tree構造で成り立ってるので、このような管理を行うには

{
  data: {}
  data_del: {}
}

になるのか?、もしこのような手法で管理する場合cloud functionでdataから消して、data_delにコピーするコストはどうなるのかはいまいち分かってない...

https://stackoverflow.com/questions/56052605/modeling-data-for-soft-deletes-in-firestore

みつきみつき

一般userにも使ってもらう予定も一応あるっちゃあるだが、そこまで多くの人がこのアプリを使ってもらう見込みは無さそうなので、マルチリージョンは別に必要ではないかなと
あとでuserが増えたらデーター移行は死ぬほど辛いかもしれないだが、その時の自分に任せることにしよう(x

簡単に要件を並べていくと(超適当

  • グループtodoと個人todo
  • グループtodoはグループ管理者でしかtodoを消せない
  • グループtodoにアサインなどの機能をつける...かどうかはまだわからない
  • soft delete
  • todoにはmemoが保存できる
  • memoは100文字などの文字数上限を決めたい(中にurlを埋め込むことはできるだが、urlは文字数にカウントしない予定です
  • memoに画像を入れられ...(今は要らないかも、使って画像が必要になったらcloud storageで入れる予定
  • clientはクロースプラットフォームのwidgetベース、とにかくいつでも簡単に見れるようにする

client側はまた他の要件はあるのだが、この記事とは関係ないので割愛

で、結論だが、nosqlは詳しくないので悩んでも無駄なので、一応realtime databaseで進めてみようかと思う
以下の記事によるとrealtime databaseはすでに過去形になったぽいのでfirestoreで進むことにした、これだといつかデーター拡張や移行を考えなきゃいけない未来の自分のストレスも減るはずです(よかったね
https://qiita.com/1amageek/items/64bf85ec2cf1613cf507

queryはfilterだけ使って、sortはclientでやる予定です。
soft deleteは伝統的な方式を選んで試してみる
もしsoft deleteのためのqueryは複雑になりすぎたら、また他の方法を考えるにしよう

みつきみつき

https://firebase.google.com/docs/auth/admin/custom-claims

で、まずuserのコントロールについて考えていくのだが、cloud fcuntionでon createの時にcustom-claimsを作ればいいらしいので、これで実践して行こうかなと思う
(でも後から更新する場合は必ずadmin-sdkを経由しないといけないらしいので、最小限のapi serverはやはりいるのか...?

みつきみつき

論理削除についてはまた他の記事を見つかったのでとりあえずメモ

https://tech.gamewith.co.jp/entry/2020/12/08/210746

firestoreでも論理削除をすることもできますが、削除フラグを持ってしまうと、削除フラグを含めた複合indexをたくさん作る必要がでてきます。 firestoreはindexも課金対象であるため、できれば避けたいです。
今回の要件として、削除された対象のログをあとから追跡できれば良かったので、削除したログを残すコレクションを別途用意し、取引情報のほうは物理削除することにしました。

らしいです

大体stackoverflowの回答と同じ感じですね

みつきみつき

cloud functionにluxonを導入してtime関連の処理をしたいので、まずruntimeと依頼関係について調べてみた

https://cloud.google.com/functions/docs/writing/specifying-dependencies-nodejs?hl=ja

https://firebase.google.com/docs/functions/handle-dependencies

結果から言うと直接npm installすれば良いみたい。簡単でよかった。なのでsoftDeleteのfunctionは以下になるかなと思う。

import {DateTime} from "luxon";
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

const app = admin.initializeApp();
const fdb = admin.firestore(app);

export const softDelete = functions
  .region("asia-northeast1")
  .firestore.document("todos/v1/{userId}/{todoId}")
  .onDelete(async (snap, context) => {
    const {userId, todoId} = context.params;

    // if ttl deleted files, do nothing.
    const deletedReg = /_deleted$/;
    if (deletedReg.test(userId)) {
      functions.logger.info(`${userId}/${todoId} is really deleted now.`);
      return;
    }

    // add new filed to data for ttl.
    const data = snap.data();
    const now = DateTime.now();
    data.deletedAt = now.toJSDate();
    data.expireAt = now.plus({days: 30}).toJSDate();
    fdb.doc(`todos/v1/${userId}_deleted/${todoId}`).create(data);
    return;
  });

これで{userId}の中の{todoID}が削除される度に、{userId_deleted}にコピーされて、普通の検索手段だと検索出来ないように出来たのて、soft deleteは一応設定出来ました。

が、料金などを考えて、soft deleteしたデーターにTTLを設定して、生存時間(例えば30日)を過ぎたら削除したいと思います。しかし、今のfirestoreのTTLだと、1つのcollectron groupに対してしか設定出来ないので、今のsoft deleteの構成だと、1つのcollectionの中に、複数のdocを持たせて、1つ1つのdocが違うtodoになっていると言う感じなので、この状態でTTLをかけたら、違うタイミングで削除されたものが同時に消されてしまうので、何か他の策を考えたい。

https://firebase.google.com/docs/firestore/ttl

TTLの挙動については、collectionのあるfiledをttlとして設定し、そのfieldの時間になったら自動的にcollectionが全て消されます(のようです)

独自でfunction作ってpubsubする方が良さそうかもしれない。

https://cloud.google.com/blog/ja/products/databases/manage-storage-costs-using-time-to-live-in-firestore

嘘つきました。この記事によるとcollectronの中のdocのfiledによって各自に削除するらしい。なので直接使って良いかな?

みつきみつき

で、試してみたところ、1つのcollectionの下の全てのdocに対してttlをかけるのが正しいだが、今の構成だと、1個1個のuserId_deletedに対して全部ttlをかけないといけないので、soft deletedの構成を変えるか、ttlを使わず、自作functionにするか2択になった。

corn自作functionだと、一気に溜まった全てのものを削除すると、パフォーマンスへの影響も考えないといけないので、考えないといけないことが結構多そうなので一旦放置。
soft deletedの構成を変える方で進めようかなと思う。

一番簡単な方法はやはり伝統的なdeleted_atを持たせる方法かなと思うので、まずこの方法でqueryがどのくらい複雑になるのかとみてみたいと思う。

みつきみつき

自分がアホだった。deleted_atを持たせる方法も、別のcollectionに移す方法も、{userId}の下にある限り、全ての{userId}に対して1個1個ttlかけなきゃいけない。結果どれも同じだったわ。

ttl使いたい場合は1つの大きいtodosの中に全てのtodoを持たせ、todoの中にuserIdを持たせるべきだが、これだとnoSqlのpathの便利性がなくなるし、セキュリティの面で他ユーザーのtodoへのアクセスを弾けないのも嫌だな。

考えた手段としてはtodos/v1/{userId}/{todoId}でuserごとtodoを保存するのは良いと思って、削除する場合は、ゴミ箱みたいなところを作ってそこに移動するのが一番いいと思った。例えばtrash/v1/todos/{todoId}の中にuserに構わず全てのtodoをここに置く、これでtodosに対してttlをかければ万事解決。transhにcopyする時のsoft delete functionにuserIdのfieldを付け加えば、復元する時はqueryにwhereをつければできるしこれで行こうかと思う。でもこれもセキュリティー面でのアクセス制御が効かないが、transhなので仕方なく受け入れます。

また嘘つきました。できるわ、読み取るデーターのfieldによってアクセスを制御するルール。

https://firebase.google.com/docs/firestore/security/rules-conditions

もっとdocを読むべきだったね(死ぬ
じゃあこれで最終的な構成が決められる。
private/v1/todos/{todoId}で全てのtodoをここに置く、そして中にuserIdのfieldを足す
trash/v1/todos/{todoId}でsoft deletedされたtodoをここに置く、コピーするときにdeleted_atとexpire_atの2つのfiledを追加

データーの保存位置は必ずdocでなきゃダメなので(つまり偶数層)、privateをつけてtodosを保管するようにした。公開しないし、ログインしないとみれないようにする予定なので。

みつきみつき

今の段階のrulesファイルはこうなってる

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /private/v1/todos/{todoId} {
      allow read, write: if request.auth != null && request.auth.uid == resource.data.user_id;
    }
    match /trash/v1/todos/{todoId} {
      allow read, write: if request.auth != null && request.auth.uid == resource.data.user_id;
    }
  }
}

構成変えたのでfunctionも応じて変える

export const softDelete = functions
  .region("asia-northeast1")
  .firestore.document("private/v1/todos/{todoId}")
  .onDelete(async (snap, context) => {
    const {todoId} = context.params;

    // add new filed to data for ttl.
    const data = snap.data();
    const now = DateTime.now();
    data.deleted_at = now.toJSDate();
    data.expire_at = now.plus({days: 30}).toJSDate();
    fdb.doc(`trash/v1/todos/${todoId}`).create(data);
    return;
  });
みつきみつき

これでtodoの基本的なcrudは完成できたのかなと思う。

これから考えるのはrulesのテストとユーザーグループのごとかな。

みつきみつき

よく考えたらgroup todoを無くす方向にしたいと思う。別に仕事管理とかに使ってもらう予定はないし、あくまで俺自身が自分のtodo管理で使いたいからこれを作り始めたので、変な要件を入れないようにしたいなと思い始めた。なので、要件の再整理です。

  • todo
    • 重要度+日付並び(そしてユーザーはこの並びを変えられないようにする
      • なぜなら、"Any color you want, so long as it is Black."デス
      • 人がやるべき事は今やらなければならないことではなく、今はそこまで急ぐわけではないがいずれ大きいメリットをもたらしてくれることだからだ。
    • soft delete
    • todoにはmemoが保存できる
    • memoは100文字などの文字数上限を決めたい(中にurlを埋め込むことはできるだが、urlは文字数にカウントしない予定です
    • memoに画像を入れられて、入れた画像を展開できる
  • keep
    • 定期的に通知してくれるもの、例えば2時間経ったら立ってちょっとした運動をするみたいなリマインド
みつきみつき

firebase authticationが新しくなった(V9)、しかしauth.onAuthStateChangedauth.onIdTokenChangedの挙動が違うので、結構はまりました。

ユーザーのメール認証を作るとき、

  1. メールとパスワードを入力してログイン
  2. ユーザーのメール認証をチェックしてもし認証していなければ認証するためのリンクを送る
  3. 認証したらdeeplinkでアプリに戻し、userを更新する
  4. 更新したユーザーは検証済みなのでこの後普通に処理を続けます

しかしここでのユーザーを更新するフェーズにめっちゃはまった。

firebaseさんのドキュメントにこう書いてました。

https://firebase.google.com/docs/reference/js/auth.auth.md#authupdatecurrentuser

https://firebase.google.com/docs/auth/web/start

うむうむ。updateCurrentUseronAuthStateChangedをトリガーするので、これでユーザーの更新ができると。

でも実際にやってみたところ、これは 行けない んですよね。

https://github.com/firebase/firebase-js-sdk/issues/2529

なぜかわからんが、updateCurrentUseronAuthStateChangedをトリガー しない んですよね。

色々試しましたが、auth.currentUser.reload()でもトリガーしないしauth.currentUser.reload()でもトリガーしないんですよね。

正直今の段階でログインし直す以外にこれをトリガーする方法ってないぽいんですよね。

でもなんでドキュメントにトリガーするって書くんだよfierbaseさん!

最後の解決法はこれを参照にしながら書きました。

https://stackoverflow.com/questions/47243702/firebase-token-email-verified-going-weird?answertab=votes#tab-top

結論から言うとonAuthStateChangedの古いバージョンの、onIdTokenChangedauth.currentUser.reload()ユーザーsignin、signoutにも対応しているらしいので、手順としては

  1. ログインチェックをこれに変える
  2. ユーザーを更新するときはまずauth.currentUser.getIdToken(true)を呼びましょう、これを呼んでまず強制的にjwtのtokenを更新します
  3. 新しいtokenが帰ってきたらauth.currentUser.reload()を呼んでonIdTokenChangedをトリガーしましょう

三時間ぐらいかかった、間違ったドキュメントの力は破壊的だったね...