SWRで爆死を避ける。firebase Cloud FirestoreとNext.js。
はじめに
2か月前に「SWRを使おうぜという話」という記事を書きました。
Vercel謹製のSWRの便利さや導入の簡単さについて語りました。
そしてそれに感動を覚えたらとにかく使いたくなります。
だって既存のプロジェクトに導入するのも簡単だから(!)
Firestore
サーバーレスでのアプリケーションやデータをほとんど持たないwebサイトを作成するときは、私はfirebaseのFirestore(GCP)をよく利用します。
これもまたSWRなどと同じくとても簡単に導入できるので、各方面に推奨しております。
しかしFirestoreでの辛い点は、データの取得や更新に必要な手数の多さです。
通常の記載は下記。(※Typescriptを使用しております。)
投稿一つ型チェックするのにこのコーディング量!
type Categories = "ブログ" | "ニュース";
type Post = {
slug: string;
title: string;
category: Categories;
body: string;
createdAt: string;
updatedAt: string;
}
const validate = (data: any): data is Post => {
if (!(data.slug typeof data.slug === "string")) {
return false;
}
if (!(data.title typeof data.title === "string")) {
return false;
}
const cat: Categories[] = ["ブログ", "ニュース"] as const;
if (!(data.category cat.some((c) => data.category === c))) {
return false;
}
if (!(data.body typeof data.body === "string")) {
return false;
}
/** ...などのバリデーションを記載 */
return true;
};
const toFirestore = (post: Post): DocumentData => {
return {
slug: post.slug,
title: post.title,
category: post.category,
body: post.body,
createdAt: post.createdAt,
updatedAt: post.updatedAt
};
};
const fromFirestore = (
snampshot: QueryDocumentSnapshot,
options: SnapshotOptions
): Post => {
const data = snapshot.data(options)!;
if (!validate(data)) {
throw new Error('invalid!');
}
return {
slug: data.slug,
title: data.title,
category: data.category,
body: data.body,
createdAt: data.createdAt,
updatedAt: data.updatedAt
};
};
const converter = {
toFirestore,
fromFirestore
};
/** 取得側 */
const fetchPosts = async () => {
const _: Post[] = [];
const doc = await firebase
.firestore()
.collection('/posts')
.withConverter(converter)
.get();
if (doc.exists) {
doc.forEach((d) => {
_.push(d.data());
});
}
return _;
};
ここで私は、SWRをFirestoreを一緒に使ったらもっと楽なのに と思ってしまいました。
ですので、さっきのfetchPostsをuseSWRに渡してみます。
※SWRの使い方は公式ドキュメント及び前回の記事を参照ください。
/** 省略 */
const { data: posts } = useSWR('firestore/posts', fetchPosts);
SWRはポーリングフェッチを行う
しかしながらこれには大きな罠がありました。
SWRは数秒に一度データの検証を行い、差分を検知することで変更をフロントエンドに反映させることが出来る機能を持っています。
そしてこの機能はデフォルトでONとなっています。
※この機能はデフォルトではOFFとなっております。(コメント内でご指摘いただきました。ありがとうございます)
この時点で私は脳死状態で実装したため、firestoreの使用量を確認すると10件程度のドキュメントしかないにも関わらずわずか1時間たらずで800件近くのGET!
気づかずに本番環境で実行して、万が一にもサイトが跳ねた場合とんでもない取得量・使用量になり課金まっしぐらです。
今回の教訓は、SWRとfirestoreの相性はあまりよくない しっかりとドキュメントを読み込んでから実装する。
どうしても使用する場合は今回の場合で言えば
/**
* コメント内でご指摘いただきました。ありがとうございます
*/
const { data: posts, revalidate } = useSWR("firestore/posts", fetchPosts, {
refreshInterval: <number>
});
で再検証時間を適切に取る。
また、フォーカス時の再検証を行う場合は、
const { data: posts, revalidate } = useSWR("firestore/posts", fetchPosts, {
revalidateOnFocus: false,
revalidateOnReconnect: false
});
再検証を止めて、再フェッチしたいタイミングでrevalidateイベントを発火させる。
または、
const { data: posts } = useSWR("firestore/posts", fetchPosts, {
focusThrottleInterval: <number>
});
などでフォーカス時の再検証を許容する間隔に幅を取る。
などの方法を取ることができます。
今回は失敗の備忘録も兼ねて短編でした。
※コードの記載は記事用に即席のため、ミスがあったらごめんなさい。
2021/08/05 記事内のご指摘いただいたミスを修正致しました。
Discussion
これはちょっと言いすぎなんじゃないかなぁー🤔と思っています。
再検証機能によってfirestoreの使用量が多くなったのは、実装の問題であってSWRとfirestoreとの相性の問題では無いと思いますし、SWRは非同期処理の状態管理・キャッシュの管理・エラーハンドリングなどをやってくれているので、仮に再検証機能を無効にしたとしても使う価値は十分あると思います。
なので、「 SWRとfirestoreの相性はあまりよくない。」ではなく、「 SWRを使う時は再検証機能に気をつけよう! 」とか 「 ドキュメントはちゃんと読もう! 」などの表現が適切な気がします( タブン )。
ここからは余談ですが、
focusThrottleInterval
オプションで検証する期間( ポーリングフェッチする期間 )を指定することが出来ます。デフォルトでは5000ms
と、結構短めです。なので、このオプションを適切に設定する事で、再検証機能をより有効に扱えるようになると思います。
バリデーションについては、個人的にzodがおすすめです。
バリデーションの実装から型を生成できるので、バリデーションと型の実装が一気にできます。
( Transformersはとても便利な機能の一つです✨ )
最後に、SWRの再検証機能は気をつけないといけないのは事実なので、こうして記事にしてくれた事はとても参考になるモノでした! 私のプロダクトでも firestore( firebase ) + SWR を使っているので、実装を今一度見直してみたいと思います💪
ご返信ありがとうございます!
少し煽りが過ぎましたね・・・。
実際には実装の問題ですし私の失敗談なので、SWRに転嫁する書き方はご指摘の通り修正をさせていただきました。
その上でこれは反論ではなく個人的な見解なのですが、上記のようなfirestore自体の取得処理の冗長性から、Reactでの使用ではcontextやreduxなどでのストアで管理する実装が周りにも多く、firestore自体にsnapshot機能があるために、データの再検証自体は本体に十分な機能が備わっていると思っています。そこまで実装した上でSWRを使おうと思い立ったのはAPIサーバと同じ感覚で本当に脳死状態だったわけですが・・・😅
Next.jsに限って言えば、セキュリティルールでクライアントからのリクエストをすべて拒否した上で、
おっしゃったように
のようなオプションやmutationと一緒にAPI RouteでREST化して使う実装が一番手数も少なくて済みそうだな、とは思っています。
ご提案ありがとうございます!さらっと目を通させていただきました。触ったことのないライブラリでしたので、ご提案嬉しいです!
fierstoreしかりのクラウドDBや関数でサーバーレスが進む中、エラハン不慣れなデザイナやフロント周りの人へロジック組みましょう!なケースも個人的に増えてきているので、一層こんな単純なミスをしないよう気を引き締めなければならないですね😑
SWRを使用することになり、そのRevalidationの挙動を調べるにあたり、本記事が参考になりました。ありがとうございます。
ですが、調査する中でいくつか気になる点がありまして、質問させていただければと存じます。
本記事では、
とありますが、これはデフォルトではOFFになっていませんでしょうか。
といいますのは、この機能はfocusThrottleIntervalオプションではなく、refreshIntervalの設定により有効化されるもののように思われます。そしてこちらはデフォルトでは無効化されています。
またfocusThrottleIntervalはポーリングフェッチの間隔の設定値ではなく、仮にrevalidateOnFocusがtrueとなっていても、前回からfocusThrottleInterval経過していなければrevalidateしないようにする間隔ではないでしょうか。
これは、
(上と同じ公式ドキュメントにおける記述)
及びソースコード中でfocusThrottleIntervalが使われている部分
及び実際の動作を見て判断しました。
ご指摘ありがとうございます。
そのとおりですね!
期間指定でのデータ再検証は
refreshInterval
を利用するのが正解です。私の記述が間違っておりました。誤解を招いてしまい、大変申し訳ございませんでした。
該当部分を修正させていただきます。
私の視点から見ると、FirebaseとNext.jsをどのように組み合わせても、自分の足を撃つようなものです。常にうまくいかず、推奨されません。Next.jsを使うなら、Node.jsや他のバックエンドを使うべきで、Firebaseとは併用しない方が良いでしょう。Firebaseを使うなら、それを最大限に活用すべきです。すべてをクライアントで行い、サーバーサイドでレンダリングが必要な部分だけサーバーで処理する場合を除いて、Nextは必ずしも必要ではありません。
ご返信ありがとうございます。この記事は3年以上前のものであり、当時はNext.jsのApp RouterやReact Server Componentsなどの機能はありませんでした。加えてVercelなどのサーバーレスなホスティング環境から利用できる安価なデータベースの選択肢が少なかったため、Next.jsやNuxtなどのフロントエンドが出自のフルスタックフレームワークとFirestoreとを組み合わせて利用するケースの需要は多かったように思います。
当時のNext.jsでSSRを行う手段は
getServerSideProps
以外存在しなかったため、UIに必要なデータの一部をクライアントで取得する場合はfetch
を用いた実装や、Firestoreであればこの記事のようなSDKを使用した実装となります。現在であれば"use server"
とサーバーアクションでしょうね。今となっては、Next.jsを含むフレームワークや周辺技術も進歩し、データベースやホスティング環境にも安価な選択肢も増えたため、おっしゃる通り用途に合わせて様々な手段が取れるでしょう。