Firebase Firestore + Functions を使ってテスト用の API サービスを素早く構築する方法
プッシュ通知の統計と Firebase Firestore + Functions の組み合わせ

Photo by Carlos Muza
はじめに
プッシュ通知の正確な統計機能
最近、APPに導入したい機能について、実装前はバックエンドからAPNS/FCMへデータを送信した成功・失敗のみをプッシュ通知の母数として扱い、クリック数を記録して「クリック率」を算出していました。しかし、この方法は非常に不正確です。母数には無効な端末や、APPが削除されていても(すぐには無効にならない)、プッシュ通知の権限をオフにしている端末も含まれ、バックエンドから送信した際には成功のレスポンスが返ってきてしまいます。
iOS 10以降では、Notification Service Extensionを実装することで、プッシュ通知のバナーが表示されるタイミングでこっそりAPIを呼び出して統計を取ることができます。利点は非常に正確で、ユーザーにプッシュ通知のバナーが表示された場合にのみAPIを呼び出す点です。アプリが削除されている、通知がオフになっている、バナー表示が無効になっている場合は動作せず、バナーが表示された時点でプッシュ通知が届いたとみなせるため、これをプッシュ通知の母数とし、クリック数と組み合わせて「正確なクリック率」を算出できます。
詳細な原理と実装方法については、以前の記事「i OS ≥ 10 Notification Service Extension 応用 (Swift)」を参照してください。
現在のテストでは、APPのロス率は0%のはずです。実際によくある例としては、Lineのメッセージのピアツーピア暗号化(プッシュ通知のメッセージは暗号化されており、スマホで受信してから復号して表示されます)。
問題
APP 側の役割は実はあまり大きくなく、iOS/Android は似たような機能を実装するだけです(ただし、Android は中国市場を考慮すると、各プラットフォーム向けにプッシュ通知のフレームワークを実装する必要があり、やや複雑です)。大きな負担はバックエンドやサーバーの処理で、プッシュ通知を一斉に送ると同時に API をコールして記録を返すため、サーバーの最大接続数を超えてしまう可能性があります。さらに RDBMS を使って記録を保存している場合は、より深刻になることがあります。統計データにロスが発生する場合、多くはこの段階で起きています。
ここではログをファイルに書き込む方法で記録し、必要に応じて集計表示を行うことができます。
また、後で考えると、一度に送信して同時に戻ってくる状況は想像ほど大量ではないかもしれません。プッシュ通知も一気に何十万、何百万件も送るわけではなく、数件ずつバッチで送信するため、バッチ送信と同時に戻ってくる数を耐えられれば十分です!
プロトタイプ
元々問題があったため、バックエンドで修正に時間をかける必要があり、市場も成果にあまり関心がない可能性があるので、まずは使えるリソースでプロトタイプを作って様子を見ようと思いました。
ここでは、ほとんどのアプリで使用される Firebase サービスの中から、Functions と Firestore の機能を選択しています。
Firebase Functions
Functions は Google が提供するサーバーレスサービスで、プログラムのロジックを書くだけで、Google が自動的にサーバーや実行環境を用意し、サーバーのスケーリングやトラフィックの管理を気にする必要がありません。
Firebase Functions は実際には Google Cloud Functions と同じですが、JavaScript (node.js) のみで記述できます。試したことはありませんが、Google Cloud Functions で他の言語を選択し、同様に Firebase サービスをインポートすれば使えると思います。
APIで使う場合は、node.jsファイルを書いて、実際のURL(例: my-project.cloudfunctions.net/getUser)を取得し、Request情報を取得して適切なResponseロジックを自分で作成できます。
以前に Google Functions について書いた記事「 Python+Google Cloud Platform+Line Bot を使って定期作業を自動化する 」があります
Firebase Functions は Blazeプラン(使った分だけ支払う)を有効にしないと使用できません。

Firebase Firestore
Firebase Firestore は、データを保存・管理するための NoSql データベースです。
Firebase Functions と組み合わせることで、リクエスト時に Firestore をインポートしてデータベースを操作し、レスポンスをユーザーに返すことで、簡単な Restful API サービスを構築できます!
実装を始めましょう!
node.js 環境のインストール
こちらでは、NVM(Node.jsのバージョン管理ツール)を使ったインストールと管理をおすすめします(Pythonのpyenvのようなものです)。
NVM Github プロジェクトからインストール用シェルスクリプトをコピー:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh \\| bash
インストール中にエラーが発生した場合は、~/.bashrc または ~/.zshrc ファイルがあるか確認してください。なければ、touch ~/.bashrc または touch ~/.zshrc を使ってファイルを作成し、その後インストールスクリプトを再実行してください。
次に nvm install node を使って最新の node.js をインストールできます。

