Post

Firebase Firestore|Functions 快速打造测试用 API 服务教学与推播统计实作

针对开发者面临后端推播统计不准确与 Server 压力问题,解析如何运用 Firebase Firestore 与 Functions 快速搭建可测试的 API 服务,并示范完整 RESTful API 范例及分散式计数器设计,提升推播点击率统计精准度与系统稳定性。

Firebase Firestore|Functions 快速打造测试用 API 服务教学与推播统计实作

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


使用 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 档案。

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 新增文章

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(档案上传),但这部分我就没研究了。

参考资料

延伸阅读

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

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