Using the Push API

这篇翻译不完整。请帮忙从英语翻译这篇文章

W3C Push API 为开发人员在Web应用程序中提供了一些令人兴奋的新功能:本文提供了一个简单的演示,以获取Push通知的设置和运行。

在任何时候——不论这一应用是否处于激活状态——从服务器向客户端推送消息或者通知的能力,是一种原生应用已经享受了一段时间的能力。现在 Web 应用也拥有了这一能力。桌面系统上的 Firefox 43+ 和 Chrome 42+ 已经支持 Push 的大部分功能,移动平台也很可能在不久的将来提供支持。 PushMessageData 当前只在 Firefox Nightly (44+) 中提供实验性的支持,并且这一实现也可能会变更。

Note: Firefox OS 的早期版本使用了这一 API 的一个 proprietary 版本,叫做 Simple Push ,现在已经被 Push API 标准废弃。

Demo: the basis of a simple chat server app

我们创建的这一 demo 是一个简单的聊天应用。它提供了一个表单,用来输入聊天内容,还有一个按钮,用来订阅(subscribe)推送的消息。按下按钮后,你将订阅这一消息推送服务,服务器会记录你的信息,同时当前所有的订阅者会收到一个推送消息,告诉他们有人订阅。

此时,新订阅者的名字会出现在订阅者列表上,同时界面上会出现一个文本域和一个提交按钮,允许订阅者发送消息。

要运行这一 demo,请参阅 push-api-demo README。请注意,想要在 Chrome 中使用这一应用并且以一个更合理的方式运行,服务器端还需要大量的工作。然而,推送的细节解释起来特别麻烦,我们先概览这个推送接口是怎么运作的,然后再回来详细了解。

Technology overview

这一部分提供了这一例子中用到的技术的概览。

Web Push 消息是 service workers 技术族的一部分;特别的,一个service worker想要接收消息,就必须在一个页面上被激活。 在 service worker 接收到推送的消息后,你可以决定如何显示这一消息。你可以:

  • 发送一个 Web notification ,弹出一个系统通知提醒用户。这一操作需要发送推送消息的权限。
  • 通过 MessageChannel 将消息送回主页面。

通常这两者可以结合使用,下面的 demo 显示了两者的特点。

注:你需要在服务器端部署某种形式的代码来处理endpoint数据的加密和发送推送消息的请求。 在我们的Demo里,我们把那些代码放进了一个快速、劣质的服务器代码(a quick-and-dirty server )里,部署在 NodeJS 上。

service worker 需要订阅推送消息服务。在订阅服务时,每一个会话会有一个独立的端点(endpoint)。订阅对象的属性(PushSubscription.endpoint) 即为端点值。将端点发送给服务器后,服务器用这一值来发送消息给会话的激活的 service worker。不同浏览器需要用不同的推送消息服务器。

加密(Encryption)

Note: For an interactive walkthrough, try JR Conlin's Web Push Data Encryption Test Page.

在通过推送消息发送数据时,数据需要进行加密。数据加密需要通过 PushSubscription.getKey() 方法产生的一个公钥。这一方法在服务器端运行,通过复杂的密码学机制来生成公钥,详情可参阅 Message Encryption for Web Push 。以后可能会有更多用于处理推送消息加密的库出现,在这一 demo 中,我们使用 Marco Castelluccio's NodeJS web-push library.

Note: There is also another library to handle the encryption with a Node and Python version available, see encrypted-content-encoding.

Push workflow summary