npm --version を実行して、npm が正しくインストールされているかとバージョンを確認します:
![]()
Firebase Functions のデプロイ
Firebase-tools のインストール:
npm install -g firebase-tools

インストールが成功したら、初回使用時に以下を入力してください:
firebase login

Firebase ログイン認証を完了する。
プロジェクトの起動:
firebase init

Firebase init のあるパスをメモしてください:
このディレクトリで Firebase プロジェクトを初期化しようとしています:
/Users/zhgchgli
ここでインストールする Firebase CLI ツールを選択できます。「↑」「↓」キーで選択し、「スペースキー」で決定します。ここでは「Functions」のみ、または「Firestore」も含めて選択してインストールできます。
=== Functions セットアップ

-
言語選択「 JavaScript 」
-
「use ESLint to catch probable bugs and enforce style」の文脈での style チェックについては、はい / いいえ のどちらでも構いません。
-
npmで依存関係をインストールしますか? はい
===エミュレーターのセットアップ

ローカル環境で Functions や Firestore の機能と設定をテストできます。使用量にはカウントされず、デプロイして公開するまで待つ必要はありません。
個人の必要に応じてインストールしてください。私はインストールしましたが使っていません...小さな機能だけなので。
コーディング!
上記で記録したパスに移動し、functions フォルダを見つけて、その中の index.js ファイルをエディタで開いてください。
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();
exports.hello = functions.https.onRequest((req, res) => {
const targetID = req.query.targetID
const action = req.body.action
const name = req.body.name
res.send({"targetID": targetID, "action": action, "name": name});
return
})
貼り付けた内容で、パスインターフェース /hello を定義し、URLの Query ?targetID= と POST の action、name パラメータ情報を返します。
修正&保存が完了したら、コンソールに戻ります:
firebase deploy
今後の変更は必ず
firebase deployコマンドを実行してから反映してください。
Firebaseへの検証&デプロイを開始…

少しお待ちください。Deploy complete! の後、最初のリクエスト&レスポンスのウェブページが完成します!
この時点で Firebase -> Functions ページに戻れます:

先ほど作成したインターフェースとURLの場所が表示されます。
以下のURLをコピーしてPostManでテストしてください:

POST ボディは必ず
x-www-form-urlencodedを選択してください。
成功!
ログ
私たちはコード内で以下のように使用できます:
functions.logger.log("ログ:", value);
ログ記録を行う。
Firebase -> Functions -> ログでログ結果を確認できます:

例の目標
記事の追加、編集、削除、検索、および「いいね」機能を持つ API の作成
Restful API の機能設計を実現したいため、上記の単純な Path 方式は使わず、Express フレームワークを利用して実装します。
POST 新しい記事を追加する
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 挿入
app.post('/', async (req, res) => { // ここでの POST は HTTP メソッドの POST を指します
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var post = {"title":title, "content":content, "author": author, "created_at": new Date()};
await admin.firestore().collection('posts').add(post);
res.status(201).send({"message":"追加成功!"});
});
exports.post= functions.https.onRequest(app); // ここでの POST は /post パスを指します
現在は Express を使ってネットワークリクエストを処理します。ここではまず、/ パスの POST メソッドを追加します。最後の行はパスがすべて /post 以下にあることを示しています。次に、更新や削除の API を追加します。
下 firebase deploy が成功したら、Post Man に戻ってテストします:

Post Manで成功した後、Firebase -> Firestoreでデータが正しく書き込まれているか確認できます:

PUT 記事の編集
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 更新
app.put("/:id", async (req, res) => {
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
} else if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var post = {"title":title, "content":content, "author": author};
await admin.firestore().collection('posts').doc(req.params.id).update(post);
res.status(200).send({"message":"更新成功!"});
});
exports.post= functions.https.onRequest(app);
デプロイ&テスト方法は新規作成と同様で、Post ManのHTTPメソッドをPUTに変更するのを忘れないでください。
DELETE 記事の削除
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 削除
app.delete("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection("posts").doc(req.params.id).delete();
res.status(200).send({"message":"記事を削除しました!"});
})
exports.post= functions.https.onRequest(app);
デプロイ&テスト方法は新規作成と同様で、Post ManのHTTPメソッドを DELETE に変更することを忘れないでください。
新規作成、更新、削除が終わったので、次は検索をしましょう!
SELECT で記事を検索する
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 一覧取得
app.get('/', async (req, res) => {
const posts = await admin.firestore().collection('posts').get();
var result = [];
posts.forEach(doc => {
let id = doc.id;
let data = doc.data();
result.push({"id":id, ...data})
});
res.status(200).send({"result":result});
});
// 単一取得
app.get("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
res.status(200).send({"result":{"id":doc.id, ...doc.data()}});
});
exports.post= functions.https.onRequest(app);

デプロイ&テスト方法は新規追加と同様で、Post ManのHTTPメソッドをGETに変更し、Bodyをnoneに戻すことを忘れないでください。
InsertOrUpdate?
時々、値が存在する場合は更新し、存在しない場合は新規作成したいことがあります。その場合は set と merge: true を使います:
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 挿入または更新
app.post("/tag", async (req, res) => {
const name = req.body.name;
if (name == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var tag = {"name":name};
await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true});
res.status(201).send({"message":"追加成功!"});
});
exports.post= functions.https.onRequest(app);
ここではタグの追加を例に、デプロイ&テスト方法は追加と同様で、Firestoreに同じデータが繰り返し追加されないことが確認できます。

