全栈笔记

【PWA学习】4. 使用 Push API 实现消息推送

2023-01-09  本文已影响0人  小贤笔记

引言

在接下来的内容里,我们会探究 PWA 中的另一个重要功能——消息推送与提醒(Push & Notification)。这个能力让我们可以从服务端向用户推送各类消息并引导用户触发相应交互

Web Push 效果

Push API 和 Notification API 其实是两个独立的技术,完全可以分开使用;不过Push API 和 Notification API相结合是一个常见的模式

浏览器是如何实现服务器消息 Push 的

Web Push 的整个流程相较之前的内容来说有些复杂。因此,在进入具体技术细节之前,我们需要先了解一下整个 Push 的基本流程与相关概念。

如果你对 Push 完全不了解,可能会认为,Push 是我们的服务端直接与浏览器进行交互,使用长连接、 WebSocket 或是其他技术手段来向客户端推送消息。然而,这里的 Web Push 并非如此,它其实是一个三方交互的过程。

在 Push 中登场的三个重要“角色”分别是:

下面就介绍一下这三者在 Web Push 中是如何交互

消息推送流程

下图来自Web Push协议草案,是 Web Push 的整个流程:

    +-------+           +--------------+       +-------------+
    |  UA   |           | Push Service |       | Application |
    +-------+           +--------------+       |   Server    |
        |                      |               +-------------+
        |      Subscribe       |                      |
        |--------------------->|                      |
        |       Monitor        |                      |
        |<====================>|                      |
        |                      |                      |
        |          Distribute Push Resource           |
        |-------------------------------------------->|
        |                      |                      |
        :                      :                      :
        |                      |     Push Message     |
        |    Push Message      |<---------------------|
        |<---------------------|                      |
        |                      |                      |

该时序图表明了 Web Push 的各个步骤,我们可以将其分为订阅(subscribe)与推送(push)两部分来看

订阅 subscribe

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

推送 Push Message

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

什么是 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. If the browser is offline, the message is queued until the browser comes online.

译: 推送服务接收一个网络请求,对其进行验证,并将推送消息传递到适当的浏览器。如果浏览器离线,则消息将排队等待浏览器在线。

目前,不同的浏览器厂商使用了不同的 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

PushSubscription 对象

那么,如果我们知道了endpoint的值,是否就代表我们可以向客户端推送消息了呢?并非如此。下面会简单介绍一下 Web Push 中的安全策略

如何保证 Push 的安全性

在 Web Push 中,为了保证客户端只会收到其订阅的服务端推送的消息(其他的服务端即使在拿到endpoint也无法推送消息),需要对推送信息进行数字签名。该过程大致如下:

在 Web Push 中会有一对公钥与私钥。客户端持有公钥,而服务端持有私钥。客户端在订阅时,会将公钥发送给 Push Service,而 Push Service 会将该公钥与相应的endpoint维护起来。而当服务端要推送消息时,会使用私钥对发送的数据进行数字签名,并根据数字签名生成一个叫Authorization请求头。Push Service 收到请求后,根据endpoint取到公钥,对数字签名解密验证,如果信息相符则表明该请求是通过对应的私钥加密而成,也表明该请求来自浏览器所订阅的服务端。反之亦然

而公钥与私钥如何生成,会在下面的实例中讲解

如何使用 Push API 来推送向用户推送信息

为了使文章与代码更清晰,将Web Push分为这几个部分:

友情提醒:由于 Chrome 所依赖的 Push Service——FCM 在国内不可访问,所以要正常运行 demo 中的代码需要“梯子”,或者可以选择 Firefox 来进行测试

浏览器(客户端)生成 subscription 信息

首先,我们需要使用PushManagersubscribe方法来在浏览器中进行订阅

在上一节中我们已经知道了如何注册 Service Worker。当我们注册完 Service Worker 后会得到一个Registration对象,通过调用Registration对象的registration.pushManager.subscribe()方法可以发起订阅

