Firebase Firestore|Functions 快速打造测试用 API 服务教学与推播统计实作
针对开发者面临后端推播统计不准确与 Server 压力问题,解析如何运用 Firebase Firestore 与 Functions 快速搭建可测试的 API 服务,并示范完整 RESTful API 范例及分散式计数器设计,提升推播点击率统计精准度与系统稳定性。
Click here to view the English version of this article.
點擊這裡查看本文章正體中文版本。
基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。
使用 Firebase Firestore + Functions 快速搭建可供测试的 API 服务
当推播统计遇上 Firebase Firestore + Functions
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 ~/.bashrc
或 touch ~/.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
档案。
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 action
、 name
参数资讯。
修改&储存完成后回到 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 新增文章
index.js:
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 修改文章
index.js:
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 删除文章
index.js:
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 查询文章
index.js:
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
:
index.js:
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
栏位纪录按赞数量,那我们该怎么做呢?
index.js:
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 栏位来统计,然后读取的时候再加总起来。
index.js:
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
index.js:
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);
回归主题,推播统计
回到一开始我们想做的,推播统计功能。
index.js:
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(档案上传),但这部分我就没研究了。
参考资料
https://firebase.google.com/docs/firestore/query-data/queries
https://firebase.google.com/docs/firestore/solutions/counters#node.js_1
延伸阅读
有任何问题及指教欢迎 与我联络 。
本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。