PWA(Progressive Web App)入門系列:Push

前言

不少時候,原生應用會經過一些消息推送來喚起用戶的關注,增長駐留率。網頁該怎麼作呢?有沒有相似原生應用的推送機制?推送功能又能玩出什麼花樣呢?html


Push API

Push API 給與了 Web 應用程序接收從服務器發出的推送消息的能力,不管 Web 應用程序是否在用戶設備前臺,甚至剛加載完成。這樣,開發人員就能夠向用戶投放異步通知和更新,從而讓用戶能更及時地獲取新內容。node

對 Web 應用來講,要想使用推送,必須在應用下的 ServiceWorker 處於激活狀態,在 ServiceWorkerRegistration scope 下的 PushManager 來作推送訂閱相關工做。git

ServiceWorkerGlobalScope scope 下經過 onpush 來監聽推送事件。github

激活一個 service worker 來提供推送消息會致使資源消耗的增長,尤爲是電池。不一樣的瀏覽器對此有不一樣的方案——目前爲止尚未標準的機制。Firefox 容許對發送給應用的推送消息作數量限制(配額)。該限制會在站點每一次被訪問以後刷新。相比之下,Chrome 選擇不作限制,但要求站點在每一次消息到達後都顯示通知,這樣可讓用戶確認他們仍但願接收消息並確保用戶可見性。web

接口

Push 的相關接口:算法

  • PushManager
  • PushEvent
  • PushMessageData
  • PushSubscription
  • PushSubscriptionOptions

PushManager

PushManager 接口用於操做推送訂閱。chrome

經過 ServiceWorkerRegistration.PushManager獲取。數據庫

方法:

subscribe()npm

用於訂閱推送服務。json

返回一個 Promise 形式的 PushSubscription 對象,該對象包含了推送訂閱詳情。若是當前 service worker 沒有已存在的訂閱,則會建立一個新的推送訂閱。

語法:

​PushManager.subscribe(options).then(function(pushSubscription) { ... } );
複製代碼

參數:

options:

  • userVisibleOnly:布爾值,表示返回的推送訂閱將只能被用於對用戶可見的消息。在訂閱時必須把此項設置爲 true,這樣當有消息推送給用戶時,瀏覽器會展現一個消息通知,也就是說不存在靜默推送。爲了讓用戶可知。
  • applicationServerKey:推送服務器用來向客戶端應用發送消息的公鑰。該值是應用程序服務器生成的簽名密鑰對的一部分,可以使用在 P-256 曲線上實現的橢圓曲線數字簽名(ECDSA)。這裏使用的是 VAPID 協議,VAPID 是 Voluntary Application Server Identification (自主應用服務器標識) 的簡稱。因此須要將 Base64 的公鑰轉爲 Uint8 的數組。

觸發推送時,瀏覽器的表現:

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:

  • userVisibleOnly
  • applicationServerKey

返回 Promise,以下值:

  • granted:WEB 應用已受權 Push 權限。
  • denied:WEB 應用已拒絕 Push 權限。
  • prompt:WEB 應用未受權 Push 權限。

以下使用:

ServiceWorkerRegistration.pushManager.permissionState({userVisibleOnly: true})
複製代碼

PushEvent

Push API 接收消息時的事件。此事件在 ServiceWorkerGlobalScope 下響應。

屬性

data:返回對 PushMessageData 類型,包含發送到的數據的對象。

PushMessageData

此接口爲 PushEvent.data 中的類型。

與 Fetch 中 Body 的方法類似,不一樣處再於能夠重複調用。

方法

  • arrayBuffer()
  • blob()
  • json()
  • text()

PushSubscription

PushSubscription 爲 PushManager.subscribe() 的訂閱信息類型。

屬性

  • endpoint:包含訂閱相關的推送服務器的信息。以 URL 形式展現。最好對於這個 URL 安全,防止被其餘人劫持它並濫用推送功能。
  • expirationTime:返回與推送訂閱關聯的訂閱到期時間(若是有),不然返回null。
  • options:PushSubscriptionOptions 類型,訂閱時的 options 信息,包含:
    • applicationServerKey
    • userVisibleOnly

方法

getKey()

