建構推播通知伺服器

在本程式碼研究室中,您將建構推播通知伺服器。伺服器會管理推送訂閱項目清單,並傳送通知給這些訂閱。

用戶端程式碼已完成。在本程式碼研究室中,您將使用伺服器端的功能。

重混範例應用程式,並在新分頁中開啟應用程式

系統會自動封鎖內嵌的 Glitch 應用程式通知,因此您無法在這個頁面上預覽應用程式。而是採取以下做法:

  1. 按一下「Remix to Edit」,讓專案可供編輯。
  2. 如要預覽網站,請按下「查看應用程式」,然後按下「全螢幕」圖示 全螢幕

執行中的應用程式會在新的 Chrome 分頁中開啟。在嵌入的 Glitch 中,按一下「View Source」,即可再次顯示程式碼。

執行本程式碼研究室時,請變更本頁內嵌 Glitch 中的程式碼。重新整理運作中的應用程式新分頁,查看變更。

熟悉入門應用程式及其程式碼

首先,請查看應用程式的用戶端 UI。

在新的 Chrome 分頁中:

  1. 按下「Control + Shift + J 鍵」(在 Mac 上則是「Command + Option + J 鍵」) 開啟開發人員工具。 再按一下「Console」(控制台) 分頁標籤即可。

  2. 嘗試點選 UI 中的按鈕 (請查看 Chrome 開發控制台取得輸出內容)。

    • 註冊 Service Worker 會針對 Glitch 專案網址的範圍註冊 Service Worker。取消註冊 Service Worker 會移除 Service Worker。如果附加了推送訂閱項目,推送訂閱項目也會一併停用。

    • 訂閱以推送會建立推送訂閱項目。只有在註冊 Service Worker,且用戶端程式碼中有 VAPID_PUBLIC_KEY 常數時才能使用此變數 (稍後將詳細說明),因此您還無法點選此常數。

    • 具備有效的推送訂閱項目時,「通知目前的訂閱」要求伺服器傳送通知至其端點。

    • 通知所有訂閱項目,告訴伺服器傳送通知至資料庫中的所有訂閱端點。

      請注意,部分端點可能處於閒置狀態。伺服器傳送通知給伺服器時,都有可能讓訂閱項目消失。

接著來看看伺服器端發生的情況。如要查看來自伺服器程式碼的訊息,請查看 Glitch 介面中的 Node.js 記錄。

  • 在 Glitch 應用程式中,按一下「Tools」->「Log」

    系統可能會顯示類似「Listening on port 3000」的訊息,

    如果您嘗試在上線的應用程式 UI 中點選「通知目前的訂閱通知」或「通知所有訂閱內容」,您也會看到以下訊息:

    TODO: Implement sendNotifications()
    Endpoints to send to:  []
    

現在,讓我們看看一些程式碼。

  • public/index.js 包含已完成的用戶端程式碼。它會執行功能偵測、註冊和取消註冊 Service Worker,並控制使用者的推播通知訂閱。此外,也會將新增和已刪除的訂閱項目相關資訊傳送到伺服器。

    由於您的示範範圍只會使用伺服器功能,因此您不必編輯這個檔案 (只需填入 VAPID_PUBLIC_KEY 常數)。

  • public/service-worker.js 是簡單的 Service Worker,可擷取推送事件並顯示通知。

  • /views/index.html 包含應用程式 UI。

  • .env 包含 Glitch 啟動時的環境變數,您將在 .env 中填入傳送通知的驗證詳細資料。

  • server.js 是您將在本程式碼研究室中處理大部分工作的檔案。

    起始程式碼會建立簡易的 Express 網路伺服器。您有四個 TODO 項目,在程式碼註解中會以 TODO: 標示。必要操作:

    在本程式碼研究室中,您將逐一瞭解這些 TODO 項目。

產生及載入 VAPID 詳細資料

您的第一個 TODO 項目是產生 VAPID 詳細資料、將這些詳細資料新增至 Node.js 環境變數,並根據新的值更新用戶端和伺服器程式碼。

背景

使用者訂閱通知時,必須信任應用程式及其伺服器的身分。使用者也必須確保,收到通知時,他們使用的是設定訂閱項目的同一個應用程式。並確保其他人無法讀取通知內容。

讓推播通知的安全性和私密性,這項通訊協定稱為「自主性應用程式伺服器識別 (VAPID)」,VAPID 使用公開金鑰密碼編譯技術驗證應用程式、伺服器和訂閱端點的身分,以及加密通知內容。

在這個應用程式中,您將使用 web-push npm 套件產生 VAPID 金鑰,以及加密和傳送通知。