記事のいいねカウンター
私たちの記事データに likeCount フィールドが追加されて、いいね数を記録するとしたら、どうすればよいでしょうか?
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 投稿に「いいね」
app.post("/like/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
exports.post= functions.https.onRequest(app);
incrementという変数を使うことで、値を取り出して+1する操作を直接行うことができます。
大量トラフィック記事のいいねカウンター
Firestore には 書き込み速度制限 があります:

1つのドキュメントは1秒に1回しか書き込めません。そのため、いいねが多くなると、同時リクエストで遅くなる可能性があります。
公式が提供する解決方法「 Distributed counters 」は特に高度な技術ではなく、複数の分散された likeCount フィールドを使って集計し、読み取る際に合計するだけです。
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 分散カウンター Like Post
app.post("/like2/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
//1~10
await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString())
.set({count: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
exports.post= functions.https.onRequest(app);

以上のように、カウントの書き込みが遅くならないようにフィールドを分散して記録します。しかし、分散するフィールドが多すぎると読み取りコスト($$)が増えますが、いいねごとに新しいレコードを追加するよりは安く済むはずです。
Siege ツールを使った負荷テスト
brew を使って siege をインストールする
brew install siege
p.s brew: command not found が表示された場合は、まず brew パッケージ管理ツールをインストールしてください :
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
インストール完了後、以下を実行してください:
siege -c 100 -r 1 -H 'Content-Type: application/json' 'https://us-central1-project.cloudfunctions.net/post/like/id POST {}'
負荷テストを実施する:
-
-c 100:100個のタスクを同時に実行する -
-r 1:各タスクが1回リクエストを実行する -
-H ‘Content-Type: application/json’:POSTの場合は追加が必要です -
‘https://us-central1-project.cloudfunctions.net/post/like/id POST {}’:POSTのURL、POSTボディ(例:{“name”:”1234”})
実行が完了すると、実行結果が表示されます:

successful_transactions: 100 は 100 回すべて成功したことを示します。
Firebase -> Firestore に戻って、結果にデータの損失がないか確認できます:

成功!
完全なサンプルコード
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 挿入
app.post('/', async (req, res) => {
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var post = {"title":title, "content":content, "author": author, "created_at": new Date()};
await admin.firestore().collection('posts').add(post);
res.status(201).send({"message":"追加成功!"});
});
// 更新
app.put("/:id", async (req, res) => {
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
} else if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var post = {"title":title, "content":content, "author": author};
await admin.firestore().collection('posts').doc(req.params.id).update(post);
res.status(200).send({"message":"更新成功!"});
});
// 削除
app.delete("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection("posts").doc(req.params.id).delete();
res.status(200).send({"message":"記事削除成功!"});
});
// 一覧取得
app.get('/', async (req, res) => {
const posts = await admin.firestore().collection('posts').get();
var result = [];
posts.forEach(doc => {
let id = doc.id;
let data = doc.data();
result.push({"id":id, ...data})
});
res.status(200).send({"result":result});
});
// 詳細取得
app.get("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
res.status(200).send({"result":{"id":doc.id, ...doc.data()}});
});
// 挿入または更新
app.post("/tag", async (req, res) => {
const name = req.body.name;
if (name == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var tag = {"name":name};
await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true});
res.status(201).send({"message":"追加成功!"});
});
// 投稿にいいね
app.post("/like/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
// 分散カウンターによるいいね
app.post("/like2/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
// 1~10
await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString())
.set({count: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
exports.post= functions.https.onRequest(app);
本題に戻って、プッシュ通知の統計
最初にやりたかった、プッシュ通知の統計機能に戻ります。
index.js:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
const vaildPlatformTypes = ["iOS","Android"]
const vaildActionTypes = ["clicked","received"]
// ログを挿入
app.post('/', async (req, res) => {
const increment = admin.firestore.FieldValue.increment(1);
const platformType = req.body.platformType;
const pushID = req.body.pushID;
const actionType = req.body.actionType;
if (!vaildPlatformTypes.includes(platformType) \\|\\| pushID == undefined \\|\\| !vaildActionTypes.includes(actionType)) {
return res.status(400).send({"message":"パラメータエラー!"});
} else {
await admin.firestore().collection(platformType).doc(actionType+"_"+pushID).collection("shards").doc((Math.floor(Math.random()*10)+1).toString())
.set({count: increment}, {merge: true})
res.status(201).send({"message":"記録成功!"});
}
});
// ログを表示
app.get('/:type/:id', async (req, res) => {
// received
const receivedDocs = await admin.firestore().collection(req.params.type).doc("received_"+req.params.id).collection("shards").get();
var received = 0;
receivedDocs.forEach(doc => {
received += doc.data().count;
});
// clicked
const clickedDocs = await admin.firestore().collection(req.params.type).doc("clicked_"+req.params.id).collection("shards").get();
var clicked = 0;
clickedDocs.forEach(doc => {
clicked += doc.data().count;
});
res.status(200).send({"received":received,"clicked":clicked});
});
exports.notification = functions.https.onRequest(app);
プッシュ通知記録の追加

プッシュ通知の統計データを確認する
https://us-centra1-xxx.cloudfunctions.net/notification/iOS/1

また、プッシュ通知の数を集計するインターフェースも作成しました。
トラブルシューティング
node.js の使い方に慣れていなかったため、最初にデータを追加する際に
awaitを付け忘れ、さらに書き込み速度制限もあり、大量トラフィック時にデータロスが発生しました…

価格設定
Firebase Functions と Firestore の料金プランを忘れずに確認してください。
Functions


運算時間

ネットワーク
Cloud Functions は計算時間リソースに対して永久無料プランを提供しており、GB/秒や GHz/秒の計算時間が含まれます。200万回の呼び出しに加え、無料プランでは 400,000 GB/秒 と 200,000 GHz/秒 の計算時間、および毎月 5 GB のインターネットアウトバウンドトラフィックも提供されます。
Firestore

価格は予告なく変更される場合があります。最新情報は公式サイトでご確認ください。
結論
タイトルにあるように「テスト用」「テスト用」「テスト用」として、上記のサービスを本番環境や製品のコアとして使用することはあまり推奨しません。
料金が高く、移行が難しい
以前、ある大きなサービスが Firebase を使って立ち上げたと聞いたことがあります。しかし、後にデータ量やトラフィックが増え、料金が非常に高額になりました。移行も難しく、プログラムはまだしもデータの移行が非常に困難でした。初期はコストを抑えられても、結果的に後で大きな損失を招くため、あまりおすすめできません。
テスト用のみ
以上の理由から、Firebase Functions + Firestore を使って構築した API サービスは、個人的にはテストやプロトタイプの製品デモにのみ利用することをお勧めします。
さらに多くの機能
Functions は Authentication(認証)や Storage(ファイルアップロード)とも連携できますが、この部分は今回は調べていません。
参考資料
-
https://firebase.google.com/docs/firestore/query-data/queries
-
https://firebase.google.com/docs/firestore/solutions/counters#node.js_1
関連記事
Post Mediumから変換 by ZMediumToMarkdown.



コメント