【PWA学习与实践】(5)在Web中进行服务端消息推送

4次阅读

共计 10002 个字符,预计需要花费 26 分钟才能阅读完成。

《PWA 学习与实践》系列文章已整理至 gitbook – PWA 学习手册,文字内容已同步至 learning-pwa-ebook。转载请注明作者与出处。
本文是《PWA 学习与实践》系列的第五篇文章。文中的代码都可以在 learning-pwa 的 push 分支上找到(git clone 后注意切换到 push 分支)。
PWA 作为时下最火热的技术概念之一,对提升 Web 应用的安全、性能和体验有着很大的意义,非常值得我们去了解与学习。对 PWA 感兴趣的朋友欢迎关注《PWA 学习与实践》系列文章。
1. 引言
在之前的几篇文章中,我和大家分享了如何使用 manifest(以及 meta 标签)让你的 Web App 更加“native”;以及如何使用 Service Worker 来 cache 资源,加速 Web App 的访问速度,提供部分离线功能。在接下来的内容里,我们会探究 PWA 中的另一个重要功能——消息推送与提醒(Push & Notification)。这个能力让我们可以从服务端向用户推送各类消息并引导用户触发相应交互。

实际上,消息推送与提醒是两个功能——Push API 和 Notification API。为了大家能够更好理解其中的相关技术,我也会分为 Push(推送消息)与 Notification(展示提醒)两部分来介绍。在这一篇里,我们先来学习如何使用 Push API 进行消息推送。
Push API 和 Notification API 其实是两个独立的技术,完全可以分开使用;不过 Push API 和 Notification API 相结合是一个常见的模式。
2. 浏览器是如何实现服务器消息 Push 的
Web Push 的整个流程相较之前的内容来说有些复杂。因此,在进入具体技术细节之前,我们需要先了解一下整个 Push 的基本流程与相关概念。
如果你对 Push 完全不了解,可能会认为,Push 是我们的服务端直接与浏览器进行交互,使用长连接、WebSocket 或是其他技术手段来向客户端推送消息。然而,这里的 Web Push 并非如此,它其实是一个三方交互的过程。
在 Push 中登场的三个重要“角色”分别是:

浏览器:就是我们的客户端
Push Service:专门的 Push 服务,你可以认为是一个第三方服务,目前 chrome 与 firefox 都有自己的 Push Service Service。理论上只要浏览器支持,可以使用任意的 Push Service
后端服务:这里就是指我们自己的后端服务

下面就介绍一下这三者在 Web Push 中是如何交互。
2.1. 消息推送流程
下图来自 Web Push 协议草案,是 Web Push 的整个流程:
+——-+ +————–+ +————-+
| UA | | Push Service | | Application |
+——-+ +————–+ | Server |
| | +————-+
| Subscribe | |
|———————>| |
| Monitor | |
|<====================>| |
| | |
| Distribute Push Resource |
|——————————————–>|
| | |
: : :
| | Push Message |
| Push Message |<———————|
|<———————| |
| | |
该时序图表明了 Web Push 的各个步骤,我们可以将其分为订阅(subscribe)与推送(push)两部分来看。

subscribe,首先是订阅:

Ask Permission:这一步不再上图的流程中,这其实是浏览器中的策略。浏览器会询问用户是否允许通知,只有在用户允许后,才能进行后面的操作。
Subscribe:浏览器(客户端)需要向 Push Service 发起订阅(subscribe),订阅后会得到一个 PushSubscription 对象
Monitor:订阅操作会和 Push Service 进行通信,生成相应的订阅信息,Push Service 会维护相应信息,并基于此保持与客户端的联系;
Distribute Push Resource:浏览器订阅完成后,会获取订阅的相关信息(存在于 PushSubscription 对象中),我们需要将这些信息发送到自己的服务端,在服务端进行保存。

Push Message,然后是推送:

Push Message 阶段一:我们的服务端需要推送消息时,不直接和客户端交互,而是通过 Web Push 协议,将相关信息通知 Push Service;
Push Message 阶段二:Push Service 收到消息,通过校验后,基于其维护的客户端信息,将消息推送给订阅了的客户端;
最后,客户端收到消息,完成整个推送过程。

2.2. 什么是 Push Service
在上面的 Push 流程中,出现了一个比较少接触到的角色:Push Service。那么什么是 Push Service 呢?
A push service receives a network request, validates it and delivers a push message to the appropriate browser.
Push Service 可以接收网络请求,校验该请求并将其推送给合适的浏览器客户端。Push Service 还有一个非常重要的功能:当用户离线时,可以帮我们保存消息队列,直到用户联网后再发送给他们。
目前,不同的浏览器厂商使用了不同的 Push Service。例如,chrome 使用了 google 自家的 FCM(前身为 GCM),firefox 也是使用自家的服务。那么我们是否需要写不同的代码来兼容不同的浏览器所使用的服务呢?答案是并不用。Push Service 遵循 Web Push Protocol,其规定了请求及其处理的各种细节,这就保证了,不同的 Push Service 也会具有标准的调用方式。
这里再提一点:我们在上一节中说了 Push 的标准流程,其中第一步就是浏览器发起订阅,生成一个 PushSubscription 对。Push Service 会为每个发起订阅的浏览器生成一个唯一的 URL,这样,我们在服务端推送消息时,向这个 URL 进行推送后,Push Service 就会知道要通知哪个浏览器。而这个 URL 信息也在 PushSubscription 对象里,叫做 endpoint。

那么,如果我们知道了 endpoint 的值,是否就代表我们可以向客户端推送消息了呢?并非如此。下面会简单介绍一下 Web Push 中的安全策略。
2.3. 如何保证 Push 的安全性
在 Web Push 中,为了保证客户端只会收到其订阅的服务端推送的消息(其他的服务端即使在拿到 endpoint 也无法推送消息),需要对推送信息进行数字签名。该过程大致如下:
在 Web Push 中会有一对公钥与私钥。客户端持有公钥,而服务端持有私钥。客户端在订阅时,会将公钥发送给 Push Service,而 Push Service 会将该公钥与相应的 endpoint 维护起来。而当服务端要推送消息时,会使用私钥对发送的数据进行数字签名,并根据数字签名生成一个叫】Authorization 请求头。Push Service 收到请求后,根据 endpoint 取到公钥,对数字签名解密验证,如果信息相符则表明该请求是通过对应的私钥加密而成,也表明该请求来自浏览器所订阅的服务端。反之亦然。

而公钥与私钥如何生成,会在第三部分的实例中讲解。
3. 如何使用 Push API 来推送向用户推送信息
到这里,我们已经基本了解了 Web Push 的流程。光说不练假把式,下面我就通过具体代码来说明如何使用 Web Push。
这部分会基于 sw-cache 分支上的代码,继续增强我们的“图书搜索”WebApp。
为了使文章与代码更清晰,将 Web Push 分为这几个部分:

浏览器发起订阅,并将订阅信息发送至后端;
将订阅信息保存在服务端,以便今后推送使用;
服务端推送消息,向 Push Service 发起请求;
浏览器接收 Push 信息并处理。

友情提醒:由于 Chrome 所依赖的 Push Service——FCM 在国内不可访问,所以要正常运行 demo 中的代码需要“梯子”,或者可以选择 Firefox 来进行测试。
3.1. 浏览器(客户端)生成 subscription 信息
首先,我们需要使用 PushManager 的 subscribe 方法来在浏览器中进行订阅。
在《让你的 WebApp 离线可用》中我们已经知道了如何注册 Service Worker。当我们注册完 Service Worker 后会得到一个 Registration 对象,通过调用 Registration 对象的 registration.pushManager.subscribe() 方法可以发起订阅。
为了使代码更清晰,本篇 demo 在之前的基础上,先抽离出 Service Worker 的注册方法:
// index.js
function registerServiceWorker(file) {
return navigator.serviceWorker.register(file);
}
然后定义了 subscribeUserToPush() 方法来发起订阅:
// index.js
function subscribeUserToPush(registration, publicKey) {
var subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: window.urlBase64ToUint8Array(publicKey)
};
return registration.pushManager.subscribe(subscribeOptions).then(function (pushSubscription) {
console.log(‘Received PushSubscription: ‘, JSON.stringify(pushSubscription));
return pushSubscription;
});
}