導入作業

在這個步驟中,請為應用程式產生一對 VAPID 金鑰,並將這些金鑰新增至環境變數。在伺服器中載入環境變數,然後將公開金鑰新增為用戶端程式碼中的常數。

  1. 使用 web-push 程式庫的 generateVAPIDKeys 函式建立一對 VAPID 金鑰。

    server.js 中,移除以下程式碼行中的註解:

    server.js

    // Generate VAPID keys (only do this once).
    /*
     * const vapidKeys = webpush.generateVAPIDKeys();
     * console.log(vapidKeys);
     */
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    
  2. Glitch 重新啟動應用程式後,會將產生的金鑰輸出到 Glitch 介面中的 Node.js 記錄 (「不是」Chrome 控制台)。如要查看 VAPID 金鑰,請在 Glitch 介面中依序選取「Tools」->「Log」

    請務必從同一個金鑰組複製公開與私密金鑰!

    Glitch 會每次編輯程式碼時重新啟動應用程式,因此產生的第一組按鍵可能會隨著輸出內容而無法顯示。

  3. .env 中複製及貼上 VAPID 金鑰。以雙引號 ("...") 括住鍵。

    VAPID_SUBJECT 中,您可以輸入 "mailto:test@test.test"

    .env

    # process.env.SECRET
    VAPID_PUBLIC_KEY=
    VAPID_PRIVATE_KEY=
    VAPID_SUBJECT=
    VAPID_PUBLIC_KEY="BN3tWzHp3L3rBh03lGLlLlsq..."
    VAPID_PRIVATE_KEY="I_lM7JMIXRhOk6HN..."
    VAPID_SUBJECT="mailto:test@test.test"
    
  4. server.js 中,再次將這兩行程式碼註解排除,因為您只需要產生 VAPID 金鑰一次。

    server.js

    // Generate VAPID keys (only do this once).
    /*
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    */
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    
  5. server.js 中,從環境變數載入 VAPID 詳細資料。

    server.js

    const vapidDetails = {
      // TODO: Load VAPID details from environment variables.
      publicKey: process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY,
      subject: process.env.VAPID_SUBJECT
    }
    
  6. 請一併複製公開金鑰並貼到用戶端程式碼中。

    public/index.js 中為您複製到 .env 檔案的 VAPID_PUBLIC_KEY 輸入相同的值:

    public/index.js

    // Copy from .env
    const VAPID_PUBLIC_KEY = '';
    const VAPID_PUBLIC_KEY = 'BN3tWzHp3L3rBh03lGLlLlsq...';
    ````
    

實作傳送通知的功能

背景

在這個應用程式中,您將使用 web-push npm 套件傳送通知。

這個套件會在呼叫 webpush.sendNotification() 時自動加密通知,因此不必擔心。

web-push 接受多個通知選項,例如您可以將標頭附加至郵件,並指定內容編碼。

在本程式碼研究室中,您只能使用兩個選項,這些選項定義如下:

let options = {
  TTL: 10000; // Time-to-live. Notifications expire after this.
  vapidDetails: vapidDetails; // VAPID keys from .env
};

TTL (存留時間) 選項會設定通知的到期時間。這可讓伺服器避免在使用者不再需要時傳送通知給使用者。

vapidDetails 選項包含您從環境變數載入的 VAPID 金鑰。

導入作業

server.js 中,按照下列方式修改 sendNotifications 函式:

server.js

function sendNotifications(database, endpoints) {
  // TODO: Implement functionality to send notifications.
  console.log('TODO: Implement sendNotifications()');
  console.log('Endpoints to send to: ', endpoints);
  let notification = JSON.stringify(createNotification());
  let options = {
    TTL: 10000, // Time-to-live. Notifications expire after this.
    vapidDetails: vapidDetails // VAPID keys from .env
  };
  endpoints.map(endpoint => {
    let subscription = database[endpoint];
    webpush.sendNotification(subscription, notification, options);
  });
}

由於 webpush.sendNotification() 會傳回 promise,因此您可以輕鬆新增錯誤處理機制。

server.js 中,再次修改 sendNotifications 函式:

server.js

function sendNotifications(database, endpoints) {
  let notification = JSON.stringify(createNotification());
  let options = {
    TTL: 10000; // Time-to-live. Notifications expire after this.
    vapidDetails: vapidDetails; // VAPID keys from .env
  };
  endpoints.map(endpoint => {
    let subscription = database[endpoint];
    webpush.sendNotification(subscription, notification, options);
    let id = endpoint.substr((endpoint.length - 8), endpoint.length);
    webpush.sendNotification(subscription, notification, options)
    .then(result => {
      console.log(`Endpoint ID: ${id}`);
      console.log(`Result: ${result.statusCode} `);
    })
    .catch(error => {
      console.log(`Endpoint ID: ${id}`);
      console.log(`Error: ${error.body} `);
    });
  });
}

處理新訂閱項目

背景

以下是使用者訂閱推播通知時會發生的情況:

  1. 使用者按一下「訂閱推送」

  2. 用戶端會使用 VAPID_PUBLIC_KEY 常數 (伺服器的公開 VAPID 金鑰),產生伺服器專用的不重複 subscription 物件。subscription 物件如下所示:

       {
         "endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9...",
         "expirationTime": null,
         "keys":
         {
           "p256dh": "BNYDjQL9d5PSoeBurHy2e4d4GY0sGJXBN...",
           "auth": "0IyyvUGNJ9RxJc83poo3bA"
         }
       }
    
  3. 用戶端將 POST 要求傳送至 /add-subscription 網址,包括在內文中以字串化 JSON 格式訂閱的訂閱項目。

  4. 伺服器從 POST 要求的內文擷取字串化的 subscription,將字串剖析回 JSON,再將其新增至訂閱資料庫。

    資料庫會以自己的端點做為索引鍵,儲存訂閱項目:

    {
      "https://fcm...1234": {
        endpoint: "https://fcm...1234",
        expirationTime: ...,
        keys: { ... }
      },
      "https://fcm...abcd": {
        endpoint: "https://fcm...abcd",
        expirationTime: ...,
        keys: { ... }
      },
      "https://fcm...zxcv": {
        endpoint: "https://fcm...zxcv",
        expirationTime: ...,
        keys: { ... }
      },
    }

現在,伺服器可以取得新的訂閱項目,並傳送通知。

導入作業

新訂閱的要求會傳送至 /add-subscription 路徑,也就是 POST 網址。您會在 server.js 中看到虛設常式路徑處理常式:

server.js

app.post('/add-subscription', (request, response) => {
  // TODO: implement handler for /add-subscription
  console.log('TODO: Implement handler for /add-subscription');
  console.log('Request body: ', request.body);
  response.sendStatus(200);
});

在實作中,這個處理常式必須:

  • 從要求的主體擷取新訂閱。
  • 存取有效訂閱項目的資料庫。
  • 將新訂閱項目加入有效訂閱項目清單。

處理新訂閱項目的方式如下:

  • server.js 中,按照下列方式修改 /add-subscription 的路徑處理常式:

    server.js

    app.post('/add-subscription', (request, response) => {
      // TODO: implement handler for /add-subscription
      console.log('TODO: Implement handler for /add-subscription');
      console.log('Request body: ', request.body);
      let subscriptions = Object.assign({}, request.session.subscriptions);
      subscriptions[request.body.endpoint] = request.body;
      request.session.subscriptions = subscriptions;
      response.sendStatus(200);
    });

處理取消訂閱作業

背景

伺服器不一定能知道訂閱項目何時失效,例如瀏覽器關閉 Service Worker 後,可能會清除訂閱項目。

不過,伺服器可以找出透過應用程式 UI 取消的訂閱項目。在這個步驟中,您將實作從資料庫移除訂閱項目的功能。

如此一來,伺服器就能避免向不存在的端點發出大量通知。這對於使用簡單的測試應用程式顯然並不重要,但對大規模來說至關重要。

導入作業

取消訂閱的要求會傳送至 /remove-subscription POST 網址。

server.js 中的虛設常式路徑處理常式如下所示:

server.js

app.post('/remove-subscription', (request, response) => {
  // TODO: implement handler for /remove-subscription
  console.log('TODO: Implement handler for /remove-subscription');
  console.log('Request body: ', request.body);
  response.sendStatus(200);
});

在實作中,這個處理常式必須:

  • 從要求的主體中擷取已取消訂閱項目的端點。
  • 存取有效訂閱項目的資料庫。
  • 從有效訂閱項目清單中移除已取消的訂閱項目。

來自用戶端的 POST 要求主體包含您需要移除的端點:

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9..."
}

如何處理取消訂閱:

  • server.js 中,按照下列方式修改 /remove-subscription 的路徑處理常式:

    server.js

  app.post('/remove-subscription', (request, response) => {
    // TODO: implement handler for /remove-subscription
    console.log('TODO: Implement handler for /remove-subscription');
    console.log('Request body: ', request.body);
    let subscriptions = Object.assign({}, request.session.subscriptions);
    delete subscriptions[request.body.endpoint];
    request.session.subscriptions = subscriptions;
    response.sendStatus(200);
  });