不少时候,原生应用会经过一些消息推送来唤起用户的关注,增长驻留率。网页该怎么作呢?有没有相似原生应用的推送机制?推送功能又能玩出什么花样呢?html
Push API 给与了 Web 应用程序接收从服务器发出的推送消息的能力,不管 Web 应用程序是否在用户设备前台,甚至刚加载完成。这样,开发人员就能够向用户投放异步通知和更新,从而让用户能更及时地获取新内容。node
对 Web 应用来讲,要想使用推送,必须在应用下的 ServiceWorker 处于激活状态,在 ServiceWorkerRegistration
scope 下的 PushManager
来作推送订阅相关工做。git
在 ServiceWorkerGlobalScope
scope 下经过 onpush
来监听推送事件。github
激活一个 service worker 来提供推送消息会致使资源消耗的增长,尤为是电池。不一样的浏览器对此有不一样的方案——目前为止尚未标准的机制。Firefox 容许对发送给应用的推送消息作数量限制(配额)。该限制会在站点每一次被访问以后刷新。相比之下,Chrome 选择不作限制,但要求站点在每一次消息到达后都显示通知,这样可让用户确认他们仍但愿接收消息并确保用户可见性。web
Push 的相关接口:算法
PushManager 接口用于操做推送订阅。chrome
经过 ServiceWorkerRegistration.PushManager
获取。数据库
subscribe()npm
用于订阅推送服务。json
返回一个 Promise 形式的 PushSubscription 对象,该对象包含了推送订阅详情。若是当前 service worker 没有已存在的订阅,则会建立一个新的推送订阅。
语法:
PushManager.subscribe(options).then(function(pushSubscription) { ... } );
复制代码
参数:
options:
触发推送时,浏览器的表现:
Base64 转 Uint8
function base64UrlToUint8Array(base64UrlData) {
const padding = '='.repeat((4 - base64UrlData.length % 4) % 4);
const base64 = (base64UrlData + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const buffer = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
buffer[i] = rawData.charCodeAt(i);
}
return buffer;
}
复制代码
getSubscription()
用于获取订阅对象 PushSubscription。
它返回一个 Promise 用来处理一个包含已经发布的分支的细节的PushSubscription 对象。若是没有已经发布的分支存在,返回null。
语法:
PushManager.getSubscription().then(function(pushSubscription) { ... } );
复制代码
permissionState()
用于获取 PushManager 的权限状态。
语法:
PushManager.permissionState(options).then(function(PushMessagingState) { ... });
复制代码
参数:
options:
返回 Promise,以下值:
以下使用:
ServiceWorkerRegistration.pushManager.permissionState({userVisibleOnly: true})
复制代码
Push API 接收消息时的事件。此事件在 ServiceWorkerGlobalScope 下响应。
data:返回对 PushMessageData 类型,包含发送到的数据的对象。
此接口为 PushEvent.data 中的类型。
与 Fetch 中 Body 的方法类似,不一样处再于能够重复调用。
PushSubscription 为 PushManager.subscribe() 的订阅信息类型。
getKey()
用于获取 PushSubscription 中订阅的公钥信息,返回 ArrayBuffer。
语法:
var key = subscription.getKey(name);
复制代码
参数:
name:
toJSON()
序列化 PushSubscription 对象,用于存储和发送给应用服务器。
subscription.toJSON()
复制代码
返回以下结构:
{
endpoint: "https://fcm.googleapis.com/fcm/send/xxx:zzzzzzzzz"
expirationTime: null
keys: {
auth: "xxxx-zzzz"
p256dh: "BasdfasdfasdfasdffsdafasdfFMRs"
}
}
复制代码
unsubscribe()
用于取消订阅推送服务。
语法:
PushSubscription.unsubscribe().then(function(Boolean) { ... });
复制代码
返回 Promise 的 Boolean。若是 true,则退订成功。
相关属性、方法:
Push API 经过下面的 serviceWorker 事件来监控并响应推送和订阅更改事件。
当 ServiceWorker 收到 Push-Server 推送的消息时,就会触发 ServiceWorkerGlobalScope 接口的 onpush 事件。
语法:
ServiceWorkerGlobalScope.onpush = function(PushEvent) { ... }
self.addEventListener('push', function(PushEvent) { ... })
复制代码
经过 PushEvent.data
来获取 PushMessageData 类型的推送消息中的数据。
当订阅信息发生改变时,会触发 ServiceWorkerGlobalScope 接口的 onpushsubscriptionchange 事件,例如:若是推送服务器设置了订阅到期时间,则可能会触发此事件。(正常订阅/退订时不会触发此事件)
发生此事件时,一般须要从新订阅推送服务器,并把新的订阅体发送给应用服务器。
语法:
ServiceWorkerGlobalScope.onpushsubscriptionchange = function() { ... }
self.addEventListener('pushsubscriptionchange', function() { ... })
复制代码
浏览器端订阅:
浏览器端在订阅 Push Server 时,必须 Notification 是受权的,不然会出现受权窗口,这里的受权交互和 Notification 的受权是同样的。
注意:Notificatino 的受权状态手动调整改变后,订阅体将失效,须要从新订阅。
注意:目前大部分国内网络环境没法访问 Chrome 的 FCM 推送服务器,因此在不出海的网络环境下浏览器没法完成订阅。FireFox 的推送服务器不存在此问题,因此能够在 FireFox 下测试此功能。
// 浏览器订阅
navigator.serviceWorker.ready.then(swReg => {
swReg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
)
}).then(pushSubscription => {
// 将订阅信息发送到你的应用服务器
fetch("https://你的应用服务器", {
method: "post",
body: JSON.stringify(pushSubscription.toJSON())
});
}).catch(e => {
console.log('订阅失败', e)
console.log('受权状态:' + await self.registration.pushManager.permissionState({userVisibleOnly:true}))
});
});
复制代码
关于推送请求问题,须要使用 VAPID 协议。
订阅时applicationServerKey
使用 VAPID 公钥做为识别标示,规范中要求公钥须要 UInt8 类型,因此订阅前要进行类型转换。
应用服务器端发送:
应用服务器从数据库里取出你的订阅信息,而后根据 Web Push 协议要求,对要发送的消息进行拼装和加密,而后发送给相应的 Push 服务器,而后 Push 服务器再根据订阅信息中的标志发送给相应的终端。
设备端接收:
浏览器端收到推送消息后,会激活相应的 ServiceWorker 线程,并触发 Push 事件。
例如收到消息后,展现一个 Notification,或者作任何其余的事:
// serviceWorker 环境下
self.addEventListener("push", function(event) {
// 此处能够作任何事
console.log("push", event);
var data = event.data.json();
if (!(self.Notification && self.Notification.permission === "granted")) {
return;
}
self.registration.showNotification(data.title, {
body: data.body
});
});
复制代码
在 subscribe()
方法中的 applicationServerKey
选项用于推送服务器鉴别订阅用户的应用服务,并用确保推送消息发送给哪一个订阅用户。
applicationServerKey
是一对公私钥。私钥应用服务器保存,公钥交给浏览器,浏览器订阅时将这个公钥传给推送服务器,这样推送服务器能够将你的公钥和用户的 PushSubscription
绑定。
当你的服务器要发送推送消息时,须要建立一个 Authorization
的 header 头,Authorization
由规范要求的加密算法进行私钥加密。推送消息收到消息时,首先取消息请求中 endpoint
对应的公钥,解码消息请求中签名过的 Authorization
header 头,验证签名是否合法,防止它人伪造身份。经过后,推送服务器把消息发送到相应的设备浏览器。
注:这里说的 applicationServerKey 就是 VAPID key。
Authorization 的签名采用 JWT(JSON web token),JWT 是一种向第三方发送消息的方式,三方收到后,获取发送者的公钥进行验证 JWT 的签名。
JWT 结构:
JWT 信息和 JWT 数据须要使用 base64 编码,因此内容是公开的。
JWT 信息部分必须包含:
{
"typ": "JWT",
"alg": "ES256"
}
复制代码
说明此签名用的哪一种算法。
JWT 数据部分,提供有关 JWT 的发送者、目标用户及有效时间等信息。
{
"aud": "https://xxx.push-server.com",
"exp": "1469632224",
"sub": "mailto:xxx@contact.com"
}
复制代码
JWT 签名部分,是取 JWT 信息部分和 JWT 数据部分的字符串拼接结果,中间用.
链接,生成未签名的令牌,而后进行签名生成的。
签名是基于应用服务器生成的 VAPID 私钥进行加密的,nodejs 可使用 jws 库来签名:
const jws = require('jws');
const asn1 = require('asn1.js');
const header = {
typ: 'JWT',
alg: 'ES256'
};
const jwtPayload = {
aud: audience,
exp: expiration,
sub: subject
};
const jwt = jws.sign({
header: header,
payload: jwtPayload,
privateKey: toPEM(privateKey)
});
function toPEM(key) {
return asn1
.define("ECPrivateKey", function() {
this.seq().obj(
this.key("version").int(),
this.key("privateKey").octstr(),
this.key("parameters")
.explicit(0)
.objid()
.optional(),
this.key("publicKey")
.explicit(1)
.bitstr()
.optional()
);
})
.encode(
{
version: 1,
privateKey: key,
parameters: [1, 2, 840, 10045, 3, 1, 7] // prime256v1
},
"pem",
{
label: "EC PRIVATE KEY"
}
);
}
复制代码
Authorization 对 JWT 签名的格式要求:
Authorization: 'WebPush <JWT Info>.<JWT Data>.<Signature>'
复制代码
在签名的前面加上 WebPush
做为 Authorization 头的值发送给推送服务器。
推送协议同时要求Crypto-Key
header 头,用来发送公钥,并须要p256ecdsa=
前缀,格式:
Crypto-Key: p256ecdsa=<URL Safe Base64 Public Application Server Key>
复制代码
发送的消息部分,也就是 payload,为了保证安全性,协议里一样要求须要加密,且推送服务器没法解密,只有浏览器才能解密消息数据。
在浏览器向推送服务器进行订阅后产生的订阅体,在这里就用的上了,再看下结构:
{
endpoint: "https://fcm.googleapis.com/fcm/send/xxx:zzzzzzzzz"
expirationTime: null
keys: {
auth: "xxxx-zzzz"
p256dh: "BasdfasdfasdfasdffsdafasdfFMRs"
}
}
复制代码
结构中的 keys
字段就是浏览器端的密钥信息,由浏览器生成。
加密须要 auth
、p256dh
和payload
三个值作为输入进行加密,加密过程比较复杂。
能够看一下,生成的具体要发送给推送服务器的字段,下面是 FCM 的请求:
{
'hostname': "fcm.googleapis.com",
'port': null,
'path':
"/fcm/send/xxx-xx:APA91bFzxDp-j-xoN_kxqzie3uJS1aSNI5wI4SXL34dLWPFFa3QSZVBOE6eG7b4tb2RIvqUy3d3ww57In2lFsZW5MVsjQRtPFfbKoq9XqqrsTwRZiPDbPcbwZ4vkmv_1lnIHRo5yOxQF",
'headers': {
'TTL': 3600,
"Content-Length": 224,
"Content-Type": "application/octet-stream",
"Content-Encoding": "aesgcm",
'Encryption': "salt=lIiVReih7lcahHxS2UhENA",
"Crypto-Key":
"dh=BG9SmS2AixNf9UgRlOr1aEiVQMH5h47cAz0FW-_m9MRiwLqrUUP9DhrbFGXqaHAYh12IyKtvySbnDYNmF3Mh0d0;p256ecdsa=BDTgN25YAAabqE6ANPP49d2EkoLAMxT4xDZxE5BdrCHPyq1zk36LofZ2M3DYosxZzSG7i_26S1ViOGC_rBifW_U",
'Authorization':
"WebPush eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvbSIsImV4cCI6MTU1OTA3ODEwOSwic3ViIjoiaHR0cHM6Ly9kZXZlbG9wZXJzLmdvb2dsZS5jb20vd2ViL2Z1bmRhbWVudGFscy8ifQ.Fa3nW6Lt7cp2dGML71aZItdyIcEabZ4GRVtkQBc3dWavAGH3_xSh0jnT-Cy8vGHJrwwRSRKaOcbt-uniIYt6fA"
},
'method': "POST"
};
复制代码
密钥使用 ECDSA(椭圆曲线迪菲-赫尔曼金钥交换)的 ES256 算法(ECDSA使用 P-256 曲线和 SHA-256 哈希算法的缩写)。
基于 node 实现:
$ npm install -g web-push
$ web-push generate-vapid-keys
复制代码
基于浏览器 JS 实现:
function generateNewKeys() {
return crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'},
true, ['deriveBits'])
.then((keys) => {
return cryptoKeyToUrlBase64(keys.publicKey, keys.privateKey);
});
}
function cryptoKeyToUrlBase64(publicKey, privateKey) {
const promises = [];
promises.push(
crypto.subtle.exportKey('jwk', publicKey)
.then((jwk) => {
const x = base64UrlToUint8Array(jwk.x);
const y = base64UrlToUint8Array(jwk.y);
const publicKey = new Uint8Array(65);
publicKey.set([0x04], 0);
publicKey.set(x, 1);
publicKey.set(y, 33);
return publicKey;
})
);
promises.push(
crypto.subtle
.exportKey('jwk', privateKey)
.then((jwk) => {
return base64UrlToUint8Array(jwk.d);
})
);
return Promise.all(promises)
.then((exportedKeys) => {
return {
public: uint8ArrayToBase64Url(exportedKeys[0]),
private: uint8ArrayToBase64Url(exportedKeys[1]),
};
});
}
function base64UrlToUint8Array(base64UrlData) {
const padding = '='.repeat((4 - base64UrlData.length % 4) % 4);
const base64 = (base64UrlData + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const buffer = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
buffer[i] = rawData.charCodeAt(i);
}
return buffer;
}
function uint8ArrayToBase64Url(uint8Array, start, end) {
start = start || 0;
end = end || uint8Array.byteLength;
const base64 = window.btoa(
String.fromCharCode.apply(null, uint8Array.subarray(start, end)));
return base64
.replace(/\=/g, '') // eslint-disable-line no-useless-escape
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
复制代码
这里用 node 来实现一下应用服务器向推送服务器发送消息。(其余语言环境能够参考 web-push-libs)
const webpush = require("web-push");
const options = {
vapidDetails: {
subject: "mail@you.com", // 你的联系邮箱
publicKey: "公钥",
privateKey: "私钥"
},
TTL: 60 * 60 // 有效时间,单位秒
};
const subscription = db.getUser("xxx"); // 从数据库取用户的订阅对象
const payload = {
// 要发送的消息
msg: "hellow"
};
// 发送消息到推送服务器
webpush
.sendNotification(subscription, payload, options)
.then(() => {})
.catch(err => {
// err.statusCode
});
复制代码
基于 web-push-libs 这种封装好的库工具用起来很方便,几行代码就能够实现应用服务器到推送服务器之间的数据请求。
状态码 | 描述 |
---|---|
201 | 建立,收到并接受发送推送消息的请求 |
429 | 请求过多,意味着应用程序服务器已经达到了推送服务的速率限制。推送服务会包括 Retry-After 标头,来指示在下一个请求发出以前等多长时间 |
400 | 无效的请求,这一般意味着存在无效的 header 或格式不正确 |
404 | 未找到,这表示订阅已过时且没法使用。在这种状况下,你应该删除 PushSubscription 并等待客户端从新订阅用户 |
410 | 被移除,订阅再也不有效,应从应用程序服务器中删除。能够经过在 PushSubscription 上调用 unsubscribe() 来重现 |
413 | 有效负载过大,一个推送服务支持的最小的有效负载大小是 4096 bytes (或者 4kb) |
Android 系统:
Android 系统的消息机制是系统级的,系统有单独的进程去监听推送消息,收到消息就会唤醒对应的应用程序来处理这个推送消息,不管应用是否关闭。全部应用都采用这种处理方式。因此当收到浏览器的推送消息时,会唤醒浏览器,而后浏览器再去激活相应 的 ServiceWorker 线程,而后触发推送事件。
MAC 系统:
MAC 系统下当打开应用后,默认关闭应用实际上还在后台运行,能够经过 dock 来查看:
能够看到未彻底关闭的应用下面会有一个黑点来标志,在这种状况下,浏览器是能够收到推送消息的。
若是浏览器彻底关闭,则当在浏览器打开后,浏览器一样会收到通知消息(TTL 有效时间内)。
Windows 系统:
Windows 系统和 MAC 类似,但判断浏览器是否在后台运行比较复杂。
Chrome 环境下,地址栏输入chrome://gcm-internals/
,并点击Start Recording
按钮进行录制。
一般来讲,主要有两方面的问题:
若是经过上述工具解决不了问题,能够提交问题到官方:
Push 是工做在 serviceWorker 线程下的,因此不关系浏览器窗口是否打开。而 Web Sockets 必须保证浏览器和网页处于打开状态才能正常工做。
关于这一点,能够在国内服务器对消息通信的请求上部署代理服务器,如在 node 环境下用 web-push 库能够这么写:
webpush.sendNotification(
subscription,
data,
{
... options,
proxy: '代理地址'
}
)
复制代码
或者能够基于三方的推送工具来实现,如:onesignal。
博客名称:王乐平博客
CSDN博客地址:blog.csdn.net/lecepin