这里我们总结一下推送消息的实现。在之后的章节中你可以找到这一 demo 代码的更多细节。

  1. 请求 web 通知及你所使用的其他功能的权限。
  2. 调用 ServiceWorkerContainer.register() ,注册一个 service worker。
  3. 使用 PushManager.subscribe() 订阅推送消息。
  4. 取得与订阅相关联的 endpoint (PushSubscription.endpoint),并且生成一个客户公钥(PushSubscription.getKey()) 。注意 getKey() 是试验性的,只在 Firefox 有效。
  5. 将详细信息发送给服务器,服务器可以用这些信息来发送推送消息。这一 demo 使用 XMLHttpRequest ,但你也可以使用 Fetch
  6. 如果你使用 Channel Messaging API 来和 service worker 通信,则创建一个新的 message channel (MessageChannel.MessageChannel()) ,并且在 service worker 调用 Worker.postMessage() ,将 port2 发送给 service worker ,以建立 communication channel 。你应该设置一个 listener 来响应从 service worker 发来的消息。
  7. 在服务器端,存储端点以及其他在发送推送消息给订阅者时需要的信息(我们使用一个简单的文本文件,但你可以使用数据库,或者其他你喜欢的方式)。在生产环境中,请保护好这些信息,以防恶意的攻击者用这些信息给订阅者推送垃圾消息。
  8. 要发送一个推送消息,你需要向端点 URL 发送一个 HTTP POST 。这一请求需要包括一个 TTL 头,用来规定用户离线时消息队列的最大长度。要在请求中包括数据,你需要使用客户公钥进行加密。在我们的 demo 中,我们使用 web-push 模块来处理困难的部分。
  9. 在你的 service worker 中,设置一个 push 事件句柄来响应接收到的推送消息。
    1. 如果你想要将一个信道消息发送回主 context(看第6步),你需要先取得之前发送给 service worker 的  port2 的引用 (MessagePort) 。这个可以通过传给 onmessage handler (ServiceWorkerGlobalScope.onmessage) 的MessageEvent 对象取得。 具体地说,是 ports 属性的索引 0 。 之后你可以用 MessagePort.postMessage() 来向 port1 发送消息 。
    2. 如果你想要使用系统通知,可以调用 ServiceWorkerRegistration.showNotification() 。注意,在我们的代码中,我们将其运行在一个 ExtendableEvent.waitUntil() 方法中——这样做将事件的 生命周期(lifetime)扩展到了通知被处理后,使得我们可以确认事情像我们期望的那样进行。

Building up the demo

让我们浏览一下 demo 的代码,理解一下它是如何工作的。

The HTML and CSS

这个 demo 的 HTML 和 CSS 没有什么需要特别留意的地方。初始化时,HTML包含一个简单的表单、一个按钮和两个列表。按钮用来订阅,两个列表分别显示订阅者和聊天消息。订阅之后,会出现用来输入聊天消息的控件。

为了对不干扰 Push API 的理解,CSS被设计得非常简单。

The main JavaScript file

JavaScript 明显更加重要。让我们看看主 JS 文件。

Variables and initial setup

开始时,我们声明一些需要使用的变量:

var isPushEnabled = false;
var useNotifications = false;

var subBtn = document.querySelector('.subscribe');
var sendBtn;
var sendInput;

var controlsBlock = document.querySelector('.controls');
var subscribersList = document.querySelector('.subscribers ul');
var messagesList = document.querySelector('.messages ul');

var nameForm = document.querySelector('#form');
var nameInput = document.querySelector('#name-input');
nameForm.onsubmit = function(e) {
  e.preventDefault()
};
nameInput.value = 'Bob';

首先,是两个布尔变量,一个用来记录 Push 是否被订阅,一个用来记录是否有通知的权限。

其次,是订阅/取消订阅 <button> 的引用,以及发送消息按钮的引用和输入的引用(订阅成功之后按钮和输入才会创建)。

接下来,是页面上的三个<div> 元素的引用,在需要插入元素时会用到(比如创建 Send Chat Message 按钮,或者 Messages 列表中增加聊天消息时)。

最后,是 name selection 表单和 <input> 元素的引用。我们给 input 一个默认值,并且使用 preventDefault() 方法,让按下回车时不会自动提交表单。

之后,我们通过 requestPermission() 请求发送web通知的权限:

Notification.requestPermission();

onload 时我们运行一段代码,让应用开始初次加载时的初始化过程。首先我们给 subscribe/unsubscribe 按钮添加 click event listener ,让这一按钮在已经订阅(isPushEnabled为真)时执行 unsubscribe() 函数,否则执行 subscribe()

window.addEventListener('load', function() {   
  subBtn.addEventListener('click', function() {  
    if (isPushEnabled) {  
      unsubscribe();  
    } else {  
      subscribe();  
    }  
  });

之后,我们检查 service workers 是否被支持。如果支持,则用 ServiceWorkerContainer.register() 注册一个 service worker 并且运行 initialiseState() 函数。若不支持,则向控制台输出一条错误信息。

  // Check that service workers are supported, if so, progressively  
  // enhance and add push messaging support, otherwise continue without it.  
  if ('serviceWorker' in navigator) {  
    navigator.serviceWorker.register('sw.js').then(function(reg) {
      if(reg.installing) {
        console.log('Service worker installing');
      } else if(reg.waiting) {
        console.log('Service worker installed');
      } else if(reg.active) {
        console.log('Service worker active');
      }

      initialiseState(reg);
    });  
  } else {  
    console.log('Service workers aren\'t supported in this browser.');  
  }  
});

接下来是 initialiseState() 函数。查看 initialiseState() source on Github 可获得有注释的完整源码(简洁起见,此处省略)。

initialiseState() 首先检查 service workers 是否支持 notifications ,如果支持则将 useNotifications 变量设为真。之后检查用户是否允许 said notifications , push messages 是否支持,并分别进行设置。

最后,使用 ServiceWorkerContainer.ready() 来检测 service worker 是否被激活并开始运行,会返回一个Promise对象。当这一 promise 对象 resolve 时,我们访问 ServiceWorkerRegistration.pushManager 属性,得到一个 PushManager 对象,再调用该对象的 PushManager.getSubscription()方法,最终获得用来推送消息的订阅对象。当第二个 promise 对象 resolve 时,我们启用 subscribe/unsubscribe 按钮(subBtn.disabled = false;),并确认订阅对象。

这样做了之后,订阅的准备工作已经完成了。即使这一应用并没有在浏览器中打开, service worker 也依然可能在后台处于激活状态。如果我们已经订阅,则对UI进行更新,修改按钮的标签,之后将 isPushEnabled 设为真,通过 PushSubscription.endpoint 取得订阅的端点,通过 PushSubscription.getKey() 生成一个公钥,调用 updateStatus() 方法与服务器进行通信。

此外,我们通过 MessageChannel.MessageChannel() 得到一个新的 MessageChannel 对象,并通过 ServiceworkerRegistration.active 得到一个激活的 service worker 的引用,然后通过 Worker.postMessage() 在主浏览器 context 和 service worker 间建立起一个信道。浏览器 context 接收 MessageChannel.port1 中的消息。当有消息到来时,我们使用 handleChannelMessage() 方法来决定如何处理数据(参阅Handling channel messages sent from the service worker)。

订阅和取消订阅(Subscribing and unsubscribing)

现在把我们的注意力转到 subscribe() 和 unsubscribe() 函数上,它们用来订阅或取消订阅 来自服务器的通知。

在订阅的时候,我们使用 ServiceWorkerContainer.ready()方法再一次确认service worker处于激活状态并且可以使用了。当 promise 成功执行,我们用PushManager.subscribe()方法订阅服务。如果订阅成功,我们会得到一个 PushSubscription 对象,它携带了endpoint信息 ,并且可以产生(generate)一个公钥的方法 (再多说一点,PushSubscription.endpoint属性和PushSubscription.getKey()方法),我们要把这两个信息传递给updateStatus() 函数,同时还要传递第三个信息——更新状态的类型(订阅还是不订阅),让它能够把这些必要的细节传递给服务器。

我们也需要更新我们应用的状态 (设置 isPushEnabledtrue) 和 UI (激活订阅或者不订阅的按钮,同时改变标签的显示状态,让用户下一次点击按钮的时候变成不订阅的状态或者订阅的状态。)

不订阅 unsubscribe() 函数在结构上和订阅函数相识,然而基本上它们做的是完全相反的事; 最值得注意的不同是得到当前订阅对象是使用PushManager.getSubscription()方法,而且使用PushSubscription.unsubscribe()方法获得的promise对象。

在两个函数中也提供了适当的错误处理函数。

为了节省时间,我们只在下面展示subscribe()的代码;查看全部请点击 subscribe/unsubscribe code on Github.

function subscribe() {
  // Disable the button so it can't be changed while
  // we process the permission request

  subBtn.disabled = true;

  navigator.serviceWorker.ready.then(function(reg) {
    reg.pushManager.subscribe({userVisibleOnly: true})
      .then(function(subscription) {
        // The subscription was successful
        isPushEnabled = true;
        subBtn.textContent = 'Unsubscribe from Push Messaging';
        subBtn.disabled = false;
        
        // Update status to subscribe current user on server, and to let
        // other users know this user has subscribed
        var endpoint = subscription.endpoint;
        var key = subscription.getKey('p256dh');
        updateStatus(endpoint,key,'subscribe');
      })
      .catch(function(e) {
        if (Notification.permission === 'denied') {
          // The user denied the notification permission which
          // means we failed to subscribe and the user will need
          // to manually change the notification permission to
          // subscribe to push messages
          console.log('Permission for Notifications was denied');
          
        } else {
          // A problem occurred with the subscription, this can
          // often be down to an issue or lack of the gcm_sender_id
          // and / or gcm_user_visible_only
          console.log('Unable to subscribe to push.', e);
          subBtn.disabled = false;
          subBtn.textContent = 'Subscribe to Push Messaging';
        }
      });
  });
}

更新应用和服务器的状态

接下来的一个主要的JavaScript函数就是updateStatus(),当订阅和取消订阅的时候,它负责更新UI中与服务器沟通的信息并发送状态更新的请求给服务器。

这个函数做了三件事当中的哪一件事,取决于下面赋值给statusType的类型:

  • subscribe: 一个按钮和和一个聊天信息的input输入框被创建后,就被插入到UI界面里面,然后通过XHR请求发送了一个包含状态信息的字面量对象给服务器,包含了statusType(subscribe)、订阅者的名字( username of the subscriber)、订阅终端(subscription endpoint)和客户端公钥(client public key)。
  • unsubscribe: 这个和订阅基本上是相反的——聊天的按钮和输入框被移除,同时又一个字面量对象被发送给服务器,告诉它取消订阅。
  • init: 当应用被第一次载入或者安装的时候会运行——它创建与服务器沟通的UI,并且发送一个对象告诉服务器是哪一个用户初始化(重新载入)了。

再多说一句,为了简介这里不会展示全部的代码。检查全部代码点击: full updateStatus() code on Github.

处理在service worker中发送过来的channel message

正如刚才我们提到的,当我们接收到从service worker发送的channel message 时,我们的 handleChannelMessage() 函数才会去执行它。 我们用channel.port1.onmessage事件处理函数去处理message event, :

channel.port1.onmessage = function(e) {
  handleChannelMessage(e.data);
}

这个函数会在service worker中发送信息给页面的时候在页面中执行(This occurs when the service worker sends a channel message over)。

 handleChannelMessage() 函数如下:

function handleChannelMessage(data) {
  if(data.action === 'subscribe' || data.action === 'init') {
    var listItem = document.createElement('li');
    listItem.textContent = data.name;
    subscribersList.appendChild(listItem);
  } else if(data.action === 'unsubscribe') {
    for(i = 0; i < subscribersList.children.length; i++) {
      if(subscribersList.children[i].textContent === data.name) {
        subscribersList.children[i].parentNode.removeChild(subscribersList.children[i]);
      }
    }
    nameInput.disabled = false;
  } else if(data.action === 'chatMsg') {
    var listItem = document.createElement('li');
    listItem.textContent = data.name + ": " + data.msg;
    messagesList.appendChild(listItem);
    sendInput.value = '';
  }
}

这个函数会做什么取决于传入的action参数的赋值:

  • subscribe or init (在启动和重启的时候,我们需要在这个示例中做同样的事情):一个<li> 元素被创建,它的text content 被设置为 data.name (订阅者的名字),然后它被添加到订阅者的列表里(一个简单的 <ul>元素里),所以这里是订阅者(再次)加入聊天的一个视觉反馈。
  • unsubscribe: 我们循环遍历订阅者的列表,查找谁的 text content 和data.name (取消订阅者的名字)相同,然后删除这个节点以提供一个视觉反馈表示有人取消订阅了。
  • chatMsg: 和第一个表现的形式类似, 一个 <li>被创建,它的text content设置为data.name + ": " + data.msg (例如: "Chris: This is my message"), 然后它被添加到每一个用户UI的聊天列表里。

