建構推播通知伺服器

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

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

重混範例應用程式,並在新分頁中查看

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

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

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

在您學習本程式碼研究室的過程中,請變更本頁面內嵌 Glitch 中的程式碼。為執行中的應用程式重新整理新分頁,就會看到變更。

瞭解入門應用程式及其程式碼

我們先查看應用程式的用戶端 UI,

在新的 Chrome 分頁中:

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

  2. 請嘗試點選使用者介面中的按鈕 (請查看 Chrome Developer Console 的輸出內容)。

    • 註冊 Service Worker 會針對您的 Glitch 專案網址範圍註冊 Service Worker。取消註冊 Service Worker 會移除 Service Worker。如已附加推送訂閱項目,系統就會停用推送訂閱項目。

    • 訂閱推送會建立推送訂閱項目。只有在 Service Worker 註冊完成,且用戶端程式碼中有 VAPID_PUBLIC_KEY 常數時 (稍後會進一步說明),因此目前無法點選該常數。

    • 只要您有有效的推送訂閱項目,「通知目前的訂閱」要求伺服器就會傳送通知至其端點。

    • 「通知所有訂閱項目」會指示伺服器傳送通知給資料庫中的所有訂閱端點。

      請注意,其中某些端點可能處於停用狀態。在伺服器傳送通知給訂閱時,訂閱項目可能就消失了。

讓我們來看看伺服器端發生的問題。如要查看伺服器程式碼傳來的訊息,請在 Glitch 介面中查看 Node.js 記錄檔。

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

    您可能會看到類似 Listening on port 3000 的訊息。

    如果您嘗試在實際應用程式的使用者介面中點選「通知目前的訂閱」或「通知所有訂閱項目」,還會看到下列訊息:

    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 會使用公開金鑰密碼編譯技術來驗證應用程式、伺服器和訂閱端點的身分,並加密通知內容。

在這個應用程式中,您將使用網頁推送 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 介面中的記錄檔

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

    每當您編輯程式碼,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 金鑰並貼到用戶端程式碼中。

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

    public/index.js

    // Copy from .env
    const VAPID_PUBLIC_KEY = '';
    const VAPID_PUBLIC_KEY = 'BN3tWzHp3L3rBh03lGLlLlsq...';
    ````
    
,瞭解如何調查及移除這項存取權。

實作傳送通知的功能

背景

在這個應用程式中,您將使用網頁推送 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() 會傳回承諾值,因此您可以輕鬆新增錯誤處理機制。

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);
  });
敬上