這是本站開發Web Push功能的紀錄,到現在我也還是沒有完全搞懂整個的邏輯,就將目前了解的部分整理出來供大家參考,也讓我自己比較好理清相關的內容w。
Web Push是什麼 🔗
簡單來說,就是在沒有開網頁、瀏覽器沒開的情況下,也可以發送通知給用戶的技術。
電腦上通常是在右下角的系統通知會跳出;在手機上面就會在手機的通知上面有通知出現。這個訂閱是跟隨著你的瀏覽器的,像你在Chrome和Firefox都訂閱了,那麼一次通知就會兩邊都跳。
Web Push的運作原理 🔗
不免俗的,這個功能還是會分前後端:
前端 🔗
先說前端,會在這邊做一個Service Worker,訂閱的時候就是註冊在這個Service Worker上面,接著就會產生訂閱資訊Push Subscription。
Service Worker會在收到屬於他的訂閱資訊的時候做對應的動作(發送通知給目標)
後端 🔗
這邊就是透過訂閱資訊,搭配Web Push的lib進行通知的發送。目前應該大部分的語言都有對應的Web Push相關lib可以使用。
Web Push 流程 🔗
這個參考Codus大的流程圖,基本上的流程就是
- 取得金鑰
- 寫好Service Worker處理推播事件的邏輯
- 註冊Service Worker
- 用金鑰公鑰向推播伺服器取得訂閱資訊subscription
- 訂閱資訊subscription存入後台
- 後台發送推播的時候使用訂閱資訊subscription
- Service Worker根據推播事件邏輯進行推播
實作範例 🔗
這邊以我的前後台設計做示範(Nuxt3-Vite + Springboot-JDK21)
前台 - Service Worker.js 🔗
push負責推送通知、notificationclick處理點擊的情況,注意這個必須是靜態資源,所以我放在專案目錄的public下
self.addEventListener('push', function (event) {
const data = event.data.json();
const title = data.title || '通知';
const body = data.body || '你有蜴則新通知!';
const url = data.url || '/';
const options = {
body: body,
icon: '/seagalogs.ico',
data: { url: url }
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.openWindow(url)
);
});
前台 - Client端 Nuxt Plugin(type script) 🔗
這邊把公鑰、後台資訊隱藏起來了,不過基本上外框就長這樣,把TODO改寫一下就可以用了。值得一提的是這邊有特別針對不能使用推播的瀏覽器進行判斷,雖然有大部分內容是靠著GPT協助生成的。
export default defineNuxtPlugin(() => {
if (
!('serviceWorker' in navigator) ||
!('PushManager' in window) ||
!('Notification' in window)
) {
console.warn('[Push] 此瀏覽器不支援 Push Notification')
return
}
const registerPush = async () => {
try {
const reg = await navigator.serviceWorker.register('/service-worker.js')
const existingSubscription = await reg.pushManager.getSubscription()
if (Notification.permission === 'granted') {
const subscription = existingSubscription
? existingSubscription
: await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: "PUBLIC_KEY"//TODO public key,
})
//TODO 送出訂閱資訊到後端
console.log('[Push] 訂閱資訊已送出至後端')
} else if (Notification.permission === 'default') {
const permission = await Notification.requestPermission()
if (permission === 'granted') {
await registerPush() // 再次執行
} else {
console.warn('[Push] 使用者未授權通知')
}
} else {
console.warn('[Push] 通知已封鎖')
}
} catch (err) {
console.error('[Push] 訂閱處理失敗:', err)
}
}
const tryRegister = () => {
registerPush()
}
if (document.readyState === 'complete') {
tryRegister()
} else {
const intervalId = setInterval(() => {
if (document.readyState === 'complete') {
clearInterval(intervalId)
tryRegister()
}
}, 100)
}
const urlBase64ToUint8Array = (base64String: string) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
}
})
後台 - PushServiceConfig.java 🔗
使用nl.martijndwars.webpush
作為Web Push的libs
package com.blog.seagalogs.config;
import com.blog.seagalogs.common.properties.VapidProperties;
import nl.martijndwars.webpush.PushService;
import nl.martijndwars.webpush.Utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.security.Security;
@Configuration
public class PushServiceConfig {
@Autowired
private VapidProperties vapidProperties;
@Bean
public PushService pushService() throws Exception {
// 註冊 BouncyCastle 提供者(只需要一次)
Security
.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
String publicKey = vapidProperties.getPublicKey();
String privateKey = vapidProperties.getPrivateKey();
String subject = vapidProperties.getSubject();
PushService pushService = new PushService().setPublicKey(
Utils.loadPublicKey(publicKey)).setPrivateKey(
Utils.loadPrivateKey(privateKey)).setSubject(subject);
return pushService;
}
}
後台 - 推送的方法 🔗
endpoint, p256dh, auth就是訂閱資訊的內容,這邊利用DB儲存這三個資訊後,依序打每個訂閱資訊即可給每個訂閱資訊發送通知。
String payload = """
{
"title": "%s",
"body": "%s",
"url": "%s"
}
""".formatted(title, body, url);
Notification notification = new Notification(
endpoint,
p256dh,
auth,
payload
);
HttpResponse response = pushService.send(notification);
注意這邊若傳404, 410代表這個訂閱無效了,最好搭配刪除機制避免太多無效訂閱塞爆你的DB。
跌跌撞撞2~3天把這個研究出來,雖然說不一定可以增加流量或是可見度之類的,但是有這樣的功能還是挺輕便又方便的是吧?