注:  我们需要在更新DOM之前传递需要的数据给主页面(main context), 因为service worker不能操作DOM。你在使用前一定要知道service worker的一些限制。阅读 Using Service Workers 获取更多细节.

发送聊天信息

当‘Send Chat Message‘ 按钮被点击后,相关联的文本域的内容就作为聊天内容被发送出去。这个由 sendChatMessage() 函数处理(再多说一句,为了简洁就不展示了). 这个和 updateStatus() 的不同之处也是差不多的。 (查看 Updating the status in the app and server) — 我们获得 endpoint 和 public key 是来自一个 PushSubscription 对象, 这个对象又是来自于 ServiceWorkerContainer.ready() 方法和PushManager.subscribe()方法。它们(endpoint、public key)被传递进一个字面量对象里面( in a message object),同时含有订阅用户的名字,聊天信息,和chatMsg的statusType,然后通过XMLHttpRequest对象发送出去的,。

服务端(The Server)

正如我们上面提到的,我们需要一个服务端的容器去存储订阅者的信息,并且还要在状态更新时发送推送消息给客户端。 我们已经用一种hack的方式把一些需要的东西放到了一个快速-劣质(quick-and-dirty)的NodeJS 服务端上(server.js),用于处理来自客户端JavaScript的异步请求。

它用一个文本文件 (endpoint.txt)去存储订阅者的信息;这个文件一开始是空的(empty)。 有四种不同类型的请求,分别由被传输过来的对象中的statusType决定;这些在客户端通俗易懂的的状态类型同样也适用于服务端,因为服务端也有相同的状态。下面是这四个个状态在服务端代表的各自的含义。

  • subscribe: 服务器将会添加订阅者的信息到存储的容器里 (endpoint.txt),包括endpoint等,然后会推送给所有已经订阅了的订阅者一条消息,告诉每一个订阅者有新的订阅者加入了聊天。
  • unsubscribe: 服务器在存储订阅信息的容器里找到发送该类型请求的订阅者的详情,并移除这个订阅者的信息,然后推送一条消息给所有依然订阅的用户,告诉他们这个人取消订阅了。
  • init: 服务器从那个存储信息的文本中读取所有订阅者的信息,然后告诉他们有人初始化或者再次加入了这个聊天。
  • chatMsg: 推送一条订阅者想发送的信息给所有的订阅者;服务器从存储容器里读取现在订阅者的信息,然后服务器给每一个人推送一条包含聊天信息的消息。

其他需要注意的东西:

  • 我们使用的是Node.js的 https 模块  去创建的服务器,因为出于安全考虑,service worker只允许工作在一个安全的连接里(https only)。这也就是为什么我们要在应用里包含了.pfx 安全证书,然后在Node代码里引用这个证书。
  • 当你发送一条没有数据的推送消息的时候,只需要把它用http的post请求发送给对应订阅者的endpoint的URL。然而,当推送消息里包含数据的时候,你需要加密它,这个加密过程往往很复杂。随着时间的推移,一些出现的库文件(libraries)帮你做了这部分的工作。在这个demo里面我们用了 Marco Castelluccio的NodeJS库文件(web-push library)。查阅这些源代码了解更多加密过程是怎么完成的 (查阅 Message Encryption for Web Push 获取更多细节)。库文件让发送一条推送消息变得简单( The library makes sending a push message simple)。

The service worker

