Web 推送技术

作者:villainthr

摘自 前端小吉米

伴随着今年 Google I/O 大会的召开,一个很火的概念--Progressive Web Apps 诞生了。这代表着我们 web 端有了和原生 APP 媲美的能力。但是,有一个很重要的痛点,web 一直不能使用消息推送,虽然,后面提出了 Notification API,但这需要网页持续打开,这对于常规 APP 实现的推送,根本就不是一个量级的。所以,开发者一直在呼吁能不能退出一款能够在网页关闭情况下的 web 推送呢?

现在,Web 时代已经到来!

为了做到在网页关闭的情况下,还能继续发送 Notification,我们就只能使用驻留进程。而现在 Web 的驻留进程就是现在正在大力普及的 Service Worker。换句话说,我们的想要实现断线 Notification 的话,需要用的技术栈是:

  • Push
  • Notification
  • Service Worker

这里,我先一个简单的 demo 样式。

说实在的,我其实 TM 很烦的这 Noti。一般使用 PC 端的,也没见有啥消息弹出来,但是,现在好了 Web 一搞,结果三端通用。你如果不禁用的话,保不准天天弹。

SW(Service Worker) 我已经在前一篇文章里面讲清楚了。这里主要探究一下另外两个技术 PushNotification。首先,有一个问题,这两个技术是用来干嘛的呢?

Push && Notification

这两个技术,我们可以理解为就是 server 和 SW 之间,SW 和 user 之间的消息通信。

  • push: server 将更新的信息传递给 SW
  • notification: SW 将更新的信息推送给用户

可以看出,两个技术是紧密连接到一起的。这里,我们先来讲解一下 notification的相关技术。

Notification

那现在,我们想给用户发送一个消息的话应该怎么发送呢? 代码很简单,我直接放了:

self.addEventListener('push', function(event) {
  var title = 'Yay a message.';
  var body = 'We have received a push message.';
  var icon = '/images/icon-192x192.png';
  var tag = 'simple-push-demo-notification-tag';
  var data = {
    doge: {
        wow: 'such amaze notification data'
    }
  };
  event.waitUntil(
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag,
      data: data
    })
  );
});

大家一开始看见这个代码,可能会觉得有点陌生。实际上,这里是结合 SW 来完成的。push 是 SW 接收到后台的 push 信息然后出发。当然,我们获取信息的主要途径也是从 event 中获取的。这里为了简便,就直接使用写死的信息了。大致解释一下 API。

  • event.waitUntil(promise): 该方法是用来延迟 SW 的结束。因为,SW 可能在任何时间结束,为了防止这样的情况,需要使用 waitUntil 监听 promise,使系统不会在 promise 执行时就结束 SW。
  • ServiceWorkerRegistration.showNotification(title, [options]): 该方法执行后,会发回一个 promise 对象。

不过,我们需要记住的是 SW 中的 notification 只是很早以前就退出的桌面 notification 的继承对象。这意味着,大家如果想要尝试一下 notification,并不需要手动建立一个 notification,而只要使用

// 桌面端
var not = new Notification("show note", { icon: "newsong.svg", tag: "song" });
not.onclick = function() { dosth(this); };

// 在 SW 中使用
self.registration.showNotification("New mail from Alice", {
  actions: [{action: 'archive', title: "Archive"}]
});

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  if (event.action === 'archive') {
    silentlyArchiveEmail();
  } else {
    clients.openWindow("/inbox");
  }
}, false);

不过,如果你想设置自己想要的 note 效果的话,则需要了解一下,showNotification 里面具体每次参数代表的含义,参考 Mozilla,我们可以了解到基本的使用方式。如上,API 的基本格式为 showNotification(title, [options])

  • title: 很简单,就是该次 Not(Notification) 的标题
  • options: 这个而是一个对象,里面可以接受很多参数。
    • actions[Array]:该对象是一个数组,里面包含一个一个对象元素。每个对象包含内容为:
      • action[String]: 表示该 Not 的行为。后面是通过监听 notificationclick 来进行相关处理
      • title[String]: 该 action 的标题
      • icon[URL]: 该 action 显示的 logo。大小通常为 24*24

actions 的上限值,通常根据 Notification.maxActions 确定。通过在 Not 中定义好 actions 触发,最后我们会通过,监听的 notificationclick 来做相关处理:

self.addEventListener('notificationclick', function(event) {  
  var messageId = event.notification.data;

  event.notification.close();
    // 通过设置的 actions 来做适当的响应
  if (event.action === 'like') {  
    silentlyLikeItem();  
  }  
  else if (event.action === 'reply') {  
    clients.openWindow("/messages?reply=" + messageId);  
  }  
  else {  
    clients.openWindow("/messages?reply=" + messageId);  
  }  
}, false);
  • body[String]: Not 显示的主体信息
  • dir[String]: Not 显示信息的方向,通常可以取:auto, ltr, or rtl
  • icon[String]:Not 显示的 Icon 图片路径。
  • image[String]:Not 在 body 里面附带显示的图片 URL,大小最好是 4:3 的比例。
  • tag[String]:用来标识每个 Not。方便后续对 Not 进行相关管理。
  • renotify[Boolean]:当重复的 Not 触发时,标识是否禁用振动和声音,默认为 false
  • vibrate[Array]:用来设置振动的范围。格式为:[振动,暂停,振动,暂停...]。具体取值单位为 ms。比如:[100,200,100]。振动 100ms,静止 200ms,振动 100ms。这样的话,我们可以设置自己 APP 都有的振动提示频率。
    • sound[String]: 设置音频的地址。例如: /audio/notification-sound.mp3
    • data[Any]: 用来附带在 Not 里面的信息。我们一般可以在 notificationclick事件中,对回调参数进行调用event.notification.data

针对于推送的图片来说,可能会针对不同的手机用到的图片尺寸会有所区别,例如,针对不同的 dpi。

具体参照:

看下 MDN 提供的 demo:

function showNotification() {
  Notification.requestPermission(function(result) {
    if (result === 'granted') {
      navigator.serviceWorker.ready.then(function(registration) {
        registration.showNotification('Vibration Sample', {
          body: 'Buzz! Buzz!',
          icon: '../images/touch/chrome-touch-icon-192x192.png',
          vibrate: [200, 100, 200, 100, 200, 100, 200],
          tag: 'vibration-sample'
        });
      });
    }
  });
}

当然,简单 API 的使用就是上面那样。但是,如果我们不加克制的使用 Not,可能会让用户完全屏蔽掉我们的推送,得不偿失。所以,我们需要遵循一定的原则去发送。

推送原则

1、推送必须简洁

遵循时间,地点,人物要素进行相关信息的设置。

2、尽量不要让用户打开网页查看

虽然这看起来有点违背我们最初的意图。不过,这样确实能够提高用户的体验。比如在信息回复中,直接显示:XX回复:... 这样的格式,可以完全省去用户的打开网页的麻烦。

3、不要在 title 和 body 出现一样的信息

比如:

correct:

incorrect

  1. 不要推荐原生 APP

因为很有可能造成推送信息重复

  1. 不要写上自己的网址

因为,Not 已经帮你写好了

  1. 尽量让 icon 和推送有关联

没用的 icon:

实用的 icon:

推送权限

实际上,Not 并不全在 SW 中运行,对于设计用户初始权限,我们需要在主页面中,做出相关的响应。当然,在设置推送的时候,我们需要考虑到用户是否会禁用,这里影响还是特别大的。 我们,获取用户权限一般可以直接使用 Notification 上挂载的 permission 属性来获取的。

  • defualt: 表示需要进行询问。默认情况是不显示推送
  • denied: 不显示推送
  • granted: 显示推送

简单的来说为:

function initialiseState() {
  if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
    return;
  }

  // 检查是否可以进行服务器推
  if (!('PushManager' in window)) {
    return;
  }

  // 是否被禁用
  if (Notification.permission === 'denied') {
    return;
  }

  if (Notification.permission === 'granted') {
    // dosth();
    return;
  }

  // 如果还处于默认情况下,则进行询问
  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    // 检查订阅
    serviceWorkerRegistration.pushManager.getSubscription()
      .then(function(subscription) {
        // 检查是否已经被订阅
        if (!subscription) {
          // 没有
          return;
        }
        // 有
        // doSth();
      })
      .catch(function(err) {
        window.Demo.debug.log('Error during getSubscription()', err);
      });
  });
}

我们在加载的时候,需要先进行检查一遍,如果是默认情况,则需要发起订阅的请求。然后再开始进行处理。 那,我们上面的那段代码该放在哪个位置呢?首先,这里使用到了 SW,这意味着,我们需要将 SW 先注册成功才行。实际代码应放在 SW 注册成功的回调中:

window.addEventListener('load', function() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./service-worker.js')
    .then(initialiseState);
  } else {
    window.Demo.debug.log('Service workers aren\'t supported in this browser.');
  }
});

为了更好的显示信息,我们还可以将授权代码放到后面去。比如,将 subscribe 和 btn 的 click 事件进行绑定。这时候,我们并不需要考虑 SW 是否已经注册好了,因为SW 的注册时间远远不及用户的反应时间。 例如:

  var pushButton = document.querySelector('.js-push-button');
  pushButton.addEventListener('click', function() {
    if (isPushEnabled) {
      unsubscribe();
    } else {
      subscribe();
    }
  });

