Firestore + Cloud Functions + ApolloでGraphQL Serverを建てる
はじめに
Firestore にはリアルタイムリスナーというとても素敵な機能があり、コンテンツが更新される度に再fetchが走るので、クライアント側での状態管理は Context API によるグローバル state + props バケツリレーという形に寄せていたのですが、明示的にクリーンアップをしないと listner が走り続けると等色々と扱いに注意が必要だったので、最近は(リアルタイム性が必要な部分以外は)通信の状態をすべて Apollo Client に路線変更することにしました👶
実際の運用まではまだ行なっていないのですが、一旦 Firestore + Cloud Functions + Apollo で作った GraphQL サーバーとクライアント側の繋ぎ込みまで成功したのでその方法を書き残してみます。
FirebaseプロジェクトにCloud Functionsを追加
firebase-tools
のインストールが未実施の場合はまず以下を実行します。
npm install -g firebase-tools
未ログイン済の場合は以下のコマンドでログインします。
firebase login
上記が終われば適当なディレクトリで以下のコマンドを実行し、FirebaseプロジェクトにCloud Functionsを追加します。
firebase init functions
今回は言語にTypeScript
を使用し、ESLint
も導入しました。
Firebase initialization complete!
と表示されればFunctionsの作成が完了したので、早速コードを書いていきます。
Apollo
まずは必要なライブラリをインストールします。
公式からはapollo-server-cloud-functions
というライブラリが提供されていますが、こちらだとローカルの立ち上げは成功しつつも build に失敗したため今回はapollo-server-express
を使用します。
npm i --save-dev apollo-server-express graphql
ちなみに、上記を実行するとぼくの手元のpackage.json
は次のようになっています。
"dependencies": {
"apollo-server-express": "^3.5.0",
"graphql": "^16.0.1"
}
Query
上記が終われば、まずはは Query(GET) 処理を書いて行きます。
予め Firestore 側で以下のように適当なデータを作成しておきましょう。
上記で作成したtests
に合わせて、Test型のGraphQL Schemaを作成します。
import {ApolloServer, gql} from "apollo-server-express";
import {DocumentNode} from "graphql";
export const typeDefs: DocumentNode = gql`
type Query {
tests: [Test]
}
type Test {
uid: ID!
text: String!
}
`;
resolver を作成します。
import * as admin from "firebase-admin";
export const resolvers = {
Query: {
async tests() {
const tests = await admin.firestore().collection("tests").get();
return tests.docs.map((test) => test.data());
},
},
}
上記ファイルをindex.ts
にインポートして ApolloServer を作成します。
import {ApolloServer, gql} from "apollo-server-express";
import {resolvers} from "./resolver";
import {typeDefs} from "./typeDefs";
import express from "express";
const app = express();
// サーバーを起動する
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
});
server
.start()
.then(() => {
server.applyMiddleware({app, path: "/"});
})
exports.graphql = functions.https.onRequest(app);
ここまでで一旦 Query を行うためのコード上の準備はおしまいです。
この状態でfunctions
ディレクターに行き、npm run build
を実行します(そうしなければ最新の変更がローカルホストに反映されません。)
それが終わると最後に親ディレクトリに戻って
firebase serve
を実行します。
すると以下のようなログが吐かれます。
functions[us-central1-graphql]: http function initialized (http://localhost:5000/hoge-app/us-central1/graphql).
表示されたリンクに飛ぶと、以下のような画面が表示されるので、「Query Your Server」を押します。
すると想定通り、 Firestore 上のデータを GraphQL クエリで取得することに成功しました🎉
これで Query(GET) は大丈夫なので、次に Mutation(Post, Update, Delete) の処理を書いてみましょう。
Mutation
先ほど作成したtypeDefs.ts
に以下を追加します
import {ApolloServer, gql} from "apollo-server-express";
import {DocumentNode} from "graphql";
export const typeDefs: DocumentNode = gql`
...(中略)...
type Mutation {
addTest(text: String!): Test // 追加
}
...(中略)...
`;
上記で定義した関数を resolver に実装します。
クライアントから受け取ったtext
を元にTestデータを作成する簡単な Mutation です。
export const resolvers = { // クエリ発行時の処理を指定する
...(中略)...
Mutation: {
addTest: async (_: null, {text}: { text: string }) => {
const uid = generateUid() // これは適当にご自身で作るなりライブラリ使うなりしてください
await admin.firestore().collection("tests").doc(uid).set({
uid: uid,
text: text,
});
const testDoc = await admin.firestore().doc(`tests/${uid}`).get();
const test = await testDoc.data();
return test;
},
},
};
ここまででMutation側の実装も終わったので、下記コマンドを打って再度 GraphQL クライアントを立ち上げます。
cd functions
npm i build
cd ..
firebase serve
どうやらちゃんと動いているようですね🙌
一応Firestore Database
の方も確認してみたところ、こちらも想定通りに動いているようでした🌎
おまけ: いつFirestoreとApolloを使うか
現在ぼくは個人開発でDBにFirestoreを使用したプロジェクトに2つ取り組んでいます。
一方は再利用性の高いデータのみ Unstated Next(ContextAPIをラップしたグローバルState hook)に突っ込んで残りは Firestore のリアルタイムリスナーに任せた状態管理を行なっており、もう一方で今回ご紹介した Apollo Client を用いた状態管理を取り入れています。
まだ後者については判断を下すには検証が足りていない感がありますが、Firestoreの状態管理にApollo Clientを使用すべきかどうかは状況によるなと感じています、逆に普通の RDB を使用している場合は通信の状態は完全に Apollo に寄せると思いますが..🐒
ぼくが考える Firestore 最大の長所は何度も述べている通り、リアルタイムリスナーだと思っています。gRPC や WebRTC を使わずとも即時反映が求められる機能を実装できるのはとてつもない恩恵であると感じています
それに対し Apollo Client による状態管理の特徴は正規化したデータをキャッシュとして保持し、サーバーにリクエストしたデータに変更が合った際は自動でキャッシュするという仕組みで、 Firestore のリアルタイムリスナーの良さを消してしまっているなと感じました。
逆に、リアルタイムにデータが更新されるとリクエスト数が増えがちになってしまうので、一般的な利用ではApolloの fetch policy などを駆使してなるたけリクエストを増やさない方向に寄せるのが安心な気がします。
Apolloを使用していない前者プロジェクトにおける自分の今の運用は、再利用性が高い状態を以下のようなカスタムhookをcontext化し、(親に再レンダリングを走らせないために)そのデータを使用する末端のコンポーネントのみで呼び出し、それ以外は親コンポーネントで都度fetch、という感じですが今後リクエスト数が肥大してボトルネックになるなどが起きる場合はApolloに乗り換えようと思います。
const = useTest = () => {
const [tests, setTests] = useState()
useEffect(() => {
const unsbscribe = () => {
db.collection("test").onSnapshot((querySnapshot) => {
const tmpTests = querySnapshot.docs.map((test) => test.data())
setTest(tmpTests)
}
}
return () => unsubscribe()
}, [])
return {tests}
}
追記 Apollo でリアルタイム性を実現する
Apollo と Firestore のリアルタイムリスナーは相性が悪いと↑で書かせて頂いたのですが、Apollo でもリアルタイム性を実現する手段はあります。( @bannzai さんに教えて頂きました。有り難うございます🙏)
それは、Firestoreによる変更の即時性通知だけを検知(流れてきたデータは無視)してApolloのリクエストを投げるという手法です。
db.collection('tests').onSnapshot((_) => {
const tests = await apollo.fetch(TestQuery())
setTests(tests)
}
この手法であれば、キャッシュの正規化も統一される上にApolloでもリアルタイムリスナーを実現することができます。
この手法では二重にFetchが走ってしまうというオーバーヘッドがありつつも、クライアント側で キャッシュによる状態と Context化された状態を分離せずに済むので、こちらを採用すべきだと思いました。
Zennで技術記事を書くのは初めてですが、投稿することでこういった知見を得られるのはとても良いので、今後も定期的に記事を書きたいなと思いました😌 (マサカリは怖いですが...)
Discussion