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で取得し、ドキュメントから取り出します。先程同様、iddataというキー名に値を埋め込み、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の更新メソッドは、setupdateの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の認証機能も使えますが、簡易な場合はこれで十分そうです。

おわりに

年末年始にReact+Firebaseで身内用のWebアプリを作成したのですが、Cloud Functionsを知っていれば楽できてたのに…と今になり思います。今回HTTPSからしか操作していませんが、Firebaseの各機能を組み合わせている場合はメリットが大きそうです。

追伸、コード分割も書いていたんですが記事が長くなるのでやめました。