为了使代码更清晰,本篇 demo 在之前的基础上,先抽离出 Service Worker 的注册方法:

function registerServiceWorker() {
  if (!navigator.serviceWorker) {
    return Promise.reject('系统不支持 service worker')
  }

  return navigator.serviceWorker.register('./sw.js').then(function (reg) {
    registration = reg
  })
}

然后定义了 subscribeAndDistribute() 方法来发起订阅:

// 订阅推送并将订阅结果发送给后端
function subscribeAndDistribute(registration) {
  if (!window.PushManager) {
    return Promise.reject('系统不支持消息推送')
  }
  // 检查是否已经订阅过
  return registration.pushManager
    .getSubscription()
    .then(function (subscription) {
      // 如果已经订阅过,就不重新订阅了
      if (subscription) {
        distributePushResource(subscription)
      } else {
        return (
          // 订阅
          registration.pushManager
            .subscribe({
              userVisibleOnly: true,
              applicationServerKey: window.base64ToUint8Array(VAPIDPublicKey),
            })
            .then(function (subscription) {
              distributePushResource(subscription)
            })
        )
      }
    })
}

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

之后,我们再将PushSubscription信息发送到后端。这里定义了一个distributePushResource()方法,该方法就是一个普通的 XHR 请求,会向接口 post 订阅信息

// 将订阅信息传给后端服务器
function distributePushResource(subscription) {
  // 为了方便之后的推送,为每个客户端简单生成一个标识
  const body = {
    subscription,
    uniqueid: new Date().getTime(),
  }
  console.log('uniqueid', body.uniqueid)

  return new Promise(function (resolve, reject) {
    var xhr = new XMLHttpRequest()
    xhr.timeout = 60000
    xhr.onreadystatechange = function () {
      var response = {}
      if (xhr.readyState === 4 && xhr.status === 200) {
        try {
          response = JSON.parse(xhr.responseText)
        } catch (e) {
          response = xhr.responseText
        }
        resolve(response)
      } else if (xhr.readyState === 4) {
        resolve()
      }
    }
    xhr.onabort = reject
    xhr.onerror = reject
    xhr.ontimeout = reject
    xhr.open('POST', '/subscription', true)
    xhr.setRequestHeader('Content-Type', 'application/json')
    xhr.send(JSON.stringify(body))
  })
}

当网站在完成推送订阅之后,Web Push API 也提供了相应的方法来取消订阅。前面提到推送订阅成功之后 PushManager.subscribe() 方法返回的 pushSubscription 对象上有一个 unsubscribe() 就是用来取消订阅的:

pushSubscription.unsubscribe().then(function () {
  console.log('取消订阅成功!')
})

在取消订阅之前,我们可以通过 PushManager.getSubscription() 方法来判断用户是否已经订阅,如果用户已经订阅过,那么该函数会返回 pushSubscription 对象,这样接下来再调用 unsubscribe() 方法最终取消订阅。如下所示:

registration.pushManager.getSubscription().then(function (pushSubscription) {
  if (!pushSubscription) {
    // 用户尚未订阅
    return
  }
  // 取消订阅
  return pushSubscription.unsubscribe()
})
.then(function () {
  console.log('取消订阅!')
})

最后,将这一系列方法组合在一起:

const VAPIDPublicKey =
  'BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A'
// 注册 service worker 并缓存 registration
let registration

// 注册 service worker
registerServiceWorker()
  // 申请桌面通知权限
  .then(function () {
    requestNotificationPermission()
  })
  // 订阅推送
  .then(function () {
    subscribeAndDistribute(registration)
  })
  .catch(function (err) {
    console.log(err)
  })

function registerServiceWorker() {
  if (!navigator.serviceWorker) {
    return Promise.reject('系统不支持 service worker')
  }

  return navigator.serviceWorker.register('./sw.js').then(function (reg) {
    registration = reg
  })
}

