Firebase Cloud Functions + Firestore超入門
目次
Firebase Cloud FunctionsとFirestoreを組み合わせてREST APIぽいものを作る。Cloud FunctionsとFirestoreに初めて触るので、その動作を知る勉強といった感じ。
REST構築には別でAPIが用意されているので、本来はそちらを使うべきかもしれません。
Cloud Functionsとは
Firebaseの機能(AuthやDatabaseなど)やHTTPSリクエストのイベントに応じて、自動的に実行できるバックエンド関数のこと。例えばFirebase Authenticationでユーザーを作成したときにCloud Functionsに定義した関数をトリガーさせて、何かしらの処理(メールを送ったり)をさせることができる。Hostingと組み合わせて動的サイトも提供できるようです。
前述のようにFirebaseの機能に限らず、HTTPSリクエストから直接呼び出すこともできる。つまりサーバーサイドからだけでなく、フロントサイドからも呼び出しができるということ。今回はこのHTTPSからリクエストを送るCloud Functionsに触れてみます。
尚、Cloud Functionsは俗に言うサーバーレスというやつで、サーバー側のメンテナンスが不要。負荷に伴い、仮想サーバーのインスタンス数を自動でスケーリングしてくれる。
準備
Cloud Functionsで扱えるNodeの実行環境は6だが、8も別途宣言すれば利用可能みたいなので、今回は8で進めてみる。
node --version
v8.15.0
# プロジェクトディレクトリの初期化する
mkdir item-server
cd item-server
yarn init -y
yarn add firebase-tools express cors
次の操作を行う前にFirebaseコンソールから新規プロジェクトを作成しておく必要があります。以下、プロジェクト名を「item-list」としています。
# ブラウザが立ち上がるのでログイン
firebase login
# 設定を行う
? Select a default Firebase project for this directory: item-list
? What language would you like to use to write Cloud Functions? JavaScript
? Do you want to use ESLint to catch probable bugs and enforce style? No
? Do you want to install dependencies with npm now? Yes
functionsディレクトリ内のpackage.jsonの先頭に以下を追記する。
"engines": { "node": "8" },
firebase-functionsは^2.0.0、firebase-toolsが^4.0.0が必要です。
とりあえず試す
Cloud Functionsを試すため、index.jsを書き換えます。
const functions = require('firebase-functions');
exports.helloWorld = functions.https.onRequest((req, res) => {
res.send("Hello);
});
firebase deploy
を実行すると、以下のようなURLが生成されます。端末に表示されなければ、Firebaseコンソールの「Functions」を見ればURLが確認できます。
https://us-central1-item-server-xxxxx.cloudfunctions.net/helloWorld
このURLをブラウザで打ち込み、画面に「Hello」が表示されれば成功です。見てわかるように、エクスポートした関数名がエンドポイントとなっています。
Express
以降、NodeフレームワークであるExpressを使いRESTチックなAPIを作成していきます。上記のようにエンドポイントはCloud Functionsに公開されているので、Expressを必須というわけではありません。ミドルウェアが必要ならば導入しましょう。今回は例としてCross-Origin Resouce Sharingを可能にするcorsを使用しています。
尚、ExpressのrequestとresponseオブジェクトはNode.jsのそれらと互換性があるので、Cloud Functionsで問題なく利用することができます。
index.jsを以下に置き換えます。
const functions = require('firebase-functions');
const express = require('express');
const cors = require('cors');
const itemRouter = require('./api/routes/itemRouter');
const app = express();
app.use(cors({ origin: true }));
// register routes
app.use('/api/v1', itemRouter);
module.exports.itemApi = functions.https.onRequest(itemRouter);
itemRouterは後で作成しますが、注目すべき点は/api/v1
というルートを作成している点です。原状、仮にitemRouterがあったとしても、/api/v1
はエクスポートしているitemApiでは解決できません。firebase.jsonのrewrites
で設定が必要です。
{
"rewrites": [
{
"source": "/api/v1/**",
"function": "itemApi"
}
]
}
これでitemApiが/api/v1
というエンドポイントに対応します。一般的REST APIだと以降の解説におけるitemApiというエンドポイントはitemという名前が適切ですが、記事内に同様の表記が多いため、わかりやすさを重視してitemApiで進めています。
Firestore
この後、Firestoreが必要になるのでセットアップを行う。
yarn add firebase-admin
functions/api/model/firebase.jsonを作成する。
const admin = require('firebase-admin');
admin.initializeApp({
credential: admin.credential.applicationDefault()
});
module.exports = admin.firestore();
FirestoreはNoSQLでMongoDBのようにデータベース-コレクション-ドキュメントの階層になっています。RDBMSでいうならば、コレクションはテーブルでドキュメントが行に相当します。
以下、Firestoreの操作がコード上に出てきますが、必要最低限のコードしか記述していません。本番で使うときはセキュリティに気をつけてください。
REST API
functions/api/routes/itemRouter.jsを作成する。
const express = require('express');
const db = require('../model/firebase');
const itemRouter = express.Router();
// Error Handling
itemRouter.use((req, res, next) => {
res.status(404).json({
error: 'Route Not Found'
});
});
itemRouter.use((e, req, res, next) => {
res.status(500).json({
error: e.name + ': ' + e.message
});
});
Error Handlingは末尾に記述しておく必要があります。
READ(ALL)
データベース内の全itemを取得します。
// Read All Item
itemRouter.get('/', async (req, res, next) => {
try {
const itemSnapshot = await db.collection('items').get();
const items = [];
itemSnapshot.forEach(doc => {
items.push({
id: doc.id,
data: doc.data()
});
});
res.json(items);
} catch (e) {
next(e);
}
});
Firestoreのitemsコレクションからget()
メソッドでドキュメントのスナップショットを取得します。それを反復させて配列に格納させ、JSONで書き出しているだけです。
Firestoreのドキュメントには要素追加時に自動でユニークIDが作成されており、これはdoc.id
のように取得が可能です。格納データはdoc.data()
のようにdata()
メソッドで取得できます。
この処理は[GET] URL/itemAPI/
で取得できます。
READ(SINGLE)
個別にitemを取得できるようにします。
// Read an Item
itemRouter.get('/:id', async (req, res, next) => {
try {
const id = req.params.id;
if (!id) {
throw new Error('id is blank');
}
const item = await db
.collection('items')
.doc(id)
.get();
if (!item.exists) {
throw new Error('item does not exists');
}
res.json({
id: item.id,
data: item.data()
});
} catch (e) {
next(e);
}
});
リクエストパラメーターをreq.params.id
で取得し、ドキュメントから取り出します。先程同様、id
とdata
というキー名に値を埋め込み、JSONで書き出します。
この処理は[GET] URL/itemAPI/itemid
で取得できます。
CREATE
itemの作成はPOSTリクエストで行います。
// Create Item
itemRouter.post('/', async (req, res, next) => {
try {
const text = req.body.text;
if (!text) {
throw new Error('Text is blank');
}
const data = { text };
const ref = await db.collection('items').add(data);
res.json({
id: ref.id,
data
});
} catch (e) {
next(e);
}
});
追加に利用するメソッドはadd()
です。これに追加する内容をオブジェクト形式で渡します。
注意点:Cloud Functionsはリクエストのコンテンツタイプによりヘッダーが解釈され、自動でパースが行われます。Expressアプリケーションだと通常body-parserでPOSTからのリクエストをパースしますが、それが不要ということです。今回は使用していませんが、req.headers['content-type']
でリクエストヘッダーの種類をチェックできます。
UPDATE
更新もCREATEとメソッド以外は大して変わりません。
// Update item
itemRouter.put('/:id', async (req, res, next) => {
try {
const id = req.params.id;
const text = req.body.text;
if (!id) {
throw new Error('id is blank');
}
if (!text) {
throw new Error('text is blank');
}
const data = { text };
const ref = await db
.collection('items')
.doc(id)
.update({
...data
});
res.json({
id,
data
});
} catch (e) {
next(e);
}
});
Firestoreの更新メソッドは、set
とupdate
の2つが用意されていますが、前者は丸ごとオブジェクト内のデータを置き換えますが、後者は指定したキーの値以外は変更しないセーフティなメソッドです。
DELETE
削除は1番シンプルです。
// Delete Item
itemRouter.delete('/:id', async (req, res, next) => {
try {
const id = req.params.id;
if (!id) {
throw new Error('id is blank');
}
await db
.collection('items')
.doc(id)
.delete();
res.json({
id
});
} catch (e) {
next(e);
}
});
削除メソッドはIDで指定したドキュメントにdelete()
をするだけです。
さて、ここまでのコードはPostmanを使いテスト可能です。何か動作がおかしいというときは、FirebaseコンソールのFunctionsの「ログ」タブを確認してみましょう。警告やエラーが確認できます。コード内のconsole.log()
も、ここで確認することができます。
以上です。今回は認証やユーザーのような概念を設けていないので、itemの追加や削除はCloud Functionsで公開しているURLさえわかれば誰でも変更が可能です。実用化するならば、ユーザー用のコレクションを別途作ったり、Firestoreのセキュリティルール設定が必要になります。Firestoreはブラウザ上で認証のエミュレートが行えるので、Realtime Databaseよりもセキュリティルールの設定はとっつきやすいなと感じました。
これ以降の解説は、オプションというかおまけです。
末尾スラッシュに対応
デフォルトではエンドポイントの末尾に「/」がないと500エラーが起きます。これが嫌な場合は、以下のようにリクエストのパスをチェックします。
module.exports.itemApi = functions.https.onRequest((request, response) => {
if (!request.path) {
request.url = `/${request.url}`;
}
return itemRouter(request, response);
});
API KEY設ける
なんちゃってAPIになんちゃって認証(呼び出しに必要なAPI KEY)を設けます。これで特定のリクエストから以外は、Cloud Functionsの呼び出しができなくなります。
# cryptoのインストール
yarn global add crypto
# API KEYの作成
node -e "console.log(require('crypto').randomBytes(20).toString('hex'))"
xxxxAPIKEYxxxx
Firebaseの環境設定でAPI KEYとクライアントのIDを指定します。
# firebase環境設定
firebase functions:config:set \
itemservice.key="xxxxAPIKEYxxxx" itemservice.id="client01"
今回利用するのはAPI KEYのみなので、クライアントIDの方は指定しなくても問題ありません。このAPI名は実際の関数名とは関係なく、単なる独立したキー名です。利用できるのは小文字限定のようです。
設定後、設定内容を忘れた時は以下のコマンドで確認ができます。
firebase functions:config:get
{
"someservice": {
"id": "client01"
},
"itemservice": {
"key": "xxxxAPIKEYxxxx"
}
}
設定後は再デプロイが必要です。
あとはコード上でキーを照らし合わせるだけです。
itemRouter.use((req, res, next) => {
const key = functions.config().itemservice.key;
const request_key = req.get('Authorization');
if (key === request_key) {
next();
} else {
throw new Error('Bad Key');
}
});
Postmanを使うときはHeaderタブのKeyにAuthorization、ValueにAPI KEYの値を打ち込みます。無事保護されていれば、間違ったキーや空の場合はエラーになります。
バックエンドにはfirebase-admin
もインポートしてあるのでFirebaseの認証機能も使えますが、簡易な場合はこれで十分そうです。