我们具体看一下 subscribe 内容:

function subscribe() {
  var pushButton = document.querySelector('.js-push-button');
  pushButton.disabled = true;

  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    // 请求订阅
    serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true})
      .then(function(subscription) {
        isPushEnabled = true;
        pushButton.textContent = 'Disable Push Messages';
        pushButton.disabled = false;
        return sendSubscriptionToServer(subscription);
      })
  });
}

说道这里,大家可能会看的云里雾里,这里我们来具体看一下 serviceWorkerRegistration.pushManager 具体含义。该参数是从 SW 注册事件回调函数获取的。也就是说,它是我们和 SW 交互的通道。该对象上,绑定了几个获取订阅相关的 API:

  • subscribe(options) [Promise]: 该方法就是我们常常用来触发询问的 API。他返回一个 promise 对象.回调参数为 pushSubscription 对象。这里,我们后面再进行讨论。这里主要说一下 options 里面有哪些内容
    • options[Object]
      • userVisibleOnly[Boolean]:用来表示后续信息是否展示给用户。通常设置为 true.
      • applicationServerKey: 一个 public key。用来加密 server 端 push 的信息。该 key 是一个 Uint8Array 对象。

例如:

registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: new Uint8Array([...])
    });
  • getSubscription() [Promise]: 用来获取已经订阅的 push subscription 对象。
  • permissionState(options) [Promise]: 该 API 用来获取当前网页消息推送的状态 'prompt', 'denied', 或 'granted'。里面的 options 和 subscribe 里面的内容一致。

为了更好的体验,我们可以将两者结合起来,进行相关推送检查,具体的 load 中,则为:

window.addEventListener('load', function() {
  var pushButton = document.querySelector('.js-push-button');
  pushButton.addEventListener('click', function() {
    if (isPushEnabled) {
      unsubscribe();
    } else {
      subscribe();
    }
  });
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./service-worker.js')
    .then(initialiseState);
  } else {
    window.Demo.debug.log('Service workers aren\'t supported in this browser.');
  }
});

当然,这里面还会涉及其他的一些细节,我这里就不过多赘述了。详情可以查阅: Notification demo

我们开启一个 Not 询问很简单,但关键是,如果让用户同意。如果我们一开始就进行询问,这样成功性的可能性太低。我们可以在页面加载后进行询问。这里,也有一些提醒原则:

  1. 通过具体行为进行询问 比如,当我在查询车票时,就可以让用户在退出时选择是否接受推送信息。比如,国外的飞机延迟通知网页:
  1. 让用户来决定是否进行推送 因为用户不是技术人员,我们需要将一些接口,暴露给用户。针对推送而言,我们可以让用户选择是否进行推送,并且,在提示的同时,显示的信息应该尽量和用户相关。

推送处理

web push 在实际协议中,会设计到两个 server,比较复杂,这里我们先来看一下。client 是如何处理接受到的信息的。 当 SW 接受到 server 传递过来的信息时,会先触发 push 事件。我们通常做如下处理:

self.addEventListener('push', function(event) { 
if (event.data) {
    console.log('This push event has data: ',event.data.text()); 
    } else {
    console.log('This push event has no data.');
  }
});

其中,我们通过 server push 过来的 msg 通常是挂载到 event.data 里的。并且,该部署了 Response 的相关 API:

  • text(): 返回 string 的内容
  • json(): 返回 经过 json parse 的对象
  • blob(): 返回 blob 对象
  • arrayBuffer(): 返回 arrayBuffer 对象

我们知道 Service Worker 并不是常驻进程,有童鞋可能会问到,那怎么利用 SW 监听 push 事件呢? 这里就不用担心了,因为浏览器自己会打开一个端口监听接受到的信息,然后唤起指定的 SW(如果你的浏览器是关闭的,那么你可以洗洗睡了)。而且,由于这样随机关闭的机制,我们需要上述提到的 event.waitUntil API 来帮助我们完成持续 alive SW 的效果,防止正在执行的异步程序被终止。针对于我们的 notification 来说,实际上就是一个异步,所以,我们需要使用上述 API 进行包裹。

self.addEventListener('push', function(event) {
const promiseChain = self.registration.showNotification('Hello, World.');
  event.waitUntil(promiseChain);
});

当然,如果你想在 SW 里面做更多的异步事情的话,可以使用 Promise.all 进行包裹。

self.addEventListener('push', function(event) {
    const promiseChain = Promise.all([ async1,async2 ]);
  event.waitUntil(promiseChain);
});

之后,就是将具体信息展示推送给用户了。上面已经将了具体 showNotification里面的参数有哪些。不过,这可能不够直观,我们可以使用一张图来感受一下:

(左:firefox,右:Chrome)

另外,在 showNotification options 里面,还有一些属性需要我们额外注意。

属性注意

tag

对于指定的 Not 我们可以使用 tag 来表明其唯一性,这代表着当我们在使用相同 tag 的 Not 时,上一条 Not 会被最新拥有同一个 tag 的Not 替换。即:

    const title = 'First Notification';
    const options = {
      body: 'With \'tag\' of \'message-group-1\'',
      tag: 'message-group-1'
    };
    registration.showNotification(title, options);

显示样式为:

接着,我显示一个不同 tag 的 Not:

const title = 'Second Notification';
const options = {
     body: 'With \'tag\' of \'message-group-2\'',
     tag: 'message-group-2'
};
registration.showNotification(title, options);

结果为:

然后,我使用一个同样 tag 的 Not:

      const title = 'Third Notification';
      const options = {
        body: 'With \'tag\' of \'message-group-1\'',
        tag: 'message-group-1'
      };
      registration.showNotification(title, options);

则相同的 tag 会被最新 tag 的 Not 替换:

Renotify

该属性是 Not 里面又一个比较尴尬的属性,它的实际应用场景是当有重复 Not 被替换时,震动和声音能不能被重复播放,但默认为 false。 那何为重复呢? 就是,上面我们提到的 tag 被替换。一般应用场景就是和同一个对象聊天时,发送多个信息来时,我们不可能推送多个提示信息,一般就是把已经存在的 Not 进行替换就 ok,那么这就是上面提到的因为重复,被替换的 Not。 一般我们对于这样的 Not 可以设置为:

      const title = 'Second Notification';
      const options = {
        body: 'With "renotify: true" and "tag: \'renotify\'".',
        tag: 'renotify',
        renotify: true
      };
      registration.showNotification(title, options);

并且,如果你设置了 renotify 而没有设置 tag 的话,这是会报错的 !!!

silent

防止自己推送的 Not 发出任何额外的提示操作(震动,声音)。默认为 false。不过,我们可以在需要的时候,设置为 true:

  const title = 'Silent Notification';
    const options = {
      body: 'With "silent: \'true\'".',
      silent: true
    };
    registration.showNotification(title, options);

requireInteraction

对于一般的 Not 来说,当展示一定时间过后,就可以自行消失。不过,如果你的 Not 一定需要用户去消除的话,可以使用 requireInteraction 来进行长时间留存。一般它的默认值为 false。

    const title = 'Require Interaction Notification';
    const options = {
      body: 'With "requireInteraction: \'true\'".',
      requireInteraction: true
    };
    registration.showNotification(title, options);

交互响应

现在,你的 Not 已经显示给用户,不过,默认情况下,Not 本身是不会做任何处理的。我们需要监听用户,对其的相关操作(其实就是 click 事件)。

self.addEventListener('notificationclick', function(event) {
    // do nothing
});

另外,通过我们在 showNotification 里面设置的 action,我们可以根据其作出不同的响应。

self.addEventListener('notificationclick', function(event) {
if (event.action) {
    console.log('Action Button Click.', event.action);
} else {
    console.log('Notification Click.');
}
});

关闭推送

这是应该算是最常用的一个,只是用来提示用户的相关信息:

self.addEventListener('notificationclick', function(event) {
  event.notification.close();

  // Do something as the result of the notification click
});

打开一个新的窗口

这里,需要使用到我们的 service 里面的一个新的 API clients

  event.waitUntil(
  // examplePage 就是当前页面的 url
    clients.openWindow(examplePage)
  );

这里需要注意的是 examplePage 必须是和当前 SW 同域名才行。不过,这里有两种情况,需要我们考虑:

  1. 指定的网页已经打开?
  2. 当前没网?

聚焦已经打开的页面

这里,我们可以利用 cilents 提供的相关 API 获取,当前浏览器已经打开的页面 URLs。不过这些 URLs 只能是和你 SW 同域的。然后,通过匹配 URL,通过 matchingClient.focus() 进行聚焦。没有的话,则新打开页面即可。

  const urlToOpen = self.location.origin + examplePage;

  const promiseChain = clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  })
  .then((windowClients) => {
    let matchingClient = null;

    for (let i = 0; i < windowClients.length; i++) {
      const windowClient = windowClients[i];
      if (windowClient.url === urlToOpen) {
        matchingClient = windowClient;
        break;
      }
    }

    if (matchingClient) {
      return matchingClient.focus();
    } else {
      return clients.openWindow(urlToOpen);
    }
  });

  event.waitUntil(promiseChain);

检测是否需要推送

