Home 使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務
Post
Cancel

使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務

使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務

當推播統計遇上 Firebase Firestore + Functions

Photo by [Carlos Muza](https://unsplash.com/@kmuza?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Carlos Muza

前言

推播精確統計功能

最近想為 APP 導入的功能,未實作前我們只能從後端 Post 資料給 APNS/FCM 的成功與否當作推播基數並記錄推播點擊,計算出「點擊率」;但此方法其實非常不準確,基數包含許多無效裝置,APP 已刪除的(不一定會馬上失效)、關閉推播權限的在後端 Post 時都還是會得到成功的回傳。

在 iOS 10 之後可以透過實踐 Notification Service Extension 在推播橫幅出現時的時機點偷偷 Call API 回傳做統計;好處是非常精準,只有在使用者推播橫幅有出現才會 Call;如果 APP 刪除、關閉通知、通知沒開橫幅,都不會有動作,橫幅等於有出現推播訊息,用此當推播基數然後再算上點擊數就能得到「精確的點擊率」。

詳細原理及實作方式可參考之前的文章:「 i OS ≥ 10 Notification Service Extension 應用 (Swift)

目前測試下來 APP 的 Loss 率應該是 0%,實際常見應用像是 Line 的訊息點對點加解密(推播的訊息是加密過的,在手機收到才解密然後顯示出來)。

問題

APP 端的功其實不大,iOS/Android 都只要實作類似的功能(但 Android 如果要考慮中國市場就比較麻煩,要為更平台實作推播框架內容);比較大的功是後端還有 Server 的壓力處理,因為推播一次出去會同時 Call API 回傳紀錄,可能會塞爆 Server 的 max connection 如果又是使用 RDBMS 儲存記錄可能會更嚴重,如果發現統計數有 Loss 多半發生在此環節。

這邊可以以 log 寫檔案方式做紀錄,要查詢時在自行做統計顯示。

另外,後來想想一次出去同時回來的情境,數量可能沒有想像中的大;因為發推播也不會一口氣發個十萬百萬筆,也是幾筆幾筆批次發送;只要能扛住批次發出去同時回來的數量即可!

Prototype

因原先有問題中的考量,後端需要花功力研究修改且市場也不一定在意做出來的成效;所以想說先用能使用的資源弄個 Prototype 出來試試水溫。

這邊選擇的是 APP 幾乎都會使用的 Firebase 服務,其中的 Functions 和 Firestore 功能。

Firebase Functions

Functions 是 Google 提供的 serverless 服務,只需撰寫好程式邏輯,Google 自動幫你弄好伺服器、執行環境,也不用去管伺服器擴充及流量的問題。

Firebase Functions 其實就是 Google Cloud Functions 但只能使用 JavaScript (node.js) 撰寫,沒試過但如果用 Google Cloud Functions 選擇用其他語言撰寫然後同樣 import Firebase 服務我想應該也能用。

用在 API 就是我可以寫一個 node.js 檔案,得到一個實體 URL (ex: 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 可在 Request 時 import Firestore 進來操作資料庫,然後Response 給使用者,就能搭建簡單的 Restful API 服務!

動手實作開始!

安裝 node.js 環境

這邊建議使用 NVM,node.js 版本管理工具進行安裝管理(像 python 用 pyenv)。

到 NVM Github 專案複製安裝 shell script:

1
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash

如果安裝過程出現錯誤,請確認有 ~/.bashrc~/.zshrc 檔案,沒有可用 touch ~/.bashrctouch ~/.zshrc 建立檔案然後再跑一下 install script。

再來就可以使用 nvm install node 安裝最新版的 node.js。

可下 npm --version 確認 npm 安裝成功、安裝版本:

部署 Firebase Functions

安裝 Firebase-tools:

1
npm install -g firebase-tools

安裝成功後,第一次使用請先輸入:

1
firebase login

完成 Firebase 登入驗證。

啟動專案:

1
firebase init

記下 Firebase init 所在路徑:

1
You're about to initialize a Firebase project in this directory:

這邊可以選擇要安裝的 Firebase CLI 工具,按 「↑」「↓」進行選擇,「空白鍵」進行選擇;這邊可以只選擇「Functions」或連「Firestore」一起選擇安裝。

=== Functions Setup

  • 語言選擇「 JavaScript
  • 關於「use ESLint to catch probable bugs and enforce style」語法 style 檢查 , YES / NO 都可
  • install dependencies with npm? YES

===Emulators Setup

可在本地環境測試 Functions、Firestore 功能及設定,不會算在使用度且不需等到部署上線才能測試。

依個人需求安裝,我有裝但沒有用...因為只是小功能而已。

Coding!

前往上述記下的路徑,找到 functions 資料夾 ,用編輯器打開裡面的 index.js 檔案。

1
2
3
4
5
6
7
8
9
10
11
12
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 actionname 參數資訊。

修改&儲存完成後回到 console 下:

1
firebase deploy

以後的每次修改都記得要回來下 firebase deploy 指令,才會生效。

開始驗證&部署到 Firebase…

可能需要稍等一下, Deploy complete! 後你的第一個 Request & Response 網頁就完成了!

這時候可以回到 Firebase -> Functions 頁面:

就會看到剛剛撰寫的接口和網址位置。

複製下方網址貼到 PostMan 測試:

POST Body 記得選擇 x-www-form-urlencoded

成功!

Log

我們可以在程式碼中使用:

1
functions.logger.log("log:", value);

進行 Log 紀錄。

並可在 Firebase -> Functions -> 紀錄中查看 log 結果:

Example Goal

建立一個可新增、修改、刪除、查詢文章和按讚的 API

我們希望能達成 Restful API 的功能設計,所以不能再使用上面範例的純 Path 方式,要改藉用 Express 框架達成。

POST 新增文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 }));