Now let's have a look at the service worker code (sw.js), which responds to the push messages, represented by push events. These are handled on the service worker's scope by the (ServiceWorkerGlobalScope.onpush) event handler; its job is to work out what to do in response to each received message. We first convert the received message back into an object by calling PushMessageData.json(). Next, we check what type of push message it is, by looking at the object's action property:

  • subscribe or unsubscribe: We send a system notification via the fireNotification() function, but also send a message back to the main context on our MessageChannel so we can update the subscriber list accordingly (see Handling channel messages sent from the service worker for more details).
  • init or chatMsg: We just send a channel message back to the main context to handle the init and chatMsg cases (these don't need a system notification).
self.addEventListener('push', function(event) {
  var obj = event.data.json();

  if(obj.action === 'subscribe' || obj.action === 'unsubscribe') {
    fireNotification(obj, event);
    port.postMessage(obj);
  } else if(obj.action === 'init' || obj.action === 'chatMsg') {
    port.postMessage(obj);
  }
});

Next, let's look at the fireNotification() function (which is blissfully pretty simple).

function fireNotification(obj, event) {
  var title = 'Subscription change';  
  var body = obj.name + ' has ' + obj.action + 'd.';
  var icon = 'push-icon.png';  
  var tag = 'push';
   
  event.waitUntil(self.registration.showNotification(title, {
    body: body,  
    icon: icon,  
    tag: tag  
  }));
}

Here we assemble the assets needed by the notification box: the title, body, and icon. Then we send a notification via the ServiceWorkerRegistration.showNotification() method, providing that information as well as the tag "push", which we can use to identify this notification among any other notifications we might be using. When the notification is successfully sent, it manifests as a system notification dialog on the users computers/devices in whatever style system notifications look like on those systems (the following image shows a Mac OSX system notification.)

Note that we do this from inside an ExtendableEvent.waitUntil() method; this is to make sure the service worker remains active until the notification has been sent. waitUntil() will extend the life cycle of the service worker until everything inside this method has completed.

Note: Web notifications from service workers were introduced around Firefox version 42, but are likely to be removed again while the surrounding functionality (such as Clients.openWindow()) is properly implemented (see bug 1203324 for more details.)

Handling premature subscription expiration

Sometimes push subscriptions expire prematurely, without PushSubscription.unsubscribe() being called. This can happen when the server gets overloaded, or if you are offline for a long time, for example.  This is highly server-dependent, so the exact behavior is difficult to predict. In any case, you can handle this problem by watching for the pushsubscriptionchange event, which you can listen for by providing a ServiceWorkerGlobalScope.onpushsubscriptionchange event handler; this event is fired only in this specific case.

self.addEventListener('pushsubscriptionchange', function() {
  // do something, usually resubscribe to push and
  // send the new subscription details back to the
  // server via XHR or Fetch
});

Note that we don't cover this case in our demo, as a subscription ending is not a big deal for a simple chat server. But for a more complex example you'd probably want to resubscribe the user.

Extra steps for Chrome support

To get the app working on Chrome, we need a few extra steps, as Chrome currently relies on Google's Cloud Messaging service to work.

Setting up Google Cloud Messaging

To get this set up, follow these steps:

  1. Navigate to the Google Developers Console  and set up a new project.
  2. Go to your project's homepage (ours is at https://console.developers.google.com/project/push-project-978, for example), then
    1. Select the Enable Google APIs for use in your apps option.
    2. In the next screen, click Cloud Messaging for Android under the Mobile APIs section.
    3. Click the Enable API button.
  3. Now you need to make a note of your project number and API key because you'll need them later. To find them:
    1. Project number: click Home on the left; the project number is clearly marked at the top of your project's home page.
    2. API key: click Credentials on the left hand menu; the API key can be found on that screen.

manifest.json

You need to include a Google app-style manifest.json file in your app, which references the project number you made a note of earlier in the gcm_sender_id parameter. Here is our simple example manifest.json:

{  
  "name": "Push Demo",  
  "short_name": "Push Demo",  
  "icons": [{  
        "src": "push-icon.png",  
        "sizes": "111x111",
        "type": "image/png"
      }],  
  "start_url": "/index.html",  
  "display": "standalone",  
  "gcm_sender_id": "224273183921"    
}

You also need to reference your manifest using a <link> element in your HTML:

<link rel="manifest" href="manifest.json">

userVisibleOnly

Chrome requires you to set the userVisibleOnly parameter to true when subscribing to the push service, which indicates that we are promising to show a notification whenever a push is received. This can be seen in action in our subscribe() function.

See also

Note: Some of the client-side code in our Push demo is heavily influenced by Matt Gaunt's excellent examples in Push Notifications on the Open Web. Thanks for the awesome work, Matt!

文档标签和贡献者

 此页面的贡献者: xgqfrms-GitHub, vankai, hibernake
 最后编辑者: xgqfrms-GitHub,