另外,如果用户已经停留在当前的网页,那我们可能就不需要推送了,那么针对于这种情况,我们应该怎么检测用户是否正在网页上呢?

const promiseChain = ({
    type: 'window',
    includeUncontrolled: true
  })
  .then((windowClients) => {
    let mustShowNotification = true;

    for (let i = 0; i < windowClients.length; i++) {
      const windowClient = windowClients[i];
      if (windowClient.focused) {
        mustShowNotification = false;
        break;
      }
    }

    return mustShowNotification;
  })
  .then((mustShowNotification) => {
    if (mustShowNotification) {
      return self.registration.showNotification('Had to show a notification.');
    } else {
      console.log('Don\'t need to show a notification.');
    }
  });

  event.waitUntil(promiseChain);

当然,如果你自己的网页已经被用户打开,我们同样也可以根据推送信息直接将信息传递给对应的 window。我们通过 clients.matchAll 获得的 windowClient 对象,调用 postMessage 来进行消息的推送。

 windowClient.postMessage({
          message: 'Received a push message.',
          time: new Date().toString()
        });

合并消息

该场景的主要针对消息的合并。比如,聊天消息,当有一个用户给你发送一个消息时,你可以直接推送,那如果该用户又发送一个消息呢? 这时候,比较好的用户体验是直接将推送合并为一个,然后替换即可。 那么,此时我们就需要获得当前已经展示的推送消息,这里主要通过 registration.getNotifications() API 来进行获取。该 API 返回的也是一个 Promise 对象。 当然,我们怎么确定两个消息是同一个人发送的呢?这里,就需要使用到,上面提到的 Not.data 的属性。这是我们在 showNotification 里面附带的,可以直接在 Notification 对象中获取。

    return registration.getNotifications()
    .then(notifications => {
      let currentNotification;

      for(let i = 0; i < notifications.length; i++) {
      // 检测已经存在的 Not.data.userName 和新消息的 userName 是否一致
        if (notifications[i].data &&
          notifications[i].data.userName === userName) {
          currentNotification = notifications[i];
        }
      }

      return currentNotification;
    })
    // 然后,进行相关的逻辑处理,将 body 的内容进行更替
    .then((currentNotification) => {
      let notificationTitle;
      const options = {
        icon: userIcon,
      }

      if (currentNotification) {
        // We have an open notification, let's so something with it.
        const messageCount = currentNotification.data.newMessageCount + 1;

        options.body = `You have ${messageCount} new messages from ${userName}.`;
        options.data = {
          userName: userName,
          newMessageCount: messageCount
        };
        notificationTitle = `New Messages from ${userName}`;

        currentNotification.close();
      } else {
        options.body = `"${userMessage}"`;
        options.data = {
          userName: userName,
          newMessageCount: 1
        };
        notificationTitle = `New Message from ${userName}`;
      }

      return registration.showNotification(
        notificationTitle,
        options
      );
    });

相当于从:

变为:

上面提到了在 SW 中使用,clients 获取窗口信息,这里我们先补充一下相关的知识。

Clients Object

我们可以将 Clients 理解为我们现在所在的浏览器,不过特殊的地方在于,它是遵守同域规则的,即,你只能操作和你域名一致的窗口。同样,Clients 也只是一个集合,用来管理你当前所有打开的页面,实际上,每个打开的页面都是使用一个 cilent object 进行表示的。这里,我们先来探讨一下 cilent object:

  • Client.postMessage(msg[,transfer]): 用来和指定的窗口进行通信
  • Client.frameType: 表明当前窗口的上下文。该值可以为: auxiliary, top-level, nested, 或者 none.
  • Client.id[String]: 使用一个唯一的 id 表示当前窗口
  • Client.url: 当前窗口的 url。
  • WindowClient.focus(): 该方法是用来聚焦到当前 SW 控制的页面。下面几个也是 Client,不过是专门针对 type=window 的client。
  • WindowClient.navigate(url): 将当前页面到想到指定 url
  • WindowClient.focused[boolean]: 表示用户是否停留在当前 client
  • WindowClient.visibilityState: 用来表示当前 client 的可见性。实际和 focused 没太大的区别。可取值为: hidden, visible, prerender, or unloaded

然后,Clients Object 就是用来管理每个窗口的。常用方法有:

  • Clients.get(id): 用来获得某个具体的 client object
self.clients.get(id).then(function(client) {
  // 打开具体某个窗口
  self.clients.openWindow(client.url); 
});
  • Clients.matchAll(options): 用来匹配当前 SW 控制的窗口。由于 SW 是根据路径来控制的,有可能只返回一部分,而不是同域。如果需要返回同域的窗口,则需要设置响应的 options。
    • includeUncontrolled[Boolean]: 是否返回所有同域的 client。默认为 false。只返回当前 SW 控制的窗口。
    • type: 设置返回 client 的类型。通常有:window, worker, sharedworker, 和 all。默认是 all
