三七互娱技术团队

Web端消息推送最佳实践

一、前言

本文将叙述web端消息推送是什么,它的原理、项目实战、遇到的问题以及解决方法。

二、消息推送原理

1、什么是消息推送

web端消息推送用一句话解释就是:服务端向浏览器客户端发送了一条消息,客户端收到推送消息后以通知的形式展示出来,用户点击消息之后能进行一系列后续的操作。这个能力让我们可以从服务端向用户推送各类消息并引导用户触发相应交互,例如:

  • 游戏新活动推广,推送消息给玩家,点击即进入活动页面。

  • 玩家很久没有进入游戏了,推送消息告知玩家这段时间游戏的更新,召回玩家

2、消息推送与通知提醒流程

从1中可知,消息推送包括两部分的功能:消息推送与通知提醒,整个流程有些复杂,因此,在进入具体技术细节之前,我们先了解一下整个流程与相关概念

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

Image

subscribe阶段:
在订阅阶段,首先浏览器会询问用户是否允许通知,只有在用户允许后,才能进行后面的操作。这一步不在上图的流程中,这其实是浏览器中的策略。客户端如果愿意接收服务端的推送,会利用service worker的的接口向 push service 发起一个订阅。push service是提供中间人服务的服务器,由浏览器负责,在使用时对普通开发者基本是透明的。

push service 返回 subscription

客户端发起subscribe后,要收到返回的subscription对象才算成功。得到的subscription会被接着传给应用服务器

向服务器发送 subscription
客户端得到的 subscription 要发给服务器。这样服务器在推送消息时,才能向 push service 证明自己和客户端是有建立合法推送约定的

服务器向客户端推送消息
服务器是不能直接向客户端发送消息的,它只是将客户端给的subscription对象和要推送的消息一起发给 push service,然后由push service 确认了 它 与客户端的有效订阅关系后,由 push service 代为推送

push serivce 推送
push serivce 再验证过服务器和客户端的订阅关系后,会将服务器的消息推送给客户端。客户端接收到消息后,以通知的形式展示出来。

3、具体实现

以下分别具体介绍获取通知授权、订阅消息、推送消息、接受消息并展示通知的实现

获取授权

在订阅消息之前,浏览器需要得到用户授权,同意后才能使用消息推送服务。通知授权类似如下图:

Image

显示以上对话框有两种方式:

(1)在订阅之前先获取用户授权,使用Notification.requestPermission方法