// 申请桌面通知权限
function requestNotificationPermission() {
  // 系统不支持桌面通知
  if (!window.Notification) {
    return Promise.reject('系统不支持桌面通知')
  }
  return Notification.requestPermission().then(function (permission) {
    if (permission === 'granted') {
      return Promise.resolve()
    }
    return Promise.reject('用户已禁止桌面通知权限')
  })
}

// 订阅推送并将订阅结果发送给后端
function subscribeAndDistribute(registration) {
  if (!window.PushManager) {
    return Promise.reject('系统不支持消息推送')
  }
  // 检查是否已经订阅过
  return registration.pushManager
    .getSubscription()
    .then(function (subscription) {
      // 如果已经订阅过,就不重新订阅了
      if (subscription) {
        distributePushResource(subscription)
      } else {
        return (
          // 订阅
          registration.pushManager
            .subscribe({
              userVisibleOnly: true,
              applicationServerKey: window.base64ToUint8Array(VAPIDPublicKey),
            })
            .then(function (subscription) {
              distributePushResource(subscription)
            })
        )
      }
    })
}

// 将订阅信息传给后端服务器
function distributePushResource(subscription) {
  // 为了方便之后的推送,为每个客户端简单生成一个标识
  const body = {
    subscription,
    uniqueid: new Date().getTime(),
  }
  console.log('uniqueid', body.uniqueid)

  return new Promise(function (resolve, reject) {
    var xhr = new XMLHttpRequest()
    xhr.timeout = 60000
    xhr.onreadystatechange = function () {
      var response = {}
      if (xhr.readyState === 4 && xhr.status === 200) {
        try {
          response = JSON.parse(xhr.responseText)
        } catch (e) {
          response = xhr.responseText
        }
        resolve(response)
      } else if (xhr.readyState === 4) {
        resolve()
      }
    }
    xhr.onabort = reject
    xhr.onerror = reject
    xhr.ontimeout = reject
    xhr.open('POST', '/subscription', true)
    xhr.setRequestHeader('Content-Type', 'application/json')
    xhr.send(JSON.stringify(body))
  })
}

注: 这里为了方便我们后面的推送,为每个客户端生成了一个唯一IDuniqueid,这里使用了时间戳生成简单的uniqueid

由于userVisibleOnlytrue,所以需要用户授权开启通知权限,因此我们会看到下面的提示框,选择“允许”即可。你可以在设置中进行通知的管理

显示通知弹窗

服务端存储客户端 subscription 信息

为了存储浏览器 post 来的订阅信息,服务端需要增加一个接口/subscription,同时添加中间件koa-body用于处理 body

const router = require('koa-router')()
const { koaBody } = require('koa-body')

/**
 * @description: 提交 subscription 信息,并保存
 * @return {*}
 */
router.post('/subscription', koaBody(), async (ctx) => {
  let body = ctx.request.body
  await util.saveRecord(body)
  ctx.response.body = {
    success: true,
  }
})

接收到 subscription 信息后,需要在服务端进行保存,你可使用任何方式来保存它:mysql、redis、mongodb… 这里为了方便,我使用了nedb来进行简单的存储。nedb不需要部署安装,可以将数据存储在内存中,也可以持久化,nedb 的 api 和 mongodb 也比较类似

这里util.saveRecord()做了这些工作:首先,查询subscription信息是否存在,若已存在则只更新uniqueid;否则,直接进行存储

推送信息

在实际中,我们一般会给运营或产品同学提供一个推送配置后台。可以选择相应的客户端,填写推送信息,并发起推送。为了简单起见,我并没有写一个推送配置后台,而只提供了一个 post 接口/push来提交推送信息。后期我们完全可以开发相应的推送后台来调用该接口

/**
 * @description: 消息推送API,可以在管理后台进行调用
 * @return {*}
 */
router.post('/push', koaBody(), async (ctx) => {
    const data = ctx.request.body
  let { uniqueid } = data
  let list = uniqueid ? await util.find({ uniqueid }) : await util.findAll()

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

  ctx.response.body = {
    data: list,
  }
})

