Firestore を使ってローカルの変更を Web ページに反映するデモを作る
このスクラップについて
このスクラップでは Firestore を使ってローカルで VSCode などでソースコードを変更した時に Web ページ上のエディタに反映するデモを作る過程を記録する。
Firebase アカウント作成
せっかくなので新しい Google アカウントを作成して始めよう。
Firebase プロジェクトの作成
久々に作成した。
Google Analytics を有効化したがしなくても良かったのかな?
料金プラン
まずは無料の Spark プランで良さそうだ。
Firestore データベース作成
asia-northeast1、本番環境モードで作成した。
Web アプリ作成
# create-next-app の最新バージョンに更新します。
npm i -g create-next-app
# Next.js プロジェクトを作成します。
npx create-next-app \
--typescript \
--tailwind \
--eslint \
--src-dir \
--import-alias "@/*" \
--use-npm \
firebase-sync-demo
catnose さんの記事により、App Router が安定するまで Pages Router を使い続けよう。
サクッと作ってみる
import { useState } from "react";
export default function Home() {
const [editorContent, setEditorContent] = useState("");
return (
<main className="container mx-auto px-4">
<h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
<label htmlFor="editor" className="block mb-2">
Editor
</label>
<textarea
name="editor"
id="editor"
rows={30}
className="border border-gray-400 w-full p-2"
value={editorContent}
onChange={(event) => setEditorContent(event.target.value)}
></textarea>
</main>
);
}
@tailwind base;
@tailwind components;
@tailwind utilities;
現状ではテキストエリアがあるだけ
Firebase アプリの追加
Web 向けのアプリを追加した。
Firebase インストール
npm install firebase
TTL を使えそう
変更をコンソールに表示する
import { deleteApp, initializeApp } from "firebase/app";
import { collection, getFirestore, onSnapshot } from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";
export default function Home() {
const [editorContent, setEditorContent] = useState("");
useEffect(() => {
const firebaseConfig = {
// ...
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const changesCollection = collection(db, "changes");
const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
console.log(snapshot);
});
return () => {
unsubscribe();
deleteApp(app);
};
}, []);
return (
<main className="container mx-auto px-4">
<Head>
<title>Firebase Sync Demo</title>
</Head>
<h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
<label htmlFor="editor" className="block mb-2">
Editor
</label>
<textarea
name="editor"
id="editor"
rows={30}
className="border border-gray-400 w-full p-2"
value={editorContent}
onChange={(event) => setEditorContent(event.target.value)}
></textarea>
</main>
);
}
現状ではセキュリティルールの設定不足のため permission denied になる。
セキュリティルール
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /changes/{change} {
allow read, create: if true;
}
}
}
変更の表示
若干コードを変更。
const unsubscribe = onSnapshot<Change>(changesCollection, (snapshot) => {
for (const docChange of snapshot.docChanges()) {
if (docChange.type === "added") {
console.log(docChange.doc.data());
}
}
});
これで無事に変更内容が表示されるようになった。
CLI の作成
せっかくなので別プロジェクトとして作成しよう。
mkdir ~/workspace/firebase-sync-cli
cd ~/workspace/firebase-sync-cli
npm init -y
npm install firebase
npm install -D ts-node @types/node
touch main.ts
ドキュメントの最大サイズ
1 MiB(1,048,576 バイト)なので 1 文字 80 行としても英数字なら 13107 行まで書くことができる。
CLI コーディング
import { deleteApp, initializeApp } from "firebase/app";
import { addDoc, collection, doc, getFirestore } from "firebase/firestore";
async function main() {
const firebaseConfig = {
// ...
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
await addDoc(collection(db, "changes"), {
content: "content",
expireAt: new Date(Date.now() + 1 * 60 * 60 * 1000),
});
await deleteApp(app);
}
main().catch((err) => console.error(err));
npx ts-node main.ts
実行すると Web の方でコンソールに内容が表示される。
expiredAt はタイムスタンプ型が良さそう
diff
通信データ量を削減するには diff にしても良さそう。
変更を反映する。
const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
for (const docChange of snapshot.docChanges()) {
if (docChange.type === "added") {
type Change = {
content: string;
};
const change = docChange.doc.data() as Change;
setEditorContent(change.content);
}
}
});
CLI を実行したらテキストエリアに変更が反映された。
CLI で watch
Node.js の fsPromise.watch を使えば良さそう。
変更が反映されるようになった
import { deleteApp, initializeApp } from "firebase/app";
import { addDoc, collection, doc, getFirestore } from "firebase/firestore";
import { readFile, watch } from "fs/promises";
async function main() {
const firebaseConfig = {
// ...
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const watcher = watch(__dirname);
for await (const event of watcher) {
if (event.filename === "main.ts" && event.eventType === "change") {
await addDoc(collection(db, "changes"), {
content: await readFile(event.filename, "utf-8"),
expireAt: new Date(Date.now() + 1 * 60 * 60 * 1000),
});
}
}
await deleteApp(app);
}
main().catch((err) => console.error(err));
TTL
後から試してみよう。
1/5 (金) はここから
もう少しデモを拡張していこう。
40 分近く経っている
onSnapshot() を呼び出す前にコレクションを削除したいと思い、どうやるのが良いんだろうと考えていたら 40 分近く経過してしまった。
tRPC セットアップ
そろそろサーバー側の処理が必要になりそうなのでセットアップする。
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@^4.0.0 zod
悩まず公式ドキュメント通りに
なんともう 2 時間も経っている
どうやって変更履歴を削除してから onSnapshot を呼び出そうかと試行錯誤してたらこんなに時間が経っていた。
結局はこのような形に落ち着いた。
import { trpc } from "@/utils/trpc";
import { deleteApp, initializeApp } from "firebase/app";
import {
collection,
deleteDoc,
doc,
getFirestore,
onSnapshot,
} from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";
export default function RemoveAllChanges() {
const mutation = trpc.removeAllChanges.useMutation();
useEffect(() => {
mutation.mutate();
}, []);
if (!mutation.isSuccess) {
return null;
}
return <Home></Home>;
}
function Home() {
const [editorContent, setEditorContent] = useState("");
useEffect(() => {
const firebaseConfig = {
// ...
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const changesCollection = collection(db, "changes");
const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
for (const docChange of snapshot.docChanges()) {
if (docChange.type === "added") {
type Change = {
content: string;
};
const change = docChange.doc.data() as Change;
setEditorContent(change.content);
deleteDoc(doc(db, "changes", docChange.doc.id));
}
}
});
return () => {
unsubscribe();
};
}, []);
return (
<main className="container mx-auto px-4">
<Head>
<title>Firebase Sync Demo</title>
</Head>
<h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
<label htmlFor="editor" className="block mb-2">
Editor
</label>
<textarea
name="editor"
id="editor"
rows={30}
className="border border-gray-400 w-full p-2"
value={editorContent}
onChange={(event) => setEditorContent(event.target.value)}
></textarea>
</main>
);
}
firebase-tools のインストール
コレクションの削除はサーバーから firebase-tools を通して実行できるようだ。
npm install firebase-tools
細かいことを気にしないなら
こんな感じの実装でも十分そうだ。
import { deleteApp, initializeApp } from "firebase/app";
import {
collection,
deleteDoc,
doc,
getDocs,
getFirestore,
onSnapshot,
} from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";
const firebaseConfig = {
// ...
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const changesCollection = collection(db, "changes");
export default function DeleteAllChanges() {
const [isDeleted, setIsDeleted] = useState(false);
useEffect(() => {
(async function () {
for (;;) {
const changesSnapshot = await getDocs(changesCollection);
if (changesSnapshot.empty) {
break;
}
for (const change of changesSnapshot.docs) {
await deleteDoc(doc(changesCollection, change.id));
}
}
setIsDeleted(true);
})();
});
if (!isDeleted) {
return null;
}
return <Home></Home>;
}
function Home() {
const [editorContent, setEditorContent] = useState("");
useEffect(() => {
const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
for (const docChange of snapshot.docChanges()) {
if (docChange.type === "added") {
type Change = {
content: string;
};
const change = docChange.doc.data() as Change;
setEditorContent(change.content);
deleteDoc(doc(db, "changes", docChange.doc.id));
}
}
});
return () => {
unsubscribe();
};
}, []);
return (
<main className="container mx-auto px-4">
<Head>
<title>Firebase Sync Demo</title>
</Head>
<h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
<label htmlFor="editor" className="block mb-2">
Editor
</label>
<textarea
name="editor"
id="editor"
rows={30}
className="border border-gray-400 w-full p-2"
value={editorContent}
onChange={(event) => setEditorContent(event.target.value)}
></textarea>
</main>
);
}
Headless UI
複数ファイルに対応できるようにするためにタブを使いたい。
npm install @headlessui/react
タブを追加してみる
import { Tab } from "@headlessui/react";
import { deleteApp, initializeApp } from "firebase/app";
import {
collection,
deleteDoc,
doc,
getDocs,
getFirestore,
onSnapshot,
} from "firebase/firestore";
import Head from "next/head";
import { useEffect, useState } from "react";
const firebaseConfig = {
// ..
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const changesCollection = collection(db, "changes");
export default function DeleteAllChanges() {
const [isDeleted, setIsDeleted] = useState(false);
useEffect(() => {
(async function () {
for (;;) {
const changesSnapshot = await getDocs(changesCollection);
if (changesSnapshot.empty) {
break;
}
for (const change of changesSnapshot.docs) {
await deleteDoc(doc(changesCollection, change.id));
}
}
setIsDeleted(true);
})();
});
if (!isDeleted) {
return null;
}
return <Home></Home>;
}
type Source = {
name: string;
content: string;
};
function Home() {
const [sources, setSources] = useState<Source[]>([
{ name: "Untitled1", content: "a" },
{ name: "Untitled2", content: "b" },
{ name: "Untitled3", content: "c" },
]);
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
const unsubscribe = onSnapshot(changesCollection, (snapshot) => {
for (const docChange of snapshot.docChanges()) {
if (docChange.type === "added") {
type Change = {
content: string;
};
const change = docChange.doc.data() as Change;
setEditorContent(change.content);
deleteDoc(doc(db, "changes", docChange.doc.id));
}
}
});
return () => {
unsubscribe();
};
}, []);
return (
<main className="container mx-auto px-4">
<Head>
<title>Firebase Sync Demo</title>
</Head>
<h1 className="mt-4 mb-4 text-2xl">Firebase Sync Demo</h1>
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
<Tab.List className="mb-4">
{sources.map((source) => (
<Tab
key={source.name}
className="px-2 py-1 mr-2 border border-gray-500 ui-selected:bg-gray-500 ui-selected:text-white ui-not-selected:bg-white ui-not-selected:text-black"
>
{source.name}
</Tab>
))}
</Tab.List>
</Tab.Group>
<label htmlFor="editor" className="block mb-2">
Editor
</label>
<textarea
name="editor"
id="editor"
rows={30}
className="border border-gray-400 w-full p-2"
value={sources[selectedIndex].content}
onChange={(event) =>
setSources((sources) => [
...sources.slice(0, selectedIndex),
{
...sources[selectedIndex],
content: event.target.value,
},
...sources.slice(selectedIndex + 1),
])
}
></textarea>
</main>
);
}
タブを選択すると別々のコンテンツが表示されるようになった。
Headless UI 初めて使ったが好みかも知れない。
MUI Base も気になる。
1/5 (金) はここまで
今日は 4 時間くらい時間を費やしたがやろうと思っていたことが全然できなかった。
まあ、色々と初めてのことにチャレンジしながら進めるとこんなもんか。
Firestore すごく便利で安くて素晴らしいんだけどコレクションの一括削除とかが簡単にできなくて、またその方法も広く使われているものがなくて辛い。
DynamoDB の時にも感じたが RDB 以外のデータストアを使おうとするとめちゃくちゃハードモードになる。
そういえば changes というコレクション名を使っていたが、channels/{channel}/messages みたいな構成にした方がやりやすそうなのでそのような方向性で考えてみよう。
参考になりそう
Firestore を使うのが辛くなってきた
まずは RDB で実装する方向で方針転換しよう。