Fcm真是个好东西,希望你也有。
需求背景
作为一个穷人,手持不了iPhone(滑稽),当同时使用着macOS、windows、安卓三个平台时,我面临着几个问题:
- 传文件。对于传文件,有许多方案,我偏向于用Feem或者共享文件夹,都是局域网传输,又快又不用经过第三方服务器。
- 复制文本。iPhone和mac之间有通用剪切板,win10和iPhone、安卓之间有微软小娜。
- 通知同步。主要是手机通知,上班工作时,听到手机推送的声音想知道时什么东西又懒得去看手机。
- 短信验证码。电脑上用短信验证码场景并不是很多,不过我们公司的线上服务器登录时需要短信验证码的二次验证,这时候去解锁手机、看验证码、一个一个输入终端。。。妈蛋很烦。
于是找了一堆,AirDroid、Pushbullet等等,在到处讲隐私的今天,我觉得把自己的剪切板、短信、通知就这么发送给人家总是有点不安(不过用fcm也是把数据给号称‘不作恶’口号的谷歌)。偶然看到了剪纸云,是个收费软件,不过看了简介说是用FCM做的,找了下FCM的接入指北,自己做一个同步工具。
整体流程
数据的流向如图,终端把要同步的数据发到自己的应用服务器,应用服务器载把数据发到FCM,交由FCM推送到设备组。
对于每一台设备,当应用与FCM建立起连接后可以得到一个fcm token,这个token就是这台设备这个应用的id了。
至于设备组id,我是用Firebase-Auth的userId作为设备组id的。
程序主要流程是:
- 客户端启动时获取fcm token,持久化存储(这个fcm token除非把应用删了和我不知道的情况,否则万年不更新一次,当然fcm sdk提供了更新的回调,我们要实现这个方法。)
- 客户端登录,拉起谷歌的OAuth2.0授权(Chrome插件用的clientId模式),登录成功后获取到firebase-auth的userId,持久化存储
- 登录成功后把userId和fcm token发送到应用服务器保存,维护双向关系,完成注册。
- 客户端每次发送同步数据时,带上时间戳、同步类型、fcm token等内容。
- 客户端接受到fcm推送时,见机行事。
同步数据的数据结构
字段 | 说明 |
---|---|
type | 短信、通知、剪切板 |
time | 毫秒时间戳,也作为分片id(其实是偷懒) |
text | 文本内容 |
head | 额外内容,主要是为了存通知的通知标题 |
fcm_token | 设备id |
mark | 分片的标识,8位整数,高位起第一位表示是否分片,第二位表示是否还有分片,余下6位表示分片顺序 |
服务端
服务端用go写的,框架使用gin,数据库用redis。
主要维护两个关系。
- 设备id到设备组id的关系
- 设备组id到所有设备id的关系
第一个直接用redis的kv模型,第二个用redis的哈希模型。
当服务器接收到客户端的同步请求时,推送到fcm有两种方式。
- 使用fcm的设备组管理,fcm设备组管理需要新建设备组,把fcm token添加到设备组,发送同步数据时,带上fcm设备组id,fcm就会把同步数据推送到所有组。对于已经失效的fcm token,fcm设备组管理会自己清理。
- 自己维护设备组,遍历设备组的设备,一个个带上fcm token推到fcm。如果接收到fcm token无效的响应,就从redis把fcm token的kv关系、哈希关系删除。
使用fcm的设备组管理的好处是只需要维护设备id到设备组id的关系,对于一些无效的fcm token由fcm自己去管理,不足的是目前fcm设备组管理没有提供API获取设备组的设备组列表,而且一发就是发全部,客户端发消息出去,待会又收到自己的消息。此外,fcm设备组管理在go没有sdk。。。
所以我决定自己管理设备组。
这里有一个☝️剪切板的问题,当发送到fcm的payload大于4kb的时候,会返回
400; reason: request contains an invalid argument; code: invalid-argument; details: Request contains an invalid argument.
也就是说,我们要控制好数据大小。作为ctrl cv工程师,如果要从mac往windows复制1000行代码怎么办?答案是分片。
正如ip分片和tcp分段为了解决报文的大小限制,我们要在应用层进行分片重组。不过这个由于是应用层的分片要简单的多。
- 应用服务器接收到同步数据后,如果text文本大于4kb,则进行分片,mark高位第一位置0,否则置1直接推到FCM。
- 第二步,由于json是个文本协议,我们分片的时候有两种方案,第一种转换为字节分片,第二种转换为rune分片(rune是go的数据类型,可以表示一个utf8字符),一个rune的大小是4个字节,为了防止达到限制,rune分片大小应为1000个rune,不过这样就可能会导致一次payload利用率不高,毕竟1000个汉字是1000个rune,至少占3000字节,1000个字母也是1000个rune,占1000个字节;好处是在客户端可以直接使用。如果使用字节分片,客户端接收到分片后需要转换为字节数组,组合字节数组,再将字节数组转换为字符串,炒鸡麻烦。
- 接下来在分片mark高位起低6位设置好分片顺序,只要简单的0,1,2..这样就好了(不同于ip分片,ip分片使用的是数据偏移量作为位置索引)。如果是最后一片分片,mark高位起第二位要置1表示没有更多分片。
接下来利用sdk推送到fcm就好了。应用服务器的接入fcm有多种方式,文档真是傻瓜式教程。
客户端
客户端写Chrome拓展和android。 主要流程是
- 根据接收到的数据类型处理,
(1)通知直接显示,
(2)短信显示并且检验有没有验证码,有则将验证码提取放入剪切板,
(3)剪切板进行分片判断 - 如果不是分片,直接放入剪切板,否则进行重组。
重组需要一个全局哈希表,key为时间戳,也是分片id(这里用时间戳是偷懒,毕竟一个人1毫秒内也不能复制两次叭);value是维护分片的数据结构FragHold,如下
字段 | 说明 |
---|---|
count | 整型 |
length | 整型 |
text | 数组 |
当第一个分片到来时,将分片time作为key,初始化一个FragHold。每次到达一个分片,FragHold.count加1,当最后一个分片到来,FragHold.length可以确定。如果FragHold的count == length,那么分片重组完成,直接对数组一个join操作得到一篇完整的千字文,又可以愉快地ctrl cv了。
此外起一个定时任务,不断将全局哈希表里过期的FragHold剔除,毕竟这个是没有超时重传的,一旦丢了就再复制一次呗。
结语
这里要说一下在设备组管理遇到的问题,一开始我也是用fcm的设备组管理,导致发送方会接收到自己发送的消息。这个本来无所谓,根据消息的fcm_token判断一下,如果等于自己的fcm_token,就不处理。但是js客户端是用service worker来处理,当service worker重新唤醒时,因为查询indexedDB和调用fcm接口都要在第二轮事件循环才能拿到自己的fcm_token, 而service worker被唤醒后的第一个事件循环就要处理消息了,所以第一次被唤醒时是不知道自己fcm_token的。
还有一点,fcm js要求你必须对收到的push弹窗,否则他会帮你弹窗,有知道怎么解决的告诉我一下。。。