// Insert
app.post('/', async (req, res) => { // 這邊的 POST 指的是 HTTP Method 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 修改文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 }));

// Update
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 Method 記得改成 PUT

DELETE 刪除文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 }));

// Delete
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 Method 記得改成 DELETE

新增、修改、刪除做完了,來做查詢!

SELECT 查詢文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 }));

// Select List
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});
});

// Select One
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 Method 記得改成 GET 還有將 Body 切回 none

InsertOrUpdate?

有時候我們需要當值存在時做更新,當值不存在時新增,這時候可以用 set 搭配 merge: true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 }));

// InsertOrUpdate
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);

這邊以新增 tag 為例,部署&測試方式如新增,可以看到 Firestore 不會一直重複新增新資料。

文章按讚計數器

假設我們的文章資料現在多一個 likeCount 欄位紀錄按讚數量,那我們該怎麼做呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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("/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 有 寫入速度限制 的:

一個文檔一秒只能寫入一次 ,所以當按讚的人一多;同時請求下可能會變得很慢。

官方給的解決方法「 Distributed counters 」其實也沒什麼高深的技術,就是多用幾個分散的 likeCount 欄位來統計,然後讀取的時候再加總起來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 }));

// Distributed counters 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);

以上就是分散出欄位來紀錄 Count 避免寫入太慢;但如果分散的欄位太多會增加讀取成本($$),但應該還是比每次按讚都 add 一筆新紀錄還便宜。

使用 Siege 工具進行壓力測試

使用 brew 安裝 siege

1
brew install siege

p.s 如果你出現 brew: command not found 請先安裝 brew 套件管理工具

1
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

安裝完成後可下:

1
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 網址、Post Body (ex: {“name”:”1234”} )

執行完成後可看到執行結果:

successful_transactions: 100 表示 100 次都執行成功。

可以回 Firebase -> Firestore 查看結果是否有 Loss Data:

成功!

完整 Example Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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 }));

// Insert
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":"新增成功!"});
});

// Update
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":"修改成功!"});
});

// Delete
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":"文章成功!"});
});

// Select List
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});
});

// Select One
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()}});
});

// InsertOrUpdate
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":"新增成功!"});
});

// Like Post
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":"按讚成功!"});
});

// Distributed counters 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);

回歸主題,推播統計

回到一開始我們想做的,推播統計功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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"]

// Insert Log
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":"紀錄成功!"});
    }
});

// View Log
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);

新增推播紀錄

檢視推播統計數字

1
https://us-centra1-xxx.cloudfunctions.net/notification/iOS/1

另外也做了個介面統計推播數字。

踩坑

因為對 node.js 用法不太熟悉,一開始摸索的時候在 add 資料時沒加上 await 再加上寫入速度限制,導致在大流量情況下會 Data Loss…

Pricing

別忘了參考 Firebase Functions & Firestore 的定價策略。

Functions

運算時間

運算時間

網路

網路

Cloud Functions 針對運算時間資源提供永久免費方案,當中包含 GB/秒和 GHz/秒的運算時間。除了 200 萬次叫用以外,免費方案也提供 400,000 GB/秒和 200,000 GHz/秒的運算時間,以及每月 5 GB 的網際網路輸出流量。

Firestore

價格可能隨時更改,請以官網最新資訊為準。

結論

如同標題所寫「可供測試」、「可供測試」、「可供測試」不太建議將以上服務用於正式環境,甚至當作產品的核心上線。

收費貴、難遷移

之前曾聽說某個蠻大的服務就是使用 Firebase 服務搭建起家,結果後期資料、流量大,收費爆貴;要轉移也很困難,程式還好但資料非常難搬;只能說是初期省了小錢卻造成後期巨大的虧損,不值得。

僅供測試

因為以上原因,使用 Firebase Functions + Firestore 搭建的 API 服務個人建議僅供測試或是 Prototype 產品展示。

更多功能

Functions 還可以串 Authentication(身份驗證)、Storage(檔案上傳),但這部分我就沒研究了。

參考資料

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

找回密碼之簡訊驗證碼強度安全問題

AppStore APP’s Reviews Bot 那些事