来看一下/push接口。

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

web-push

安装

npm install web-push --save

密钥

公钥和私钥可以在线生成, 也可以通过下面方法用命令生成:

npm install web-push -g

web-push generate-vapid-keys

得到的结果如下所示:

=======================================

Public Key:
BO45dpS6296H9sSWdVcZsnsYeHXCXgYv-9jEUcyFrNfUvmvWF_c5iniytzHU_pEP9mE50xcUKbJrqh-hmMbvLZs

Private Key:
qXsFHan2sLkH0RFHGkNIkVgiUmox3uVYKwEAphUo7II

=======================================

转码

正如在订阅推送中提到的,subscribe 方法通过 applicationServerKey 传入所需要的公钥。一般来说得到的公钥一般都是 base64 编码后的字符串,需要将其转换成 Uint8Array 格式才能作为 subscribe 的参数传入。下面给出一个 base64 转 Uint8Array 的函数实现:

function base64ToUint8Array (base64String) {
  let padding = '='.repeat((4 - base64String.length % 4) % 4)
  let base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/')
  let rawData = atob(base64)
  let outputArray = new Uint8Array(rawData.length)

  for (let i = 0; i < rawData.length; i++) {
    outputArray[i] = rawData.charCodeAt(i)
  }
  return outputArray
}

使用

const webpush = require('web-push')

// VAPID
const VAPIDKeys = {
    publicKey: 'BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A',
    privateKey: 'TVe_nJlciDOn130gFyFYP8UiGxxWd3QdH6C5axXpSgM'
}

// 设置 web-push 的 VAPID 值
webpush.setVapidDetails(
  'mailto:shenxh0928@gmail.com', // 联系邮箱
  VAPIDKeys.publicKey,
  VAPIDKeys.privateKey,
)

设置完成后即可使用webpush.sendNotification()方法向 Push Service 发起请求。

最后我们来看下pushMessage()方法的细节:

/**
 * 向push service推送信息
 * @param {*} subscription
 * @param {*} data
 */
function pushMessage(subscription, data = {}) {
  webpush
    .sendNotification(subscription, data, options)
    .then((res) => {
      console.log('push service的相应数据:', JSON.stringify(res))
      return
    })
    .catch((err) => {
      // 判断状态码,440和410表示失效
      if (err.statusCode === 410 || err.statusCode === 404) {
        return util.remove(subscription)
      } else {
        console.log('失败', err)
      }
    })
}

webpush.sendNotification为我们封装了请求的处理细节。状态码401和404表示该 subscription 已经无效,可以从数据库中删除

Service Worker 监听 Push 消息

调用webpush.sendNotification()后,我们就已经把消息发送至 Push Service了;而 Push Service 会将我们的消息推送至浏览器

要想在浏览器中获取推送信息,只需在 Service Worker 中监听push的事件即可:

// 监听 push 事件
self.addEventListener('push', function (e) {
  var data = e.data

  if (e.data) {
    data = data.json()
    console.log('push 的数据为:', data)
  } else {
    console.log('没有 push 任何数据')
  }
})

Push Service可以在设备离线时,帮你维护推送消息。当浏览器设备重新联网时,就会收到该推送

兼容性

又到了查看兼容性的时间了。对于Push API,目前 Safari 团队并没有明确表态计划支持

Push API 兼容性

其实比兼容性更大的一个问题是,Chrome 所依赖的 FCM 服务在国内是无法访问的,而 Firefox 的服务在国内可以正常使用。这也是为什么在代码中会有这一项设置:

const options = {
  proxy: 'http://34.82.107.67' // 使用FCM(Chrome)需要配置代理
}

注: 免费代理获取方法: 戳这里

虽然由于 google 服务被屏蔽,导致国内 Push 功能无法在 chrome 上使用,但是作为一个重要的技术点,Web Push 还是非常值得我们了解与学习的

本章分支: push

上一篇下一篇

猜你喜欢

热点阅读