【PWA學習與實踐】(5)在Web中進行服務端訊息推送

AlienZHOU發表於2018-04-13

《PWA學習與實踐》系列文章已整理至gitbook - PWA學習手冊,文字內容已同步至learning-pwa-ebook。轉載請註明作者與出處。

本文是《PWA學習與實踐》系列的第五篇文章。文中的程式碼都可以在learning-pwa的push分支上找到(git clone後注意切換到push分支)。

PWA作為時下最火熱的技術概念之一,對提升Web應用的安全、效能和體驗有著很大的意義,非常值得我們去了解與學習。對PWA感興趣的朋友歡迎關注《PWA學習與實踐》系列文章。

1. 引言

在之前的幾篇文章中,我和大家分享瞭如何使用manifest(以及meta標籤)讓你的Web App更加“native”;以及如何使用Service Worker來cache資源,加速Web App的訪問速度,提供部分離線功能。在接下來的內容裡,我們會探究PWA中的另一個重要功能——訊息推送與提醒(Push & Notification)。這個能力讓我們可以從服務端向使用者推送各類訊息並引導使用者觸發相應互動。

Web Push效果

實際上,訊息推送與提醒是兩個功能——Push API 和 Notification API。為了大家能夠更好理解其中的相關技術,我也會分為Push(推送訊息)與Notification(展示提醒)兩部分來介紹。在這一篇裡,我們先來學習如何使用Push API進行訊息推送。

Push API 和 Notification API其實是兩個獨立的技術,完全可以分開使用;不過Push API 和 Notification API相結合是一個常見的模式。

2. 瀏覽器是如何實現伺服器訊息Push的

Web Push的整個流程相較之前的內容來說有些複雜。因此,在進入具體技術細節之前,我們需要先了解一下整個Push的基本流程與相關概念。

如果你對Push完全不瞭解,可能會認為,Push是我們的服務端直接與瀏覽器進行互動,使用長連線、WebSocket或是其他技術手段來向客戶端推送訊息。然而,這裡的Web Push並非如此,它其實是一個三方互動的過程。

在Push中登場的三個重要“角色”分別是:

  • 瀏覽器:就是我們的客戶端
  • Push Service:專門的Push服務,你可以認為是一個第三方服務,目前chrome與firefox都有自己的Push Service Service。理論上只要瀏覽器支援,可以使用任意的Push Service
  • 後端服務:這裡就是指我們自己的後端服務

下面就介紹一下這三者在Web Push中是如何互動。

2.1. 訊息推送流程

下圖來自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物件中),我們需要將這些資訊傳送到自己的服務端,在服務端進行儲存。

【PWA學習與實踐】(5)在Web中進行服務端訊息推送

  • Push Message,然後是推送:
    1. Push Message階段一:我們的服務端需要推送訊息時,不直接和客戶端互動,而是通過Web Push協議,將相關資訊通知Push Service;
    2. Push Message階段二:Push Service收到訊息,通過校驗後,基於其維護的客戶端資訊,將訊息推送給訂閱了的客戶端;
    3. 最後,客戶端收到訊息,完成整個推送過程。

【PWA學習與實踐】(5)在Web中進行服務端訊息推送

2.2. 什麼是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.

Push Service可以接收網路請求,校驗該請求並將其推送給合適的瀏覽器客戶端。Push Service還有一個非常重要的功能:當使用者離線時,可以幫我們儲存訊息佇列,直到使用者聯網後再傳送給他們。

目前,不同的瀏覽器廠商使用了不同的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

【PWA學習與實踐】(5)在Web中進行服務端訊息推送

那麼,如果我們知道了endpoint的值,是否就代表我們可以向客戶端推送訊息了呢?並非如此。下面會簡單介紹一下Web Push中的安全策略。

2.3. 如何保證Push的安全性

在Web Push中,為了保證客戶端只會收到其訂閱的服務端推送的訊息(其他的服務端即使在拿到endpoint也無法推送訊息),需要對推送資訊進行數字簽名。該過程大致如下:

在Web Push中會有一對公鑰與私鑰。客戶端持有公鑰,而服務端持有私鑰。客戶端在訂閱時,會將公鑰傳送給Push Service,而Push Service會將該公鑰與相應的endpoint維護起來。而當服務端要推送訊息時,會使用私鑰對傳送的資料進行數字簽名,並根據數字簽名生成一個叫】Authorization請求頭。Push Service收到請求後,根據endpoint取到公鑰,對數字簽名解密驗證,如果資訊相符則表明該請求是通過對應的私鑰加密而成,也表明該請求來自瀏覽器所訂閱的服務端。反之亦然。

【PWA學習與實踐】(5)在Web中進行服務端訊息推送

而公鑰與私鑰如何生成,會在第三部分的例項中講解。

3. 如何使用Push API來推送向使用者推送資訊

到這裡,我們已經基本瞭解了Web Push的流程。光說不練假把式,下面我就通過具體程式碼來說明如何使用Web Push。

這部分會基於sw-cache分支上的程式碼,繼續增強我們的“圖書搜尋”WebApp。

為了使文章與程式碼更清晰,將Web Push分為這幾個部分:

  1. 瀏覽器發起訂閱,並將訂閱資訊傳送至後端;
  2. 將訂閱資訊儲存在服務端,以便今後推送使用;
  3. 服務端推送訊息,向Push Service發起請求;
  4. 瀏覽器接收Push資訊並處理。

友情提醒:由於Chrome所依賴的Push Service——FCM在國內不可訪問,所以要正常執行demo中的程式碼需要“梯子”,或者可以選擇Firefox來進行測試。

3.1. 瀏覽器(客戶端)生成subscription資訊

首先,我們需要使用PushManagersubscribe方法來在瀏覽器中進行訂閱。

《讓你的WebApp離線可用》中我們已經知道了如何註冊Service Worker。當我們註冊完Service Worker後會得到一個Registration物件,通過呼叫Registration物件的registration.pushManager.subscribe()方法可以發起訂閱。

為了使程式碼更清晰,本篇demo在之前的基礎上,先抽離出Service Worker的註冊方法:

// index.js
function registerServiceWorker(file) {
    return navigator.serviceWorker.register(file);
}
複製程式碼

然後定義了subscribeUserToPush()方法來發起訂閱:

// index.js
function subscribeUserToPush(registration, publicKey) {
    var subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: window.urlBase64ToUint8Array(publicKey)
    }; 
    return registration.pushManager.subscribe(subscribeOptions).then(function (pushSubscription) {
        console.log('Received PushSubscription: ', JSON.stringify(pushSubscription));
        return pushSubscription;
    });
}

複製程式碼

這裡使用了registration.pushManager.subscribe()方法中的兩個配置引數:userVisibleOnlyapplicationServerKey

  • userVisibleOnly表明該推送是否需要顯性地展示給使用者,即推送時是否會有訊息提醒。如果沒有訊息提醒就表明是進行“靜默”推送。在Chrome中,必須要將其設定為true,否則瀏覽器就會在控制檯報錯:

userVisibleOnly不為true時的報錯資訊

  • applicationServerKey是一個客戶端的公鑰,VAPID定義了其規範,因此也可以稱為VAPID keys。如果你還記得2.3中提到的安全策略,應該對這個公鑰不陌生。該引數需要Unit8Array型別。因此定義了一個urlBase64ToUint8Array方法將base64的公鑰字串轉為Unit8Array。subscribe()也是一個Promise方法,在then中我們可以得到訂閱的相關資訊——一個PushSubscription物件。下圖展示了這個物件中的一些資訊。注意其中的endpoint,Push Service會為每個客戶端隨機生成一個不同的值.

PushSubscription資訊

之後,我們再將PushSubscription資訊傳送到後端。這裡定義了一個sendSubscriptionToServer()方法,該方法就是一個普通的XHR請求,會向介面post訂閱資訊,為了節約篇幅就不列出具體程式碼了。

最後,將這一系列方法組合在一起。當然,使用Web Push前,還是需要進行特性檢測'PushManager' in window

// index.js
if ('serviceWorker' in navigator && 'PushManager' in window) {
    var publicKey = 'BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A';
    // 註冊service worker
    registerServiceWorker('./sw.js').then(function (registration) {
        console.log('Service Worker 註冊成功');
        // 開啟該客戶端的訊息推送訂閱功能
        return subscribeUserToPush(registration, publicKey);
    }).then(function (subscription) {
        var body = {subscription: subscription};
        // 為了方便之後的推送,為每個客戶端簡單生成一個標識
        body.uniqueid = new Date().getTime();
        console.log('uniqueid', body.uniqueid);
        // 將生成的客戶端訂閱資訊儲存在自己的伺服器上
        return sendSubscriptionToServer(JSON.stringify(body));
    }).then(function (res) {
        console.log(res);
    }).catch(function (err) {
        console.log(err);
    });
}
複製程式碼

注意,這裡為了方便我們後面的推送,為每個客戶端生成了一個唯一IDuniqueid,這裡使用了時間戳生成簡單的uniqueid

此外,由於userVisibleOnlytrue,所以需要使用者授權開啟通知許可權,因此我們會看到下面的提示框,選擇“允許”即可。你可以在設定中進行通知的管理。

【PWA學習與實踐】(5)在Web中進行服務端訊息推送

3.2. 服務端儲存客戶端subscription資訊

為了儲存瀏覽器post來的訂閱資訊,服務端需要增加一個介面/subscription,同時新增中介軟體koa-body用於處理body

// app.js
const koaBody = require('koa-body');
/**
 * 提交subscription資訊,並儲存
 */
router.post('/subscription', koaBody(), async ctx => {
    let body = ctx.request.body;
    await util.saveRecord(body);
    ctx.response.body = {
        status: 0
    };
});
複製程式碼

接收到subscription資訊後,需要在服務端進行儲存,你可使用任何方式來儲存它:mysql、redis、mongodb……這裡為了方便,我使用了nedb來進行簡單的儲存。nedb不需要部署安裝,可以將資料儲存在記憶體中,也可以持久化,nedb的api和mongodb也比較類似。

這裡util.saveRecord()做了這些工作:首先,查詢subscription資訊是否存在,若已存在則只更新uniqueid;否則,直接進行儲存。

至此,我們就將客戶端的訂閱資訊儲存完畢了。現在,就可以等待今後推送時使用。

3.3. 使用subscription資訊推送資訊

在實際中,我們一般會給運營或產品同學提供一個推送配置後臺。可以選擇相應的客戶端,填寫推送資訊,併發起推送。為了簡單起見,我並沒有寫一個推送配置後臺,而只提供了一個post介面/push來提交推送資訊。後期我們完全可以開發相應的推送後臺來呼叫該介面。

// app.js
/**
 * 訊息推送API,可以在管理後臺進行呼叫
 * 本例子中,可以直接post一個請求來檢視效果
 */
router.post('/push', koaBody(), async ctx => {
    let {uniqueid, payload} = ctx.request.body;
    let list = uniqueid ? await util.find({uniqueid}) : await util.findAll();
    let status = list.length > 0 ? 0 : -1;

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

    ctx.response.body = {
        status
    };
});
複製程式碼

來看一下/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. 最後返回結果,這裡只是簡單的根據是否有訂閱資訊來進行返回。

安裝node版web-push

npm install web-push --save
複製程式碼

前面我們提到的公鑰與私鑰,也可以通過web-push來生成

【PWA學習與實踐】(5)在Web中進行服務端訊息推送

使用web-push非常簡單,首先設定VAPID keys:

// app.js
const webpush = require('web-push');
/**
 * VAPID值
 * 這裡可以替換為你業務中實際的值
 */
const vapidKeys = {
    publicKey: 'BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A',
    privateKey: 'TVe_nJlciDOn130gFyFYP8UiGxxWd3QdH6C5axXpSgM'
};

// 設定web-push的VAPID值
webpush.setVapidDetails(
    'mailto:alienzhou16@163.com',
    vapidKeys.publicKey,
    vapidKeys.privateKey
);
複製程式碼

設定完成後即可使用webpush.sendNotification()方法向Push Service發起請求。

最後我們來看下pushMessage()方法的細節:

// app.js
/**
 * 向push service推送資訊
 * @param {*} subscription 
 * @param {*} data 
 */
function pushMessage(subscription, data = {}) {
    webpush.sendNotification(subscription, data, options).then(data => {
        console.log('push service的相應資料:', JSON.stringify(data));
        return;
    }).catch(err => {
        // 判斷狀態碼,440和410表示失效
        if (err.statusCode === 410 || err.statusCode === 404) {
            return util.remove(subscription);
        }
        else {
            console.log(subscription);
            console.log(err);
        }
    })
}
複製程式碼

webpush.sendNotification為我們封裝了請求的處理細節。狀態碼401和404表示該subscription已經無效,可以從資料庫中刪除。

3.4. Service Worker監聽Push訊息

呼叫webpush.sendNotification()後,我們就已經把訊息傳送至Push Service了;而Push Service會將我們的訊息推送至瀏覽器。

要想在瀏覽器中獲取推送資訊,只需在Service Worker中監聽push的事件即可:

// sw.js
self.addEventListener('push', function (e) {
    var data = e.data;
    if (e.data) {
        data = data.json();
        console.log('push的資料為:', data);
        self.registration.showNotification(data.text);        
    } 
    else {
        console.log('push沒有任何資料');
    }
});
複製程式碼

4. 效果展示

我們同時使用firefox與chrome來訪問該WebApp,並分別向這兩個客戶端推送訊息。我們可以使用console中列印出來的uniqueid,在postman中發起/push請求進行測試。

Web Push效果

可以看到,我們分別向firefox與chrome中推送了“welcome to PWA”這條訊息。console中的輸出來自於Service Worker中對push事件的監聽。而彈出的瀏覽器提醒則來自於之前提到的、訂閱時配置的userVisibleOnly: true屬性。在後續的文章裡,我繼續帶大家瞭解Notification API(提醒)的使用。

正如前文所述,Push Service可以在裝置離線時,幫你維護推送訊息。當瀏覽器裝置重新聯網時,就會收到該推送。下面展示了在裝置恢復聯網後,就會收到推送:

恢復網路則會收到推送訊息

5. 萬惡的相容性

又到了檢視相容性的時間了。比較重要的是,對於Push API,目前Safari團隊並沒有明確表態計劃支援。

【PWA學習與實踐】(5)在Web中進行服務端訊息推送

當然,其實比相容性更大的一個問題是,Chrome所依賴的FCM服務在國內是無法訪問的,而Firefox的服務在國內可以正常使用。這也是為什麼在程式碼中會有這一項設定:

const options = {
    // proxy: 'http://localhost:1087' // 使用FCM(Chrome)需要配置代理
};
複製程式碼

上面程式碼其實是用來配置web-push代理的。這裡有一點需要注意,目前從npm上安裝的web-push是不支援設定代理選項的。針對這點github上專門有issue進行了討論,並在最近(兩週前)合入了相應的PR。因此,如果需要web-push支援代理,簡單的方式就是基於master進行web-push程式碼的相應調整。

雖然由於google服務被遮蔽,導致國內Push功能無法在chrome上使用,但是作為一個重要的技術點,Web Push還是非常值得我們瞭解與學習的。

6. 寫在最後

本文中所有的程式碼示例均可以在learn-pwa/push上找到。注意在git clone之後,切換到push分支。切換其他分支可以看到不同的版本:

  • basic分支:基礎專案demo,一個普通的圖書搜尋應用(網站);
  • manifest分支:基於basic分支,新增manifest等功能;
  • sw-cache分支:基於manifest分支,新增快取與離線功能;
  • push分支:基於sw-cache分支,新增服務端訊息推送功能;
  • master分支:應用的最新程式碼。

如果你喜歡或想要了解更多的PWA相關知識,歡迎關注我,關注《PWA學習與實踐》系列文章。我會總結整理自己學習PWA過程的遇到的疑問與技術點,並通過實際程式碼和大家一起實踐。

在下一篇文章裡,我們先緩下腳步——工欲善其事,必先利其器。在繼續瞭解更多PWA相關技術之前,先了解一些chrome上的PWA除錯技巧。之後,我們會再回來繼續瞭解另一個經常與Push API組合在一起的功能——訊息提醒,Notification API。

《PWA學習與實踐》系列

參考資料

相關文章