这里使用了 registration.pushManager.subscribe() 方法中的两个配置参数:userVisibleOnly 和 applicationServerKey。

userVisibleOnly 表明该推送是否需要显性地展示给用户,即推送时是否会有消息提醒。如果没有消息提醒就表明是进行“静默”推送。在 Chrome 中,必须要将其设置为 true,否则浏览器就会在控制台报错:

applicationServerKey 是一个客户端的公钥,VAPID 定义了其规范,因此也可以称为 VAPID keys。如果你还记得 2.3 中提到的安全策略,应该对这个公钥不陌生。该参数需要 Unit8Array 类型。因此定义了一个 urlBase64ToUint8Array 方法将 base64 的公钥字符串转为 Unit8Array。subscribe() 也是一个 Promise 方法,在 then 中我们可以得到订阅的相关信息——一个 PushSubscription 对象。下图展示了这个对象中的一些信息。注意其中的 endpoint,Push Service 会为每个客户端随机生成一个不同的值.

之后,我们再将 PushSubscription 信息发送到后端。这里定义了一个 sendSubscriptionToServer() 方法,该方法就是一个普通的 XHR 请求,会向接口 post 订阅信息,为了节约篇幅就不列出具体代码了。
最后,将这一系列方法组合在一起。当然,使用 Web Push 前,还是需要进行特性检测 ’PushManager’ in window。
// index.js
if (‘serviceWorker’ in navigator && ‘PushManager’ in window) {
var publicKey = ‘BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A’;
// 注册 service worker
registerServiceWorker(‘./sw.js’).then(function (registration) {
console.log(‘Service Worker 注册成功 ’);
// 开启该客户端的消息推送订阅功能
return subscribeUserToPush(registration, publicKey);
}).then(function (subscription) {
var body = {subscription: subscription};
// 为了方便之后的推送,为每个客户端简单生成一个标识
body.uniqueid = new Date().getTime();
console.log(‘uniqueid’, body.uniqueid);
// 将生成的客户端订阅信息存储在自己的服务器上
return sendSubscriptionToServer(JSON.stringify(body));
}).then(function (res) {
console.log(res);
}).catch(function (err) {
console.log(err);
});
}
注意,这里为了方便我们后面的推送,为每个客户端生成了一个唯一 IDuniqueid,这里使用了时间戳生成简单的 uniqueid。
此外,由于 userVisibleOnly 为 true,所以需要用户授权开启通知权限,因此我们会看到下面的提示框,选择“允许”即可。你可以在设置中进行通知的管理。

