Web Push 推播通知實作指南 整合前後端機制實現高品質之訊息觸及策略

me
林彥成
2021-09-26 | 7 min.
文章目錄
  1. 1. 什麼是 Web Push Notification?
  2. 2. 什麼是 Web Push Notification?提升用戶參與度的關鍵
  3. 3. Web Push 原理密:推播通知是如何運作的?
    1. 3.1. 實現 Push Notification 實作的三個關鍵步驟
    2. 3.2. 客戶端訂閱通知
    3. 3.3. 伺服器發送推播訊息
    4. 3.4. 用戶端推播事件處理
  4. 4. 網頁推播通知伺服器實作:VAPID 金鑰配置
  5. 5. 網頁推播通知用戶端實作
  6. 6. 推播通知協定
    1. 6.1. 金鑰對 Application server keys
    2. 6.2. 傳輸內容加密
    3. 6.3. Header 參數配置
  7. 7. FAQ:Web Push 常見問題
    1. 7.1. Q1:為什麼 VAPID 是必須的?
    2. 7.2. Q2:使用者清除瀏覽器資料後,訂閱會失效嗎?
    3. 7.3. Q3:訊息發送後,使用者如果離線會發生什麼事?

什麼是 Web Push Notification?

Web Push Notification (網頁推播通知) 是一種結合 Push API 與 Notifications API 的技術,允許網站伺服器向使用者的瀏覽器發送即時訊息,即使該網頁目前已關閉。其運作依賴於 Service Worker 在背景持續監聽,並透過 VAPID (Voluntary Application Server Identification) 協定確保伺服器身分的安全驗證。這項技術能讓 Web 應用具備如同原生 App 般的「主動觸及」能力,是實現 PWA 使用者回流與即時互動的核心利器。


什麼是 Web Push Notification?提升用戶參與度的關鍵

推播通知(Push Notification)不論對於 Native App 還是 Web App 來說,都是重新吸引用戶回流(Re-engagement)的核心手段。我們的目標是:即使在瀏覽器關閉的狀態下,App 也能在背景接收並顯示訊息。

小編覺得推播通知是提升 Progressive Web App 參與度的最強武器。這篇文章將為您解密推播的運作原理,並提供完整的前後端 PWA 通知教學。


Web Push 原理密:推播通知是如何運作的?

在 PWA 通知教學 中,我們主要會用到兩個互補的技術:

  • Push API:當伺服器向 Service Worker 發送訊息。
  • Notifications API:在用戶裝置上顯示訊息內容。

實現 Push Notification 實作的三個關鍵步驟

  1. 用戶端訂閱通知:加入註冊訂閱邏輯。
  2. 伺服器發送推播訊息:透過後端向用戶設備發送訊息。
  3. 用戶端推播事件處理:Service Worker 捕捉推播事件並執行後續邏輯。

小編在這裡也提供了一個實作範例給大家參考:
https://linyencheng-push-notification.herokuapp.com/

客戶端訂閱通知

前端在實作概念上主要是四個步驟:

  1. 獲得向用戶發送推播消息的許可
  2. 拿到伺服器的 VAPID 公開金鑰
  3. 透過 Push API 生成一個 PushSubscription 需要的所有相關訊息,這裡可以把 PushSubscription 當成用戶的 ID
  4. 對伺服器發送訂閱請求

伺服器發送推播訊息

在實作後端的時候會遇到三個問題:

  1. 什麼是推播服務、要怎麼執行?

    收到訂閱通知的請求時,將請求內容驗證後就將消息推播到瀏覽器,若是瀏覽器離線,訊息就會排隊儲存起來等連線恢復後繼續傳送

  2. 相關 API 傳送協定和格式?

    瀏覽器會依照符合 IETF 標準的推播協議實作,伺服器原則上就是使用套件只要提供一個 endpoint 並且處理 PushSubscription 即可。

  3. API 可以做到什麼程度

    API 提供了一種向裝置發送訊息的方法

只要從伺服器發了推播消息,推播服務會將訊息保留,直到發生以下事件之一:

  • 裝置上線,推播服務推播訊息成功
  • 訊息過期,推播服務會將從 queue 刪除

用戶端推播事件處理

當推播服務成功推播後,瀏覽器會:

  1. 接收到訊息
  2. 解密數據
  3. 觸發 Service Worker 中的事件

透過 Service Worker 瀏覽器可以在不打開頁面的情況下去監聽相關事件,所以 App 在 Service Worker 收到 "推播" 事件後,就可以執行任何背景的任務,像是偷偷傳送分析資料、離線快取資源下載、顯示通知等等。

網頁推播通知伺服器實作:VAPID 金鑰配置

在還沒實際看教學文件前,會發現如果要從零開始依照推播協議的標準開始實作流程是相對複雜:

  1. 了解並按照協議去處理相關的內文傳輸格式
  2. 了解什麼是 VAPID 金鑰對的概念以及生成方式
  3. 數據加解密流程

再來就是觸發推播如果遇到問題,會較困難去排除問題。當然隨著開發者工具的進步會慢慢改善,但還是建議使用現成的套件工具來處理推播,Google 提供的 Firebase 就是不錯的方案,如果還是想要體驗自行實作,也可以使用 web-push 這個套件,來縮短開發時間。

接下來就來示範用 web-push 實作推播伺服器的主要步驟:

  1. 透過現有的推播套件產生並配置相關的 VAPID 金鑰對
  2. 建立一個 API 接收網頁的訂閱請求
  3. 從透過推播套件從後端針對剛剛的訂閱請求推播消息

可以發現如果是使用套件,底層標準的部分就都不需要實作,可以著重在商業邏輯的開發,透過 node.js 最簡單的版本推播實作程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const express = require("express");
const webPush = require("web-push");

const app = express();
const webPush = require("web-push");

// 1. 透過現有的推播套件配置相關的 VAPID 金鑰對
const publicVapidKey = process.env.PUBLIC_VAPID_KEY;
const privateVapidKey = process.env.PRIVATE_VAPID_KEY;
webPush.setVapidDetails(
"mailto:test@example.com",
publicVapidKey,
privateVapidKey
);

// 2. 建立一個 API 接收網頁的訂閱請求
app.post("/subscribe", (req, res) => {
// 收到訂閱請求
const subscription = req.body;

res.status(201).json({});

const payload = JSON.stringify({
title: "推播訊息內容",
});

// 3. 從透過推播套件從後端針對剛剛的訂閱請求推播消息
webPush
.sendNotification(subscription, payload)
.catch((error) => console.error(error));
});

如果需要進階一點的使用,可以多實作更多相關邏輯:

  1. 實作訂閱請求的儲存分類等機制: 把每次訂閱的 const subscription = req.body; 儲存起來,方便後續進階操作
  • 舉例來說訂閱時可以多帶參數 /subscribe?device=android&time=morning
  1. 實做 API 去處理要推播訊息: 訊息可以針對裝置或是用戶做分眾,或是取消推播等等

這次小編其實在後端也沒有多做複雜的處理,目的是為了測試關閉頁面後仍然可以推播,所以補上了一分鐘後再多送一次的邏輯。

1
2
3
4
5
setTimeout(() => {
webPush
.sendNotification(subscription, payload30)
.catch((error) => console.error(error));
}, 60 * 1000);

網頁推播通知用戶端實作

後端可以選擇自己實作也可以直接串接使用現有的服務,程式撰寫上相關操作的 JavaScript API 也相對簡單,接下來就來一步步完成最基本的推播吧。

  1. 檢查支援度,然後針對支援的部分做漸進式增強
  • serviceWorker: 背景也要可以收資料
  • PushManager: 串接推播
1
2
3
4
5
6
7
8
9
if (!("serviceWorker" in navigator)) {
// Service Worker 不支援
return;
}

if (!("PushManager" in window)) {
// Push API 不支援
return;
}
  1. 請求權限,瀏覽器需要允許推播通知
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function askPermission() {
return new Promise(function (resolve, reject) {
const permissionResult = Notification.requestPermission(function (result) {
resolve(result);
});

if (permissionResult) {
permissionResult.then(resolve, reject);
}
}).then(function (permissionResult) {
if (permissionResult !== "granted") {
throw new Error("We weren't granted permission.");
}
});
}
  1. 註冊 serviceWorker: 相關處理會寫在這邊
  2. 使用 PushManager 訂閱推播: 其中的參數 userVisibleOnly 目前 Chrome 只支援每次收到推送時顯示通知,這代表沒有辦法在背景偷偷做事情,applicationServerKey 則是會用到後端給的 Public Vapid Key,產生出來的 subscription 可以看成是 client 端的 ID。
  3. 向 Sever 發送訂閱: 把剛剛產生的 subscription 傳送到後端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if ("serviceWorker" in navigator) {
// 3. 註冊 serviceWorker
const register = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});

// 4. 使用 PushManager 訂閱推播
const subscription = await register.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey),
});

// 5. 向 Sever 發送訂閱
await fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
});
}
  1. 在 serviceWorker 中依照資料格式處理 push 事件,這裡的例子為顯示通知
  • 字串: event.data.text()
  • JSON: event.data.json()
  • blob: event.data.blob()
  • arrayBuffer: event.data.arrayBuffer()
  1. 透過 event.waitUntil() 等待通知執行完成
1
2
3
4
5
6
7
8
9
self.addEventListener("push", (event) => {
const data = event.data.json();

const promiseChain = self.registration.showNotification(data.title, {
body: "Yay it works!",
});

event.waitUntil(promiseChain);
});

推播通知協定

之前已經可以用後端的套件去實作推播的伺服器,但那個套件實際上做了哪些事情?

  • 金鑰對 Application server keys
  • 傳輸內容加密 Payload Encryption
  • Header 參數配置

流程如下圖,相關定義可參考連結:
https://datatracker.ietf.org/doc/html/draft-ietf-webpush-encryption

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+-------+           +--------------+       +-------------+
| UA | | Push Service | | Application |
+-------+ +--------------+ +-------------+
| | |
| Setup | |
|<====================>| |
| Provide Subscription |
|-------------------------------------------->|
| | |
: : :
| | Push Message |
| Push Message |<---------------------|
|<---------------------| |
| | |

也因為定義了標準協議的關係,所以有了各語言的實作版本:

金鑰對 Application server keys

  • 密鑰: 推播服務使用
  • 公鑰: 用戶端使用

密鑰會用來檢查訂閱的用戶身分公鑰是否符合,pushManager.subscribe() 會用公鑰來檢查接收到的簽章訊息是否由與公鑰相關的私鑰簽出來的。

簽章訊息的傳遞會透過 JWT 放在 header 進行資料交換,一個 JWT 會由三段字串組成並由 . 號分隔 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9.zTWqeQdfDM0WKGBFig2-VmUpTLkIQ4DvAJN6_LzDZzU,前兩個字串(JWT info 和 JWT data)是經過 base64 編碼的 JSON ,所以其實是公開可閱讀的。

想要解密 JWT,可以直接用官方網站提供的介面。
https://jwt.io/

  1. JWT 訊息: 會記錄用哪種算法簽章
1
2
3
4
5
{
"//": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
"alg": "HS256",
"typ": "JWT"
}
  1. JWT 資料: 看程式設計上想要帶什麼資料會放在這邊,推播的話會有三個欄位 aud、exp、sub,透過這三個參數和 vapid 產生工具就能夠產生金鑰對
  • aud: 推播服務來源
  • exp: JWT 到期時間
  • sub: 推播訊息的人的聯絡訊息

https://github.com/web-push-libs/vapid

1
2
3
4
5
6
{
"//": "`eyJuYW1lIjoidGVzdCJ9",
"aud": "https://YourSiteHere.example",
"sub": "mailto://admin@YourSiteHere.example",
"exp": 1457718878
}

最後的 header 就會像是 Authorization: 'WebPush <JWT Info>.<JWT Data>.<Signature>'

傳輸內容加密

推播的訊息不能赤裸裸的傳遞,所以在加密上也有定義相關規範,我也看不是很懂,總體來說就是套了加密、加鹽、金鑰對搭配使用,執行加密的時候最後也加上填充,避免被用長度推斷。

  • ECDH (elliptic-curve Diffie-Hellman): 透過運算去發現原來我們是天生一對,數學假設如下
    • y^2 = x^3 + ax + b
    • 4a^3 + 27b^2 != 0

看不懂也沒關係可以看影片,但我相信看完影片可能還是只懂概念。
https://youtu.be/F3zzNa42-tQ

1
2
3
4
5
const keyCurve = crypto.createECDH("prime256v1");
keyCurve.generateKeys();

const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
  • HKDF (Hashed Message Authentication Code): SHA-256
    • Salt: 16 byte authentication secret
    • IKM: shared secret
    • info
    • length
  • Nonce: 加密通信只能使用一次的數字,可能是一個隨機或偽隨機數,以避免重送攻擊
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hkdf(salt, ikm, info, length) {
// ikm 加鹽
const keyHmac = crypto.createHmac("sha256", salt);
keyHmac.update(ikm);
const key = keyHmac.digest();

// info 加密
const infoHmac = crypto.createHmac("sha256", key);
infoHmac.update(info);

const ONE_BUFFER = new Buffer(1).fill(1);
infoHmac.update(ONE_BUFFER);

// 長度控制
return infoHmac.digest().slice(0, length);
}

const nonce = hkdf(salt, prk, nonceInfo, 12);
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);
  • 使用者裝置:

    • ecdh_secret = ECDH(ua_private, as_public)
    • auth_secret = random(16)
    • salt = header 來的
  • 伺服器:

    • ecdh_secret = ECDH(as_private, ua_public)
    • auth_secret = user agent 來的
    • salt = random(16)

Header 參數配置

  • TTL header (time to live): 訊息能在推播服務上存活多久
  • Topic: 同個主題下可以實作舊訊息取代新訊息
  • Urgency: 訊息的重要程度

FAQ:Web Push 常見問題

Q1:為什麼 VAPID 是必須的?

A:VAPID 是自願性應用程式伺服器識別。它的作用是向推播服務(如 Google 的 FCM)證明訊息是由您授權的伺服器發出的。沒有 VAPID,推播服務將無法區分發送者,也無法提供進階的流量配額管理。

Q2:使用者清除瀏覽器資料後,訂閱會失效嗎?

A:會。 PushSubscription 是儲存在瀏覽器端的 ID。如果使用者清除快取或重設權限,原本的 Subscription 資訊會失效。好的做法是在每次網頁開啟時,重新執行訂閱邏輯以獲取最新(或檢查有效)的訂閱物件並同步到後端。

Q3:訊息發送後,使用者如果離線會發生什麼事?

A:推播服務會根據您設定的 TTL (Time to Live) 將訊息存放在 Queue 中。一旦使用者裝置上線,服務就會嘗試發送。若超過 TTL 時間使用者仍未上線,訊息則會被捨棄。