// 常用属性为:
clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  }).then(function(clientList) {
  for(var i = 0 ; i < clients.length ; i++) {
    if(clientList[i].url === 'index.html') {
      clients.openWindow(clientList[i]);
    }
  }
});
  • Clients.openWindow(url): 用来打开具体某个页面
  • Clients.claim(): 用来设置当前 SW 和同域的 cilent 进行关联。

Push

先贴一张 google 关于 web push 的详解图:

上述图,简单阐述了从 server 产生信息,最终到手机生成提示信息的一系列过程。 先说一下中间那个 Message Server。这是独立于我们常用的 Server -> Client 的架构,浏览器可以自己选择 push service,开发者一般也不用关心。不过,如果你想使用自己定制的 push serivce 的话,只需要保证你的 service 能够提供一样的 API 即可。上述过程为:

  1. 用于打开你的网页,并且,已经生成好用来进行 push 的 applicationServerKey。然后,phone 开始初始化 SW。
  2. 用户订阅该网页的推送,此时会给 message server 发送一个请求,创建一个订阅,然后返回 message server 的相关信息。
  3. 浏览器获得 message server 的相关信息后,然后在发送一个请求给该网页的 server。
  4. 如果 server 这边检测到有新的信息需要推送,则它会想 message server 发送相关请求即可。

这里,我们可以预先看一下 message server 返回来的内容:

{
  "endpoint": "https://random-push-service.com/some-kind-of-unique-id-1234/v2/",
  "keys": {
    "p256dh" : "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
    "auth"   : "tBHItJI5svbpez7KI4CCXg=="
  }
}

endpoint 就是浏览器订阅的 message server 的地址。这里的 keys 我们放到后面讲解,主要就是用来进行 push message 的加密。 根据官方解释,Message Server 与用户将的通信,借用的是 HTTP/2 的 server push 协议。上面的图,其实可以表达为:

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

接下来,我们就需要简单的来看一下使用 Web Push 的基本原则。

Push 基本原则

  1. 首先,server 发送的 push msg 必须被加密,因为这防止了中间的 push service 去查看我们的推送的信息。
  2. 通过 server 发送的 msg 需要设置一个失效时间,以为 Web Push 真正能够作用的时间是当用户打开浏览器的时候,如果用户没有打开浏览器,那么 push service 会一直保存该信息直到该条 push msg 过期。

那么如果我们想让用户订阅我们的 push service 我们首先需要得到用户是否进行提示的许可。当然,一开始我们还需要判断一下,该用户是否已经授权,还是拒绝,或者是还未处理。这里,可以参考上面提到的推送权限一节中的 initialiseState 函数方法。 这里我们主要研究一下具体的订阅环节(假设用户已经同意推送)。基本格式为:

 navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    const subscribeOptions = {
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U'
      )
    };

    return registration.pushManager.subscribe(subscribeOptions);
    )}
      .then(function(subscription) {
        return subscription
      })

这里有两个参数 userVisibleOnlyapplicationServerKey。这两个属性值具体代表什么意思呢?

userVisibleOnly

该属性可以算是强制属性(你必须填,而且只能填 true)。因为,一开始 Notification 的设计是 可以在用户拒绝的情况下继续在后台执行推送操作,这造成了另外一种情况,开发者可以在用户关闭的情况下,通过 web push 获取用户的相关信息。所以,为了安全性保证,我们一般只能使用该属性,并且只能为 true(如果,不呢?浏览器就会报错)。

applicationServerKey

前面说过它是一个 public key。用来加密 server 端 push 的信息。该 key 是一个 Uint8Array 对象,而且它 需要符合 VAPID 规范实际,所以我们一般可以叫做 application server keys 或者 VAPID keys,我们的 server 其实有私钥和公钥两把钥匙,这里和 TLS/SSL 协商机制类似,不过不会协商出 session key,直接通过 pub/pri key 进行信息加/解密。不过,它还有其他的用处:

  • 对于信息
    • 进行加密/解密,增强安全性
  • 对于 push service
    • 保证唯一性,因为 subscribe 会将该 key 发送过去。在 push service 那边,会根据该 key 针对每次发送生成独一无二的 endpoint,然后根据该 endpoint 给某些指定用户信息 push message。

整个流程图为:

另外,该 key 还有一个更重要的用途是,当在后台 server 需要进行 push message,向 push service 发送请求时,会有一个 Authorization 头,该头的内容时由 private key 进行加密的。然后,push service 接受到之后,会根据配套的 endpoint 的 public key 进行解密,如果解密成功则表示该条信息是有效信息(发送的 server 是合法的)。