3.2. 服务端存储客户端 subscription 信息
为了存储浏览器 post 来的订阅信息,服务端需要增加一个接口 /subscription,同时添加中间件 koa-body 用于处理 body
// app.js
const koaBody = require(‘koa-body’);
/**
* 提交 subscription 信息,并保存
*/
router.post(‘/subscription’, koaBody(), async ctx => {
let body = ctx.request.body;
await util.saveRecord(body);
ctx.response.body = {
status: 0
};
});
接收到 subscription 信息后,需要在服务端进行保存,你可使用任何方式来保存它:mysql、redis、mongodb……这里为了方便,我使用了 nedb 来进行简单的存储。nedb 不需要部署安装,可以将数据存储在内存中,也可以持久化,nedb 的 api 和 mongodb 也比较类似。
这里 util.saveRecord() 做了这些工作:首先,查询 subscription 信息是否存在,若已存在则只更新 uniqueid;否则,直接进行存储。
至此,我们就将客户端的订阅信息存储完毕了。现在,就可以等待今后推送时使用。
3.3. 使用 subscription 信息推送信息
在实际中,我们一般会给运营或产品同学提供一个推送配置后台。可以选择相应的客户端,填写推送信息,并发起推送。为了简单起见,我并没有写一个推送配置后台,而只提供了一个 post 接口 /push 来提交推送信息。后期我们完全可以开发相应的推送后台来调用该接口。
// app.js
/**
* 消息推送 API,可以在管理后台进行调用
* 本例子中,可以直接 post 一个请求来查看效果
*/
router.post(‘/push’, koaBody(), async ctx => {
let {uniqueid, payload} = ctx.request.body;
let list = uniqueid ? await util.find({uniqueid}) : await util.findAll();
let status = list.length > 0 ? 0 : -1;

for (let i = 0; i < list.length; i++) {
let subscription = list[i].subscription;
pushMessage(subscription, JSON.stringify(payload));
}

ctx.response.body = {
status
};
});
来看一下 /push 接口。

首先,根据 post 的参数不同,我们可以通过 uniqueid 来查询某条订阅信息:util.find({uniqueid});也可以从数据库中查询出所有订阅信息:util.findAll()。
然后通过 pushMessage() 方法向 Push Service 发送请求。根据第二节的介绍,我们知道,该请求需要符合 Web Push 协议。然而,Web Push 协议的请求封装、加密处理相关操作非常繁琐。因此,Web Push 为各种语言的开发者提供了一系列对应的库:Web Push Libaray,目前有 NodeJS、PHP、Python、Java 等。把这些复杂而繁琐的操作交给它们可以让我们事半功倍。
最后返回结果,这里只是简单的根据是否有订阅信息来进行返回。

安装 node 版 web-push
npm install web-push –save
前面我们提到的公钥与私钥,也可以通过 web-push 来生成

使用 web-push 非常简单,首先设置 VAPID keys:
// app.js
const webpush = require(‘web-push’);
/**
* VAPID 值
* 这里可以替换为你业务中实际的值
*/
const vapidKeys = {
publicKey: ‘BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A’,
privateKey: ‘TVe_nJlciDOn130gFyFYP8UiGxxWd3QdH6C5axXpSgM’
};

// 设置 web-push 的 VAPID 值
webpush.setVapidDetails(
‘mailto:alienzhou16@163.com’,
vapidKeys.publicKey,
vapidKeys.privateKey
);
设置完成后即可使用 webpush.sendNotification() 方法向 Push Service 发起请求。
最后我们来看下 pushMessage() 方法的细节:
// app.js
/**
* 向 push service 推送信息
* @param {*} subscription
* @param {*} data
*/
function pushMessage(subscription, data = {}) {
webpush.sendNotification(subscription, data, options).then(data => {
console.log(‘push service 的相应数据:’, JSON.stringify(data));
return;
}).catch(err => {
// 判断状态码,440 和 410 表示失效
if (err.statusCode === 410 || err.statusCode === 404) {
return util.remove(subscription);
}
else {
console.log(subscription);
console.log(err);
}
})
}
webpush.sendNotification 为我们封装了请求的处理细节。状态码 401 和 404 表示该 subscription 已经无效,可以从数据库中删除。
3.4. Service Worker 监听 Push 消息
调用 webpush.sendNotification() 后,我们就已经把消息发送至 Push Service 了;而 Push Service 会将我们的消息推送至浏览器。
要想在浏览器中获取推送信息,只需在 Service Worker 中监听 push 的事件即可:
// sw.js
self.addEventListener(‘push’, function (e) {
var data = e.data;
if (e.data) {
data = data.json();
console.log(‘push 的数据为:’, data);
self.registration.showNotification(data.text);
}
else {
console.log(‘push 没有任何数据 ’);
}
});
4. 效果展示
我们同时使用 firefox 与 chrome 来访问该 WebApp,并分别向这两个客户端推送消息。我们可以使用 console 中打印出来的 uniqueid,在 postman 中发起 /push 请求进行测试。