Notification.requestPermission().then((permission) => { console.log("permission=", permission); if (permission === "granted") {    //do something } else if (permission === "denied") {    //do something } else {    //do something }});

Notification.requestPermission()方法执行会返回授权结果,主要有granted(已授权)、denied(被拒绝)、default(被关闭)这3中状态。只有当授权结果为granted时,当前网页才能进行后面订阅推送服务和通知消息这两个步骤。

(2)如果不选择使用方法(1),在正式订阅时浏览器也会自动弹出,对于开发者而言不需要显式调用

值得注意的是,当用户允许或者拒绝授权后,后续都不会重复询问。想要更改这个设置,在 Chrome 地址栏左侧网站信息中如下手动修改:

Image

订阅消息

订阅消息的具体实现步骤如下:

(1)注册 Service Worker

(2)使用 pushManager 添加订阅,浏览器向推送服务发送请求,其中传递参数对象包含两个属性:

  • userVisibleOnly,不允许静默的推送,所有推送都对用户可见,所以值为true

  • applicationServerKey,服务器生成的公钥

(3)得到推送服务成功响应后,浏览器将推送服务返回的 subscribe,向后端服务器发送这个subscribe并存储

代码参考如下:

// 注册Service Workerif ("serviceWorker" in navigator && "PushManager" in window) {  navigator.serviceWorker    .register("./service-worker.js")    .then(function (reg) {});  navigator.serviceWorker.ready.then(function (reg) {    subscribe(reg);  });}

// 发起订阅function subscribe(serviceWorkerReg) { serviceWorkerReg.pushManager .subscribe({ userVisibleOnly: true, applicationServerKey: "xxxx" }) .then(function (subscribe) { //获取到的subscribe发送推送给后端存储起来 sendToServer(subscribe); }) .catch(function () { // 用户拒绝了订阅请求 if (Notification.permission === "denied") { //do something } });}

插播一个生成公钥私钥的方法:可以用web push的Node包生成:

(1)npm install web-push --save
(2)每运行一次就会生成一对新的密钥对,公钥私钥只要能配套就好,公钥在浏览器端使用,用来生成subscribe,私钥在服务端使用,用来发Push,代码如下:
const webpush = require('web-push');//VAPID keys should only be generated only once.const vapidKeys = webpush.generateVAPIDKeys();console.log(vapidKeys.publicKey, vapidKeys.privateKey);

推送消息

当服务器想推送消息给用户时,可以用FCM提供的web push的库发送推送,它支持多种语言,包括Node.js/PHP等版本。用Node.js可以这样发Push:

const webpush = require("web-push");// 从数据库取出用户的subsciption,例如取出的subsciption如下const pushSubscription = {  endpoint: "xxx",  expirationTime: null,  keys: {    p256dh: "xxxx",    auth: "xxxx",  },};// push的数据const payload = {  title: "消息标题",  body: "点开看看吧",  icon: "xxx.png",  data: { url: "https://www.xxx.com" },};webpush.sendNotification(pushSubscription, JSON.stringify(payload));

推送服务接收到了服务器的调用请求,向设备推送消息。

接收消息并显示通知

要想在浏览器中接收推送信息,只需在Service Worker中监听push事件即可,接收到消息之后调用通知api展示通知:

this.addEventListener("push", function (event) {  console.log("[Service Worker] Push Received.");  console.log(`[Service Worker] Push had this data: "${event.data.text()}"`);  let notificationData = event.data.json();  const title = notificationData.title;  // 弹通知消息框  event.waitUntil(self.registration.showNotification(title, notificationData));});

本次项目使用的是firebase云消息服务,firebase对一系列api进行了封装,在我们项目中的流程为:

Image

原理大家都懂了,下面关结合代码介绍关键步骤:

1、firebase配置项目

在最开始,需要在firebase上进行云消息项目的配置,配置完成后,就可以拿到公钥和私钥、项目id等信息,用于初始化firebase以及后端发送推送时需要的一些私钥等。

2、消息推送相关api浏览器支持情况检查

来到步骤2,进行消息推送相关api浏览器支持情况检查,若检查到浏览器不支持,则进行不支持回调,该浏览器无法使用消息推送功能:

//检查是否支持service worker、notification、pushManagerfunction checkFirebaseSupport() {  return (    checkNotificationSupport() &&    checkServiceworkerSupport() &&    checkPushMessageSupport()  );}
//Notification支持判断function checkNotificationSupport() { return "Notification" in window;}
//service worker支持判断function checkServiceworkerSupport() { return "serviceWorker" in window.navigator;}
//PushManager支持判断function checkPushMessageSupport() { return "PushManager" in window;}

3、初始化firebase

确认支持之后初始化firebase,带上创建项目时的参数,具体看如下代码注释解析:

import * as firebase from "firebase";//firebase项目初始化function firebaseInit() {  firebase.initializeApp({    messagingSenderId: FCM_APP_ID,    projectId: PROJECT_ID,    apiKey: API_KEY,    appId: APP_ID,  });  this.messaging = firebase.messaging();  window.messaging = this.messaging;  htmlOnMessageInit();}//当页面聚焦时,监听推送事件,必须初始化,否则收不到推送function htmlOnMessageInit() {  window.addEventListener("load", function () {    //手动更新service worker    navigator.serviceWorker      .getRegistration("/firebase-cloud-messaging-push-scope")      .then((registration) => {        registration && registration.update();      });    if (window.messaging) {      //firebase封装的方法,监听service worker的postMessage事件      //当收到推送且用户当前浏览器页面处于该service worker的作用域范围时,能拿到推送的数据payload      //考虑到用户已经处于我们平台的页面了,此时不显示通知,实际上是可以调用notification的api去显示通知的      window.messaging.onMessage(function (payload) {        console.log("Message received:", payload);      });    }  });}

4、通知授权

当用户触发页面授权的操作时,会弹出询问是否允许授权接收通知弹窗,通知有3种状态,能监听到状态并进行行为上报

//调起授权function getRequestPermission(grantedCallback, deniedCallback, closeCallback) {  //调起授权询问  Notification.requestPermission().then(async (permission) => {    switch (permission) {      case "granted": {        //此处可加上允许授权事件上报        //调用获取subscribe方法        let token = await _this.getToken();        if (token) {          grantedCallback(token);          sendTokenToServer(token);        }        break;      }      case "denied": {        //此处可加上拒绝授权事件上报        deniedCallback();        break;      }      default: {        //此处可加上默认处理、关闭通知授权事件上报        closeCallback();        break;      }    }  });}

5、获取订阅

若授权了,则能够调用firebase封装好的getToken()方法获取到subscribe,唯一标识此浏览器,并将此标识发送给后端存储起来

//获取firebase tokenasync function getToken() {  try {    //firebase封装好的getToken()方法获取到subscribe    let token = await this.messaging.getToken();    return token;  } catch (e) {    console.log("get token error", e);    return false;  }}

6、推送消息

后端使用FCM提供的web push的库发送推送,带上私钥和subscribe

from firebase_admin import messagingimport sysimport jsoncred = credentials.Certificate('xxxx.json')default_app = firebase_admin.initialize_app(cred)message = messaging.Message(    data={        'data_title': 'test title',        'data_body': 'hahahaha',        'data_icon': 'xxx',        'jump_url': 'xxx',        'send_id' : '123123',        'isShow': 'true',    },    token='xxx')
response = messaging.send(message)

7、接收推送并展示通知

FCM验证过后,发送push给浏览器,service worker监听push事件,根据所在页面不同状态而调用不同的处理方法并进行行为统计上报

a.当浏览器位于前台时,考虑到用户已经在当前活动页面了就不显示通知了(实际上也是可以调用notification的方法显示通知的),但是能监听到消息

if (window.messaging) {  //此处是与service worker进行通信  window.messaging.onMessage(function (payload) {    console.log("Message received:", payload);  });}

b.当浏览器位于后台时,收到并显示通知,在service worker文件处理:

//当页面位于后台时,调用的是此方法messaging.setBackgroundMessageHandler(function (payload) {  console.log(    "[firebase-messaging-sw.js] Received background message ",    payload  );  if (payload.data.isShow === "true") {    //payload.data可获取到消息的内容    const notification = payload.data;    //设置消息的标题、内容、图标、点击需要跳转的地址、该消息的标识    const notificationTitle = notification.data_title;    const notificationOptions = {      body: notification.data_body,      icon: notification.data_icon,      data: {        linkUrl: notification.jump_url,        sendId: notification.send_id,      },    };    //向后端发送收到消息统计    //service worker限制无法使用XMLHttpRequest,可使用fetch api发起请求    var request = new Request(      `https://xxx.xxx.com/game_message/show_times?SEND_ID=${notification.send_id}`,      {        method: "GET",        mode: "no-cors",        redirect: "follow",        headers: new Headers({          "Content-Type": "text/plain",        }),      }    );    fetch(request)      .then(function () {        console.log("send show notification statistics succ");      })      .catch(function (err) {        console.log("send show notification statistics fail");      });    //设置显示通知    return self.registration.showNotification(      notificationTitle,      notificationOptions    );  }});

四、问题&解决

1、如何监控用户收到消息后的行为?

firebase消息分2种类型,分别为通知消息和数据消息

  • 后端在推送消息时,若使用的是通知字段推送,则由后端控制通知显示行为,不会去调前端的控制方法;

  • 后端在推送消息时,若使用的是数据字段推送,则由前端端控制通知显示行为,这时就能进行一些行为监控和数据上报了,比如收到、点击、关闭推送通知上报

但是不管哪一种方式,页面都必须要监听messaging.onMessage()方法,否则无法收到推送(踩过的坑)

2、如何更新service worker文件?

(1)每次进入页面,代码手动更新servie worker文件,当service worker文件有更新的时候,会再次进行安装,但是安装完成之后处于waiting状态,还是旧的service worker控制页面,需要关闭页面之后再打开页面,新的service worker才能控制页面,于是需要配合(2)的使用,安装完成之后新service worker就能控制页面了(网上说service worker每24小时会自动更新一次,测试结果来看是没有的)

navigator.serviceWorker  .getRegistration("/firebase-cloud-messaging-push-scope")  .then((registration) => {    registration && registration.update();  });

2)在service worker的install事件中,调用self.skipWaiting()跳过waiting状态直接进入activate激活状态,新service worker控制页面

self.addEventListener("install", function (event) {  self.skipWaiting();})

3、是否需要频繁授权通知,影响用户体验?(通知授权逻辑是如何的呢?)

文档没说明,那就手动测试一下吧:

Image

4、如何清除无效token?

背景:目前发送失败一次我们才知道这条token是无效的,浪费了发送时间,且当后续收集到很多token时,查询和发送效率会有一定影响。

解决:与后端协商推送消息时带上isShow参数,isShow为true时,才显示消息,isShow为false则用来检查token是否清除,不显示消息,不会对用户造成影响,这个可以在服务端空闲的时候进行定期的清除工作,不影响正常推送过程。

收益:

(1)提高查询token的效率

(2)提高发送消息,减少无效发送

messaging.setBackgroundMessageHandler(function (payload) {  //payload.data.isShow === 'true'才显示消息,否则是token有效性检查,不显示通知  if (payload.data.isShow === "true") {    const notification = payload.data;    const notificationTitle = notification.data_title;    const notificationOptions = {      body: notification.data_body,      icon: notification.data_icon,      data: {        linkUrl: notification.jump_url,        sendId: notification.send_id,      },    };    return self.registration.showNotification(      notificationTitle,      notificationOptions    );  }});

五、通知其他玩法

1、通知多选形式:https://www.jianshu.com/p/f9480c35e32d

2、更多的通知属性配置参考

https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification