今回は、React+TypescriptでNoSQLであるFirestoreを扱ってみる技術ログになります。新しい機能として、Typescriptの型を適応することができる【FirestoreDataConverter】を発見したので扱ってみようと思います。内容としてはTodoのCRUD処理を実装しています。
初めに
今回この記事で分かることは以下になります。
- React+Typescript+Firestore(v9)環境ででのCRUD処理の基本
- FirestoreDataConverterを用いてFirestoreのやり取りに型定義を導入する
サンプルコードは下の方にあるので、このブログは下から読みましょう。心に余裕のある人だけは、上から読んでも問題ありません。
どもども、お疲れ様です。3月も折り返しに差し掛かりぬるっと生活をしている龍ちゃんです。前回は、認証基盤として【Firebase】を利用しました。(技術記事:「React+Recoil+Firebase認証ページ【Typescript】」)合わせてDBを使用したいとなったときに、同一のサービス内で扱える【Firestore】というものがあるので、そこを紹介する記事を書いていきます。
アプリを作成していく上で、フロントエンドだけではどうしようもない場面は結構ありますよね。でも、自分でAPIを組むのはちょっと大変というフロントエンドエンジニアはいっぱいいると思います。そこで、外部サービスという選択は一般的な手段です。最近ちょこっと触ってみたサービスを以下に列挙しておきますね。
- Azure AD B2C
- Firebase
- microCMS
上記に書いているのは、「Azure・Firebase」が認証基盤として、「microCMS」はCMSとして利用してみました。やはりすべて自分で作成するよりも圧倒的に開発体験が上がります。フロントエンドエンジニアとして、やるべきところに注力することができるという訳ですね。ちなみに、やってみたことは全てブログに書いているので(現在執筆中もあるよ)、ぜひ読んでみてください。下にリンクだけ並べておきます。
それでは本題に入っていきましょう。今回は以下の流れで進めていきます。
- NoSQLの特徴について
- Firestoreでのデータアクセス方法について
- CRUD :FirestoreDataConverterを添えて
NoSQLって?ざっくりと解説
いきなりNoSQLという単語が出てきて混乱した人のために、僕の勉強もかねてざっくりとまとめていきたいと思います。とりあえずRDBとNoSQLという二つのキーワードがあるところから始めていきましょう。
とりあえず、それぞれについてAIに聞いてみました。
RDBとは、リレーショナルデータベースの略称です。リレーショナルデータベースは、データをテーブルの形式で管理するデータベースの一種であり、SQLを使用してデータを操作します。RDBは、データの整合性を確保するためのトランザクションやACID特性をサポートすることができます。
NoSQLとは、リレーショナルデータベース(RDB)の代替として開発された非関係型データベースです。NoSQLデータベースは、データの構造が自由であり、大規模な分散システムでのスケーラビリティが高く、高速な読み取りと書き込みを提供します。リレーショナルデータベースと比較して、NoSQLデータベースはより柔軟で、よりスケーラブルであると考えられています。
理解しやすいところだけ、抜き出して図にすると以下になります。
今回の対象である【Firestore】は、【NoSQL】なのでそちらを重点的にお話していきますね。【NoSQL】はデータを入力するクライアントから、どういった情報を登録するかを選択することができます。なので使用方法としては、以下のパターン全てに対して自由に対応することができます。
- とりあえずこの配列保存しておきたいな~($・・)っ~~~
- ユーザ情報を保存しよう、ついでにユーザーのアンケートも保存したい
- 掲示板みたいな自由なテキスト
その代わり、何をどういった形式で保存するかというのは自分で設計しなければなりません。結局設計が大切なわけですね。もし設計なしで使用した場合で最悪の場合に「ユーザー情報の中になんか重要そうな配列情報が保存されている」みたいなことが起きかねないわけです。
ざっくりと説明していきました。もっと内容まで突っ込みたい方は、独自でお調べお願いします。僕もそのうちまとめておきます。
それでは、本題である【Firestore】の方の説明に入っていこうと思います。
Firestoreにおけるデータの取り扱い
コードの説明をする前に【Firestore】のデータの取り扱いについて説明を入れていきます。公式の記載から情報を持ってきます。引用元はこちらです。データを取り扱うためには「collection/document/data」という三つの言葉を理解する必要があります。その情報を理解するための図が以下になります。
collectionはフォルダ、documentはペーパー、dataはデータとして表現されています。英語だと都合が悪いので日本語に変換して解説を行っていきます。しっかりとした解説は公式に任せて僕が理解しやすかった形で解説をしていきます。
- コレクション:フォルダのようなイメージで、ドキュメントを複数保存する
- ドキュメント:ファイルのようなイメージで、データまたはフォルダを保存する
- データ:key-valueで値を保存する
言い換えとしては、図のままになっています。【Firestore】では、コードレベルでデータの場所と条件を指定する必要があります。大まかな流れとしては以下のイメージです。
- コレクションの中の○○というファイル名のデータを取得する
- コレクションの中のファイルをすべて取得する
- コレクションの中の特定条件に一致したファイルをすべて取得する
「コレクションを指定して~」というのが順番になります。【RDB】の場合はテーブル名を指定しますが、【Firestore】ではコレクション名を指定します。コレクションをフォルダで例えましたが、フォルダ自体はたくさん作ることができます。【RDB】で言うところのテーブルをいっぱい作ることができるみたいなイメージと一緒ですね。
では実際の使用感を【React】のカスタムHooksの形にまとめておいておきます。初期セットアップはこちらからどうぞ。
CRUD処理:FirestoreDataConverterを添えて
それではカスタムHooksにまとめたコードを置いていこうと思います。今回すごく勉強となった点としては以下になります。
- Typescript の型をFirestoreに義務付けることができる【FirestoreDataConverter】
- データを送信・受信で渡る前に共通処理を挟むことができる(変換とか)
Typescript 環境で【Firestore】を使用する場合は、型定義を挟み込む工夫が大変だったのですが、これでだいぶ楽ができます次、VScode上でも型定義が認識されるので大変楽です。
初期設定
この辺りはサクッと流しても大丈夫だと思いますが、念のために書いておきます。公式のサンプルはこちらです。
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FB_APIKEY,
authDomain: import.meta.env.VITE_FB_AUTHDOMAIN,
projectId: import.meta.env.VITE_FB_PROJECT_ID,
storageBucket: import.meta.env.VITE_FB_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FB_MESSAGEING_SENDER_ID,
appId: import.meta.env.VITE_FB_APP_ID,
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const provider = new GoogleAuthProvider();
const db = getFirestore(app);
export { auth, provider, db };
CRUD処理
それではCRUD処理のカスタムHooksのコードを張ります。
import {
addDoc,
collection,
deleteDoc,
doc,
DocumentData,
FirestoreDataConverter,
getDocs,
QueryDocumentSnapshot,
serverTimestamp,
setDoc,
SnapshotOptions,
} from 'firebase/firestore';
import { db } from '@/auth/authFirebase';
export type todoType = {
uid: string;
text: string;
timestamp?: Date;
done: boolean;
};
export const useFireStore = () => {
const todoConverter: FirestoreDataConverter<todoType> = {
fromFirestore(
snapshot: QueryDocumentSnapshot,
options: SnapshotOptions
): todoType {
const data = snapshot.data(options);
return {
uid: snapshot.id,
done: data.done,
text: data.text,
timestamp: data.timestamp.toDate(),
};
},
toFirestore(todo: todoType): DocumentData {
return {
text: todo.text,
done: todo.done,
timestamp: todo.timestamp ? todo.timestamp : serverTimestamp(),
};
},
};
const createTodo = async (todo: todoType): Promise<void> => {
const collRef = collection(db, 'todo').withConverter(todoConverter);
await addDoc(collRef, todo);
};
const readTodo = async (): Promise<todoType[]> => {
const collRef = collection(db, 'todo').withConverter(todoConverter);
const snapshot = await getDocs(collRef);
const result = snapshot.docs.map((doc) => doc.data());
return result;
};
const updateTodo = async (todo: todoType): Promise<void> => {
const collRef = collection(db, 'todo');
const docRef = doc(collRef, todo.uid);
await setDoc(docRef, todo);
};
const deleteTodo = async (uid: string): Promise<void> => {
const collRef = collection(db, 'todo');
const docRef = doc(collRef, uid);
await deleteDoc(docRef);
};
return { createTodo, readTodo, updateTodo, deleteTodo };
};
CRUDと言っていますので、「create/read/update/delete」の順番に合わせて定義をしてみました。自分でこねくり回してやってみるのが一番わかる方法だと思いますが、自分なりの理解をぶら下げていこうと思います。
まず注目してほしいのは、すべての関数の一行目でコレクションを取得している点です。それぞれの処理を日本語に直すと以下になります。ここではコレクションをフォルダに例えています。
- createTodo:挿入先のフォルダ名を取得して、ドキュメント(データ入り)を挿入する
- readTodo:取得先のフォルダ名から、すべてのドキュメントを取得して、データを取得する
- updateTodo:指定のフォルダから、一致するドキュメントを探して、データを変更する
- deleteTodo:指定のフォルダから、一致するドキュメントを探して、ドキュメントを削除する
日本語の句読点の位置が、処理の切り分けになっています。【Firestore】ではSQLが関数になったように直感的に処理を記述することができます。
ちなみに処理をサクッと簡略化することもできます。今回は、説明のために丁寧なコーディングをしましたが、いきなりドキュメントを指定する方法もあります。その辺はお好みですね。
FirestoreDataConverterについては、僕もあいまいな理解しかしていないので、今時点での理解をぶら下げておきますね。
- fromFirestore:Firestoreから来たデータのときにデータの整形などを行う
- toFirebase:Firestoreにデータを送信する前に処理を挟んだり整形(切り貼り)したりする
今回は、【Firestore】に入れる時間の情報を変換する処理と値がない場合に現在の時間を入れる処理を行っています。ついでに、Typescriptの型が定義済みで情報が返ってくるのが利点です。これは、「うまく使えれば便利になるな~」って感想を持ったぐらいですけど、いいたとえ話が思いつかなかったので皆さん使って教えてください。ちなみに僕はこちらの記事を読んだ時に初めて知りました。
終わりに
お疲れ様です。今回は、【Firestore】の基本的な使い方と【FirestoreDataConverter】を発見した報告をしておきました。【Firestore】の導入をためらう理由の一つに型定義を扱うのが面倒だからというのがあったのですが、これで問題がなくなりましたね。型セーフな開発を行えるようになると思います。せっかくTypescriptを使うので、型は極力強制していきたいものです。
さて、それではまた何かしら発見したらまとめていきます。
文章はハチャメチャでもコードはコピペで動くことを意識して進めていきます。
ではまた~