可以看到,我们分别向 firefox 与 chrome 中推送了“welcome to PWA”这条消息。console 中的输出来自于 Service Worker 中对 push 事件的监听。而弹出的浏览器提醒则来自于之前提到的、订阅时配置的 userVisibleOnly: true 属性。在后续的文章里,我继续带大家了解 Notification API(提醒)的使用。
正如前文所述,Push Service 可以在设备离线时,帮你维护推送消息。当浏览器设备重新联网时,就会收到该推送。下面展示了在设备恢复联网后,就会收到推送:

5. 万恶的兼容性
又到了查看兼容性的时间了。比较重要的是,对于 Push API,目前 Safari 团队并没有明确表态计划支持。

当然,其实比兼容性更大的一个问题是,Chrome 所依赖的 FCM 服务在国内是无法访问的,而 Firefox 的服务在国内可以正常使用。这也是为什么在代码中会有这一项设置:
const options = {
// proxy: ‘http://localhost:1087’ // 使用 FCM(Chrome)需要配置代理
};
上面代码其实是用来配置 web-push 代理的。这里有一点需要注意,目前从 npm 上安装的 web-push 是不支持设置代理选项的。针对这点 github 上专门有 issue 进行了讨论,并在最近(两周前)合入了相应的 PR。因此,如果需要 web-push 支持代理,简单的方式就是基于 master 进行 web-push 代码的相应调整。
虽然由于 google 服务被屏蔽,导致国内 Push 功能无法在 chrome 上使用,但是作为一个重要的技术点,Web Push 还是非常值得我们了解与学习的。
6. 写在最后
本文中所有的代码示例均可以在 learn-pwa/push 上找到。注意在 git clone 之后,切换到 push 分支。切换其他分支可以看到不同的版本:

basic 分支:基础项目 demo,一个普通的图书搜索应用(网站);
manifest 分支:基于 basic 分支,添加 manifest 等功能;
sw-cache 分支:基于 manifest 分支,添加缓存与离线功能;
push 分支:基于 sw-cache 分支,添加服务端消息推送功能;
master 分支:应用的最新代码。

如果你喜欢或想要了解更多的 PWA 相关知识,欢迎关注我,关注《PWA 学习与实践》系列文章。我会总结整理自己学习 PWA 过程的遇到的疑问与技术点,并通过实际代码和大家一起实践。
在下一篇文章里,我们先缓下脚步——工欲善其事,必先利其器。在继续了解更多 PWA 相关技术之前,先了解一些 chrome 上的 PWA 调试技巧。之后,我们会再回来继续了解另一个经常与 Push API 组合在一起的功能——消息提醒,Notification API。
《PWA 学习与实践》系列

第一篇:2018,开始你的 PWA 学习之旅
第二篇:10 分钟学会使用 Manifest,让你的 WebApp 更“Native”
第三篇:从今天起,让你的 WebApp 离线可用
第四篇:TroubleShooting: 解决 FireBase login 验证失败问题
第五篇:与你的用户保持联系: Web Push 功能(本文)
第六篇:How to Debug? 在 chrome 中调试你的 PWA
第七篇:增强交互:使用 Notification API 来进行提醒
第八篇:使用 Service Worker 进行后台数据同步
第九篇:PWA 实践中的问题与解决方案
第十篇:Resource Hint – 提升页面加载性能与体验
第十一篇:从 PWA 离线工具集 workbox 中学习各类离线策略(写作中…)

参考资料

Generic Event Delivery Using HTTP Pus (draft-ietf-webpush-protocol-12)
FCM 简单介绍
How Push Works

正文完
 0