流程图为:

通过 subscribe() 异步调用返回的值 subscription 的具体格式为:

{
  "endpoint": "https://some.pushservice.com/something-unique",
  "keys": {
    "p256dh": "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=",
    "auth":"FPssNDTKnInHVndSTdbKFw=="
  }
}

简单说一下参数,endpoint 就是 push service 的 URL,我们的 server 如果有消息需要推送,就是想该路由发送请求。而 keys 就是用来对信息加密的钥匙。得到返回的 subscription 之后,我们需要发送给后台 server 进行存储。因为,每个用户的订阅都会产生独一无二的 endpoint,所以,我们只需要将 endpoint 和关联用户存储起来就 ok 了。

fetch('/api/save-subscription/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(subscription)
  })

接下来就到了 server 推送 msg 的环节了。

服务器推送信息

当服务器有新的消息需要推送时,就需要向 push service 发送相关的请求进行 web push。不过,这里我们需要了解,从服务器到 push service的请求,实际上就是 HTTP 的 post method。我们看一个具体的请求例子:

POST /push-service/send/dbDqU8xX10w:APA91b... HTTP/1.1  
Host: push.example.net  
Push-Receipt: https://push.example.net/r/3ZtI4YVNBnUUZhuoChl6omU  
TTL: 43200  
Content-Type: text/plain;charset=utf8  
Content-Length: 36  
Authorization: WebPush
eyJ0eXAiOiJKV1QiLCJErtm.ysazNjjvW2L9OkSSHzvoD1oA  
Crypto-Key:
p256ecdsa=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU\_RCPCfA5aq9ojSwk5Y2EmClBPsiChYuI3jMzt3ir20P8r\_jgRR-dSuN182x7iB

当然,变化的是里面推送的具体的 Headers 和 body 内容。我们可以看一下具体头部代表的意思:

头部参考

Header

Content

Authorization

可以理解该头是一个 JSON Web Token,用来验证是否是真实的订阅 server

Crypto-Key

用来表示加密的 key。它由两部分组成:dh=publicKey,p256ecdsa=applicationServerKey。其中 p256ecdsa 就是由 pub key 加密的 base64 的 url

Encryption

它用来放置加盐秘钥。用来加密 payload

Content-Type

如果你没发送 payload 的话,那么就不用发送该头。如果发送了,则需要将其设置为 application/octet-stream。这是为了告诉浏览器我发送的是 stream data

Content-Length

用来描述 payload 的长度(没有 payload 的不用)

Content-Encoding

该头必须一直是 aesgcm 不论你是否发送 payload

TTL (Time to Live)

表示该 message 可以在 push service 上停留多长时间(为什么停留?因为用户没有打开指定浏览器,push service 发布过去)。如果 TTL 为 0,表示当有推送信息时,并且此时 push service 能够和用户的浏览器建立联系,则第一时间发送过去。否则立即失效

Topic

该头实际上和 Notification 中的 tag 头类似。如果 server 先后发送了两次拥有相同 Topic 的 message 请求,如果前一条 topic 正在 pending 状态,则会被最新一条 topic 代替。不过,该 Topic 必须 <= 32 个字符

Urgency[实验特性]

表示该消息的优先级,优先级高的 Notification 会优先发送。默认值为: default。可取值为: "very-low"

"low"

"normal"

"high"

返回的响应码

通常,push service 接受之后,会返回相关的状态码,来表示具体操作结果:

statusCode

Description

201

表示推送消息在 push service 中已经成功创建

429

此时,push service 有太多的推送请求,无法响应你的请求。并且,push service 会返回 Retry-After 的头部,表示你下次重试的时间。

400

无效请求,表示你的请求中,有不符合规范的头部

413

你的 payload 过大。最小的 payload 大小为 4kb

发送过程

可以从上面头部看出,push service 需要的头很复杂,如果我们纯原生手写的话,估计很快就写烦了。这里推荐一下 github 里面的库,可以直接根据 app server key 来生成我们想要的请求头。这里,我们打算细节的了解一下每个头部内容产生的相关协议。

applicationServerKey

首先,这个 key 是怎么拿到的?需要申请吗?

答案是:不需要。这个 key 只要你符合一定规范就 ok。不过一旦生成之后,不要轻易改动,因为后面你会一直用到它进行信息交流。规则简单来说为:

  • 它是 server 端生成 pub/pri keys 的公钥
  • 它是可以通过 crypto 加密库,依照 P-256 曲线,生成`ECDSA` 签名方式。
  • 该 key 需要是一个 8 位的非负整型数组(Unit8Array)

简单 demo 为:

function generateVAPIDKeys() {
  var curve = crypto.createECDH('prime256v1');
  curve.generateKeys();

  return {
    publicKey: curve.getPublicKey(),
    privateKey: curve.getPrivateKey(),
  };
}

// 也可以直接根据 web-push 库生成
const vapidKeys = webpush.generateVAPIDKeys();

具体头部详细信息如下:

头部参考

Authorization

Authorization 头部的值(上面也提到了)是一个 JSON web token(简称为 JWT)。基本格式为:

Authorization: WebPush <JWT Info>.<JWT Payload>.<Signature>

实际上,该头涵盖了很多信息(手写很累的。。。)。所以,我们这里可以利用现有的一些 github 库,比如 jsonwebtoken。专门用来生成,JWT 的。我们看一下它显示的例子:

简单来说,上面 3 部分都是将对象通过 private key 加密生成的字符串。 info 代表:

{  
  "typ": "JWT",  
  "alg": "ES256"  
}

用来表示 JWT 的加密算法是啥。 Payload 代表:

{  
  "aud": "https://some-push-service.org",
  "exp": "1469618703",
  "sub": "mailto:example@web-push-book.org"  
}

其中

  • aud 表示,push service 是谁
  • exp(expire)表示过期时间,并且是以秒为单位,最多只能是一天。
  • sub 用来表示 push service 的联系方式。

Signature 代表: 它是用来验证信息安全性的头。它是前面两个,JWT.info + '.' + JWT.payload的字符串通过私有 key 加密的生成的结果。

Crypto-Key

这就是我们公钥的内容,简单格式为:

Crypto-Key: dh=<URL Safe Base64 Encoded String>, p256ecdsa=<URL Safe Base64 Public Application Server Key>

// 两个参数分别代表:

dh=publicKey,p256ecdsa=applicationServerKey

Content-Type, Length & Encoding

这几个头是涉及 payload 传输时,需要用到的。基本格式为:

Content-Length: <Number of Bytes in Encrypted Payload>
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'

其中,只有 Content-Length 是可变的,用来表示 payload 的长度。

TTL,Topic & Urgency

这几个头上面已经说清楚了,我这里就不赘述了。

最后放一张关于 SW 的总结图:

原文链接:http://ivweb.io/topic/58764667441a881744b0d6d5

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏专注于主流技术和业务

axios2教程

axios 是一个基于 promise 的 HTTP 库,用于浏览器和node.js的http客户端,支持拦截请求和响应,自动转换 JSON 数据, 客户端支持...

992
来自专栏菜鸟前端工程师

JavaScript学习笔记028-ajax0get0post0跨域请求

541
来自专栏Keegan小钢

App架构设计经验谈:接口的设计

App与服务器的通信接口如何设计得好,需要考虑的地方挺多的,在此根据我的一些经验做一些总结分享,旨在抛砖引玉。

633
来自专栏腾讯IVWEB团队的专栏

H5 的复制操作

HTML5 现在能完美提供给我们的应该就是 copy 事件的使用,对于市面上的 clipboard.js 差不多也是运用上述的知识点。根据上面的描述,可以了...

1.8K1
来自专栏开发技术

spring-boot-2.0.3不一样系列之shiro - 搭建篇

       上一篇:spring-boot-2.0.3不一样系列之国际化,讲了如何实现国际化,实际上我工作用的模版引擎是freemaker,而不是thymel...

1141
来自专栏腾讯云数据库(TencentDB)

Spring MVC使用redis共享session

Spring Web MVC是基于Java的轻量级Web框架,使用了MVC架构模式的思想。Spring Web MVC核心架构为:

70625
来自专栏一个会写诗的程序员的博客

《Spring Boot 实战:从0到1》第2章 快速开始Hello World第2章 快速开始Hello World

大约20年前,程序员们使用“企业级Java Bean”(EJB)开发企业应用,需要配置复杂的XML。在二十世纪初期,新兴Java技术——Spring,横空出世。...

361
来自专栏听雨堂

【6】页面数据和控件的自动交换机制

阅读目录 数据维护通用流程 数据的加载 数据的修改 数据的添加 自动交换机制 使用PageX来完成数据的自动加载 非标准数据的处理 只要...

1958
来自专栏菩提树下的杨过

利用Lucene打造站内搜索引擎的思路

1.为什么要用Lucene,而不用直接从数据库里搜索记录? 主要是考虑到几个因素:(1)性能问题,Lucene是基于文件索引的搜索机制,性能要比数据库里检索更...

1775
来自专栏磨磨谈

rbd的image快照与Pool快照

这个错我之前也没见过,并且因为很少用到快照,所以可能也就没有触发这个问题,在查看了一些资料以后,明白了原因,这里就梳理一下

772

扫码关注云+社区