用於獲取 PushSubscription 中訂閱的公鑰信息,返回 ArrayBuffer。

語法:

​var key = subscription.getKey(name);
複製代碼

參數:

name:

  • p256dh:P-256曲線上的橢圓曲線Diffie-Hellman公鑰(即NIST secp256r1橢圓曲線)。 生成的密鑰是ANSI X9.62格式的未壓縮點。
  • auth:身份驗證密鑰,Web推送的加密描述。

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 相關事件

Push API 經過下面的 serviceWorker 事件來監控並響應推送和訂閱更改事件。

onpush

當 ServiceWorker 收到 Push-Server 推送的消息時,就會觸發 ServiceWorkerGlobalScope 接口的 onpush 事件。

語法:

ServiceWorkerGlobalScope.onpush = function(PushEvent) { ... }
self.addEventListener('push', function(PushEvent) { ... })
複製代碼

經過 PushEvent.data 來獲取 PushMessageData 類型的推送消息中的數據。

onpushsubscriptionchange

當訂閱信息發生改變時,會觸發 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"  
}

複製代碼
  • aud:推送服務器的地址。
  • exp:簽名過時時間,單位秒,必須不大於 24 小時。
  • sub:必須是 URL 或者 郵箱地址。用於推送服務器聯繫發送人。

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 字段就是瀏覽器端的密鑰信息,由瀏覽器生成。

加密須要 authp256dhpayload 三個值作爲輸入進行加密,加密過程比較複雜。

能夠看一下,生成的具體要發送給推送服務器的字段,下面是 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"
};
複製代碼

VAPID key 生成

密鑰使用 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)

常見問題

1. 瀏覽器關閉能否收到推送?

Android 系統:

Android 系統的消息機制是系統級的,系統有單獨的進程去監聽推送消息,收到消息就會喚醒對應的應用程序來處理這個推送消息,不管應用是否關閉。全部應用都採用這種處理方式。因此當收到瀏覽器的推送消息時,會喚醒瀏覽器,而後瀏覽器再去激活相應 的 ServiceWorker 線程,而後觸發推送事件。

MAC 系統:

MAC 系統下當打開應用後,默認關閉應用實際上還在後臺運行,能夠經過 dock 來查看:

能夠看到未徹底關閉的應用下面會有一個黑點來標誌,在這種狀況下,瀏覽器是能夠收到推送消息的。

若是瀏覽器徹底關閉,則當在瀏覽器打開後,瀏覽器一樣會收到通知消息(TTL 有效時間內)。

Windows 系統:

Windows 系統和 MAC 類似,但判斷瀏覽器是否在後臺運行比較複雜。

2. 對於消息推送如何在瀏覽器上調試查看?

Chrome 環境下,地址欄輸入chrome://gcm-internals/,並點擊Start Recording按鈕進行錄製。

一般來講,主要有兩方面的問題:

  • 發送消息時的問題:
    • 受權問題
    • HTTP 狀態碼錯誤問題
  • 接收消息時的問題:
    • payload 加密問題
    • 鏈接問題

若是經過上述工具解決不了問題,能夠提交問題到官方:

3. 爲何 Push 比 Web Sockets 好?

Push 是工做在 serviceWorker 線程下的,因此不關係瀏覽器窗口是否打開。而 Web Sockets 必須保證瀏覽器和網頁處於打開狀態才能正常工做。

4. 國內服務器沒法與 FCM/GCM 推送服務器通信,怎麼辦?

關於這一點,能夠在國內服務器對消息通信的請求上部署代理服務器,如在 node 環境下用 web-push 庫能夠這麼寫:

webpush.sendNotification(
	subscription,
	data,
	{
		... options,
		proxy: '代理地址'
	}
)
複製代碼

或者能夠基於三方的推送工具來實現,如:onesignal


工具

  • 推送加密驗證工具:地址
  • Mozilla 推送數據加密測試頁:地址
  • Google Codelab 推送工具:地址
  • JWT 驗證:地址

兼容性


博客名稱:王樂平博客

CSDN博客地址:blog.csdn.net/lecepin

知識共享許可協議
本做品採用 知識共享署名-非商業性使用-禁止演繹 4.0 國際許可協議進行許可。
相關文章
相關標籤/搜索