はじめに
この記事は TypeScript Advent Calendar 2022 、 17日目の記事です。
最近は Ubiq というメディア系のベンチャーで開発をしています。Ubiq についてはぜひこちらのサイトをご覧ください!(宣伝)。
スマホアプリがメインプロダクトで、構成はフロントエンドが Flutter 、バックエンドが Firebase (Firestore Cloud Functions, Firestore) です。
私は主にバックエンドを担当しています。
開発している中で Fireschema というライブラリを採用したのですが、それがとても便利だったので今回紹介したいと思います。
そもそも Fireschema とはなんぞや
Fireschema は Firebase JavaScript SDK を拡張する形で作られた TypeScript 製ライブラリです。
以下の革命的な機能・特徴によって、システムの可用性および開発体験を大きく高めます。
- スキーマモデルで Firetore のコレクションを管理できる
- セキュリティルールを自動で生成してくれる
一言でいうと、「Firestore + スキーマ = 最強」です。詳しくは後の章で紹介します。
他にも、同梱されている React Hooks を使えば1行でデータを型付きで取ってこれたり、 Callable Function のリクエスト・レスポンスのバリデーション & 型付けができたりもします。
できることを列挙しているときりがないので、ぜひ作者の yamaimo さんが公開されている紹介記事も合わせてご覧ください!
なぜ Fireschema が必要だったのか
機能の説明をする前に、なぜ Fireschema が必要となり、導入したのかを説明したいと思います。
理由1. スキーマ変更に気づけない & 抜けてても普通に書き込めてしまう
Fireschema の導入を検討していたころ、私たちのプロダクトはまだ開発の初期段階であり、毎日のように設計変更でフィールドが新規追加されるというような状況でした。そうすると、新しくフィールドが追加されたことに気付かず抜けているというようなミスが必然的に発生します。しかし、Firestore はスキーマレスのため、そのような状況でも特にエラーになることなく書き込めてしまいます。それに辛さを感じたのが理由の一つです。
理由2. 型付きで読み書きできるようにするのが大変だった
TypeScript を使って開発している以上、型を使いたくなります。しかし、これもまた面倒でした。
import { collection, addDoc } from "firebase/firestore";
// まず型定義
type User = {
name: string;
age: number;
mailaddress: string;
};
// User型で書き込む情報を定義
const user: User = {
name: "Namae",
age: 22,
mailaddress: "test@example.com"
};
// 書き込む
const docRef = await addDoc(collection(db, "users"), user);
書き込みはまだいいですが、読み込み時に型を付けるようにするのはかなり面倒です。型付きで読み書きするには、withConverter
を使います。以下が例になります。
import { doc, setDoc } from "firebase/firestore";
// わざわざconverterのためだけに独自のクラスを作らないといけない 😇
class User {
constructor (name, age, mailaddress) {
this.name = name;
this.age = age;
this.mailaddress = mailaddress;
}
toString() {
return this.name+ ', ' + this.age + ', ' + this.mailaddress;
}
}
// converterを作る
const userConverter = {
toFirestore: (user) => {
return {
name: user.name,
age: user.age,
mailaddress: user.mailaddress
};
},
fromFirestore: (snapshot, options) => {
const data = snapshot.data(options);
return new User(data.name, data.age, data.mailaddress);
}
};
// refにwithConverterをつけて読み書きする
const ref = doc(db, "users", "xxxxxxxx").withConverter(userConverter);
await getDoc(ref); // 型付きで読み込まれる
await setDoc(ref, new User("Namae", 22, "test@example.com")); // 書き込み
つらいですね。特に、converter のためだけにクラスを作らなければならないのがしんどさ MAX です。
理由3. セキュリティルールの設定がだるい (特にバリデーション)
Firestore にはセキュリティルールというものがあり、読み書きの権限やバリデーションを設定することが出来ます。しかし、それを人力で書くのはとても面倒です。
バグや不正を防ぐためにもセキュリティルールは厳格に設定したい、しかしだるい。そんな気持ちと日々葛藤していました。
Fireschema でこれらの問題を一気に解決できる!
Fireschema を導入することで先ほど書いたような問題を一気に解決することが出来ます。それでは機能の説明です!
機能1. スキーマモデルで Firetore のコレクションを管理できる
Zod でスキーマを書ける
Fireschema はバリデーションに Zod を利用しているため、 Zod の
記法をそのまま使ってスキーマを定義することができます。
注意点として、タイムスタンプは Fireschema 独自の timestampType
を使用する必要があります。
import { z } from 'zod';
import { timestampType, DataModel, FirestoreModel } from 'fireschema';
import { Merge } from 'type-fest';
export const UserType = z.object({
name: z.string(),
age: z.number().min(13), // 13歳未満は登録できない
mailaddress: z.string().email(), // EMail形式のみ許容
createdAt: timestampType() // タイムスタンプは独自のtimestampTypeを使う
});
// 下に続く...
withConverter を葬り去れる
前の章で説明したwithConverter
では、型定義とは別に Firestore のためだけのクラスを作る必要がありました。しかし、Fireschema では Zod の定義をもとに型を生成しているため、その必要がありません。そのため、比較的シンプルに書くことができます。
// 上に続く...
// typeof や z.inferを使うことで型に変換できる
export type User = z.infer<typeof UserType>;
// タイムスタンプが独自なので、汎用的なDate型に変換する
type UserDecoded = Merge<User, { createdAt: Date }>;
// DataModelを作成する。withConverterのconverterのようなイメージ
// これは型付きでFirestoreを扱うために必要となる。
export const UserModel = new DataModel({
schema: UserType,
decoder: (data: User): UserDecoded => ({
...data,
createdAt: data.createdAt.toDate()
}),
});
// FirestoreModelを作成する。
// コレクション名・ドキュメント名・データモデル名・セキュリティルールなどを設定する
const firestoreModel = new FirestoreModel({
'function requestUserIs(uid)': `
return request.auth.uid == uid;
`,
collectionGroups: {
},
'/users/{userID}': {
model: UserModel,
allow: {
read: true,
create: 'requestUserIs(userID)',
update: 'requestUserIs(userID)',
delete: 'requestUserIs(userID)',
}
}
});
export default firestoreModel; // 別ファイルで読み込ませるためにexport
あとは firestoreModel
を TypedFirestoreUniv
に渡します。
import * as admin from "firebase-admin";
import { TypedFirestoreUniv } from 'fireschema';
import firestoreModel from "./firestoreModel"; // exportしたものをimportする
const createFirestoreStaticAdmin = (raw: any) => {
return {
arrayRemove: raw.FieldValue.arrayRemove.bind(raw.FieldValue),
arrayUnion: raw.FieldValue.arrayUnion.bind(raw.FieldValue),
deleteField: raw.FieldValue.delete.bind(raw.FieldValue),
documentId: raw.FieldPath.documentId.bind(raw.FieldPath),
increment: raw.FieldValue.increment.bind(raw.FieldValue),
serverTimestamp: raw.FieldValue.serverTimestamp.bind(raw.FieldValue),
Timestamp: raw.Timestamp,
};
};
export const db = admin.firestore();
export const typedFirestore = new TypedFirestoreUniv(firestoreModel, createFirestoreStaticAdmin(admin.firestore), db);
最後にこれを使うことで型付きの読み書きが実現できます。1行インポートするだけで済むため、素の Firebase JavaScript SDK を使うよりはだいぶシンプルに書くことができます。
import { typedFirestore } from "./typedFirestore"; // 最終的に使う時は1行インポートするだけで済む!
// 型付きで書き込み
await typedFirestore.collection("users").doc("xxxxxxxx").create({
name: "Namae",
age: 22,
mailaddress: "test@example.com"
});
// 型付きで読み込み
const userInfo = await typedFirestore.collection("users").doc("xxxxxxxx").get();
console.log(userInfo.data());
// 上書き
await typedFirestore.collection("users").doc("xxxxxxxx").update({
age: 23
});
// 削除
await typedFirestore.collection("users").doc("xxxxxxxx").delete();
…ところで、上のサンプルコードにはミスがあることに気付きましたか?
そうです、createdAt
が抜けていますね。このような抜けもしっかりエラーとして検出してくれます。ちなみにこの状態ではビルドもしっかりコケてくれます。
// 書き込み時には createdAt も必須なので、正しくはこうなる
await typedFirestore.collection("users").doc("xxxxxxxx").create({
name: "Namae",
age: 22,
mailaddress: "test@example.com",
createdAt: admin.firestore.FieldValue.serverTimestamp()
});
長くなりましたが、以上がひとつめの機能についての説明でした。
コードを見ても少し分かりにくいかと思うので、サンプルリポジトリを用意しました。
こちらも合わせて見て頂くと理解が深まるかな〜と思います。
機能2. ルールを自動生成してくれる
Fireschema には fireschemaModel
からセキュリティルールを自動生成する機能があります。次のコマンドを実行してみましょう。
$ npx fireschema rules src/fireschema/firestoreModel.ts
すると、ルートに firestore.rules
が生成されます。このファイルの中身は次のようになっています。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function __validator_meta__(data) {
return (
(request.method == "create" && data._createdAt == request.time && data._updatedAt == request.time)
|| (request.method == "update" && data._createdAt == resource.data._createdAt && data._updatedAt == request.time)
);
}
function __validator_keys__(data, keys) {
return data.keys().removeAll(['_createdAt', '_updatedAt']).hasOnly(keys);
}
function requestUserIs(uid) {
return request.auth.uid == uid;
}
match /users/{userID} {
function __validator_0__(data) {
return (__validator_meta__(data) && (
__validator_keys__(data, ['name', 'age', 'mailaddress', 'createdAt'])
&& data.name is string
&& (data.age is number && data.age >= 13)
&& data.mailaddress is string
&& data.createdAt is timestamp
));
}
allow read: if true;
allow create: if (requestUserIs(userID) && __validator_0__(request.resource.data));
allow update: if (requestUserIs(userID) && __validator_0__(request.resource.data));
allow delete: if requestUserIs(userID);
}
}
}
特に注目すべきは入力バリデーション部分です。必須のプロパティが渡されていること及び、その型が意図したものであることを検証するバリデータを自動生成してくれます。しかも、Zod の .min
で設定した文字数制限までしっかり反映されていますね。優秀すぎてもはや恐ろしい…
今後の可能性
Fireschema は自動生成の可能性も開いたと感じています。
現在公式で提供されているのはセキュリティルールの生成機能のみですが、私たちは Fireschema に Dart のモデルを生成する機能を実装して使用しています。
他にも、ドキュメントを自動生成する機能なども実装可能だと思います。
おわりに
Fireschema は素晴らしいライブラリですが、まだネット上に公開されている記事・採用事例が少なく、ぜひ使う人が増えたらいいなという気持ちからこの記事を書かせていただきました。今後さらにこのライブラリの良さが広まることを願っています。