Web 推送技术
伴隨著今年 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) 我已經在前一篇文章里面講清楚了。這里主要探究一下另外兩個技術 Push 和 Notification。首先,有一個問題,這兩個技術是用來干嘛的呢?
Push && Notification
這兩個技術,我們可以理解為就是 server 和 SW 之間,SW 和 user 之間的消息通信。
push: server 將更新的信息傳遞給 SW
notification: SW 將更新的信息推送給用戶
可以看出,兩個技術是緊密連接到一起的。這里,我們先來講解一下 notification 的相關技術。
Notification
那現在,我們想給用戶發送一個消息的話應該怎么發送呢?
代碼很簡單,我直接放了:
大家一開始看見這個代碼,可能會覺得有點陌生。實際上,這里是結合 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,可能會讓用戶完全屏蔽掉我們的推送,得不償失。所以,我們需要遵循一定的原則去發送。
推送原則
推送必須簡潔
遵循時間,地點,人物要素進行相關信息的設置。
盡量不要讓用戶打開網頁查看
雖然這看起來有點違背我們最初的意圖。不過,這樣確實能夠提高用戶的體驗。比如在信息回復中,直接顯示:XX回復:... 這樣的格式,可以完全省去用戶的打開網頁的麻煩。
不要在 title 和 body 出現一樣的信息
比如:
correct:
incorrect
不要推薦原生 APP
因為很有可能造成推送信息重復
不要寫上自己的網址
因為,Not 已經幫你寫好了
盡量讓 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 注冊成功的回調中:
為了更好的顯示信息,我們還可以將授權代碼放到后面去。比如,將 subscribe 和 btn 的 click 事件進行綁定。這時候,我們并不需要考慮 SW 是否已經注冊好了,因為SW 的注冊時間遠遠不及用戶的反應時間。
例如:
我們具體看一下 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 詢問很簡單,但關鍵是,如果讓用戶同意。如果我們一開始就進行詢問,這樣成功性的可能性太低。我們可以在頁面加載后進行詢問。這里,也有一些提醒原則:
通過具體行為進行詢問
比如,當我在查詢車票時,就可以讓用戶在退出時選擇是否接受推送信息。比如,國外的飛機延遲通知網頁:
讓用戶來決定是否進行推送
因為用戶不是技術人員,我們需要將一些接口,暴露給用戶。針對推送而言,我們可以讓用戶選擇是否進行推送,并且,在提示的同時,顯示的信息應該盡量和用戶相關。
推送處理
web push 在實際協議中,會設計到兩個 server,比較復雜,這里我們先來看一下。client 是如何處理接受到的信息的。
當 SW 接受到 server 傳遞過來的信息時,會先觸發 push 事件。我們通常做如下處理:
其中,我們通過 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 進行包裹。
當然,如果你想在 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 可以設置為:
并且,如果你設置了 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 就是當前頁面的 urlclients.openWindow(examplePage));這里需要注意的是 examplePage 必須是和當前 SW 同域名才行。不過,這里有兩種情況,需要我們考慮:
指定的網頁已經打開?
當前沒網?
聚焦已經打開的頁面
這里,我們可以利用 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 對象中獲取。
相當于從:
變為:
上面提到了在 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
-
Clients.matchAll(options): 用來匹配當前 SW 控制的窗口。由于 SW 是根據路徑來控制的,有可能只返回一部分,而不是同域。如果需要返回同域的窗口,則需要設置響應的 options。
includeUncontrolled[Boolean]: 是否返回所有同域的 client。默認為 false。只返回當前 SW 控制的窗口。
type: 設置返回 client 的類型。通常有:window, worker, sharedworker, 和 all。默認是 all。
Clients.openWindow(url): 用來打開具體某個頁面
Clients.claim(): 用來設置當前 SW 和同域的 cilent 進行關聯。
Push
先貼一張 google 關于 web push 的詳解圖:
上述圖,簡單闡述了從 server 產生信息,最終到手機生成提示信息的一系列過程。
先說一下中間那個 Message Server。這是獨立于我們常用的 Server -> Client 的架構,瀏覽器可以自己選擇 push service,開發者一般也不用關心。不過,如果你想使用自己定制的 push serivce 的話,只需要保證你的 service 能夠提供一樣的 API 即可。上述過程為:
用于打開你的網頁,并且,已經生成好用來進行 push 的 applicationServerKey。然后,phone 開始初始化 SW
用戶訂閱該網頁的推送,此時會給 message server 發送一個請求,創建一個訂閱,然后返回 message server 的相關信息。
瀏覽器獲得 message server 的相關信息后,然后在發送一個請求給該網頁的 server。
如果 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 協議。上面的圖,其實可以表達為:
接下來,我們就需要簡單的來看一下使用 Web Push 的基本原則。
Push 基本原則
首先,server 發送的 push msg 必須被加密,因為這防止了中間的 push service 去查看我們的推送的信息。
通過 server 發送的 msg 需要設置一個失效時間,以為 Web Push 真正能夠作用的時間是當用戶打開瀏覽器的時候,如果用戶沒有打開瀏覽器,那么 push service 會一直保存該信息直到該條 push msg 過期。
那么如果我們想讓用戶訂閱我們的 push service 我們首先需要得到用戶是否進行提示的許可。當然,一開始我們還需要判斷一下,該用戶是否已經授權,還是拒絕,或者是還未處理。這里,可以參考上面提到的推送權限一節中的 initialiseState 函數方法。
這里我們主要研究一下具體的訂閱環節(假設用戶已經同意推送)。基本格式為:
這里有兩個參數 userVisibleOnly 和 applicationServerKey。這兩個屬性值具體代表什么意思呢?
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 內容。我們可以看一下具體頭部代表的意思:
頭部參考
| 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 接受之后,會返回相關的狀態碼,來表示具體操作結果:
| 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 代表:
用來表示 JWT 的加密算法是啥。
Payload 代表:
其中
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=applicationServerKeyContent-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 的總結圖:
總結
- 上一篇: 华为又一家联合创新实验室揭牌 聚焦数字孪
- 下一篇: 存在隐私风险?特斯拉在荷兰调整哨兵模式以