iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Build on AWS

從一個網站的誕生,看懂 AWS 架構與自動化的全流程!系列 第 25 篇

Day 25 即時互動新體驗:API Gateway WebSocket 實現聊天與提醒

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20251009/20172743hVpySYXAew.png

一、前言

傳統的 REST API 屬於請求-回應模式,無法即時主動推送訊息。但在會員系統中,聊天室、即時提醒(如新公告、好友上線通知)都需要即時互動能力。利用 API Gateway WebSocket,可以讓後端主動推播事件給前端,帶來更流暢的使用者體驗。

這個 Lab 解決的痛點是「即時性不足」。若僅依賴輪詢(Polling),會增加成本與延遲;而 WebSocket 提供長連線方式,讓系統能立即廣播通知或訊息。在 Serverless 架構中,API Gateway WebSocket 搭配 Lambda 與 DynamoDB,可打造一個無伺服器聊天室與提醒系統,擁有高擴展性與低維運成本。

二、需要使用到的服務

  • Amazon API Gateway (WebSocket API):管理長連線,處理連線/斷線事件。
  • AWS Lambda:處理訊息收發邏輯(如廣播、單點推送)。
  • Amazon DynamoDB:儲存連線 ID 與會員對應關係。
  • CloudWatch Logs:監控連線與訊息傳遞狀況。

三、架構/概念圖

https://ithelp.ithome.com.tw/upload/images/20251009/20172743pvJv6XMGDd.png

四、技術重點

  1. 使用 DynamoDB TTL 自動清理過期的連線紀錄。
  2. 針對廣播訊息,考慮分批推送,避免 API Gateway 限速。
  3. 可以設計 多聊天室 Room 概念,在 DynamoDB 中加上 roomId 區分訊息群組。
  4. 加入 身分驗證(Cognito JWT 驗證),避免未授權使用者連線。

五、Lab流程

1️⃣ 前置作業

無

2️⃣ 主要配置

1. 建立 DynamoDB 資料表 Connections,主鍵為 connectionId

💡由於同樣的user可能會透過不同裝置有不同的「Connection ID」,所以建議會用主鍵為「Connection ID」的DynamoDB來作為連線的依據。

  1. 進入「DynamoDB」頁面。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743Z1AoFXYGLo.png

  2. 創建一個新的資料表。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743MSRzKmE228.png

  3. 設定名稱及主鍵。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743e2R6fIrLaj.png
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743t70yCLuKUm.png

2. 創建Lambda用的IAM Role

  1. 進入「IAM」頁面。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743ife2YgUErK.png

  2. 創建一個新的IAM Role。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743F1lG15YoEF.png

  3. 選擇用途為「AWS服務」。(稍後需要用於3個Lambda)
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743sofrZYxUwa.png

  4. 選擇政策「AWSLambdaBasicExecutionRole」,並點選「下一步」。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743QfNLh5943a.png

  5. 設定角色名稱,並點選創建。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743kbKX3afz4P.png
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743KNqkSCe9Yi.png

  6. 重新進到該IAM Role中。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743weAmT0jOyF.png

  7. 添加「許可政策」。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743VCkCg0RUWW.png

  8. 添加以下權限。
    dynamodb:PutItem
    dynamodb:DeleteItem
    dynamodb:Scan
    execute-api:ManageConnections
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743eOdfdC8HrH.png

    https://ithelp.ithome.com.tw/upload/images/20251009/201727433beYh5rCFV.png

    • DynamoDB的ARN在哪裡?
      https://ithelp.ithome.com.tw/upload/images/20251009/201727433jArTK9I1V.png
  9. 點選下一步。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743U3oMmuaO7A.png

  10. 命名政策。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743lmTUJfaJcl.png

  11. 完成畫面。
    https://ithelp.ithome.com.tw/upload/images/20251009/201727430WA3bJl5Lc.png

3. 創建3個Lambda函數(控制Web Socket聊天的程式)

  1. 進入「Lambda」頁面。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743pKySOVvRNe.png

  2. 創建3個新的函數。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743bB2rXaDoYk.png

  3. 輸入函數名稱,並選擇編撰語言。名稱分別為:OnConnect、OnDisconnect、OnMessage
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743DNS9LT4K00.png

  4. 跳過建議畫面。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743ZAx3vU3iLL.png

  5. 寫入程式碼、部署,並新增環境變數。

    (1) 程式碼 - OnConnect

    • 程式碼範例-OnConnect

      // index.mjs (或 index.js,取決於您的 Lambda 執行模式設定)
      import AWS from 'aws-sdk';
      
      const DYNAMODB = new AWS.DynamoDB.DocumentClient();
      const TABLE_NAME = process.env.TABLE_NAME;
      
      export const handler = async (event) => {
          // 從連線事件中取得連線 ID
          const connectionId = event.requestContext.connectionId;
      
          try {
              await DYNAMODB.put({
                  TableName: TABLE_NAME,
                  Item: { 
                      connectionId: connectionId,
                      // 可選:設定 TTL (Time-To-Live) 欄位來自動清理過期連線
                  }
              }).promise();
      
              return { statusCode: 200, body: 'Connected.' };
          } catch (err) {
              console.error('Connection error:', err);
              return { statusCode: 500, body: 'Failed to connect.' };
          }
      };
      

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743vXUPEgDUcq.png
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743J1iFCbUbEL.png

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743tCQNbvpxYO.png

    環境變數:「TABLE_NAME:DynamoDB 資料表名稱」。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743s84IeOF8bq.png

    (2) 程式碼 - OnDisconnec

    • 程式碼範例-OnDisconnec

      // index.mjs (OnDisconnectLambda - 統一使用 AWS SDK v3)
      
      // 1. 匯入必要的 SDK v3 模組
      import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
      import { DynamoDBDocumentClient, DeleteCommand } from "@aws-sdk/lib-dynamodb";
      
      const TABLE_NAME = process.env.TABLE_NAME;
      
      // 2. 初始化客戶端 (在 Handler 外部初始化以重複使用,降低冷啟動時間)
      const client = new DynamoDBClient({});
      // 使用 DocumentClient 讓操作更容易,處理 JavaScript 物件
      const DYNAMODB = DynamoDBDocumentClient.from(client); 
      
      export const handler = async (event) => {
          // 從斷線事件中取得連線 ID
          const connectionId = event.requestContext.connectionId;
      
          try {
              // 3. 使用 v3 的 DeleteCommand 進行刪除操作
              await DYNAMODB.send(new DeleteCommand({
                  TableName: TABLE_NAME,
                  Key: { connectionId: connectionId } // v3 DocumentClient 會自動處理 Key 類型
              }));
      
              return { statusCode: 200, body: 'Disconnected.' };
          } catch (err) {
              console.error('Disconnection error:', err);
              return { statusCode: 500, body: 'Failed to disconnect.' };
          }
      };
      

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743TQUalhMlF2.png
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743q4IQN2IRNg.png

    環境變數:「TABLE_NAME:DynamoDB 資料表名稱」
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743MyT8eC0Xex.png

    (3) 程式碼 - OnMessage

    • 程式碼範例-OnMessage

      // index.mjs (使用 AWS SDK v3)
      
      // 1. 匯入必要的模組
      import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
      // 引入 ScanCommand 和 DeleteCommand
      import { DynamoDBDocumentClient, DeleteCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; 
      // 引入 v3 的 APIGW 模組
      import { ApiGatewayManagementApiClient, PostToConnectionCommand } from "@aws-sdk/client-apigatewaymanagementapi"; 
      
      const TABLE_NAME = process.env.TABLE_NAME;
      const WS_ENDPOINT = process.env.WS_ENDPOINT;
      
      // 2. 初始化客戶端
      // DynamoDB Document Client
      const dbClient = new DynamoDBClient({});
      const DYNAMODB = DynamoDBDocumentClient.from(dbClient);
      
      // API Gateway Management Client (Endpoint 必須在初始化時傳入)
      const APIGW_MANAGEMENT = new ApiGatewayManagementApiClient({
          endpoint: WS_ENDPOINT 
      });
      
      /**
       * 嘗試向單個連線推送數據。
       * 如果連線過期 (410),則觸發背景清理。
       * 無論推播或清理失敗,此函數都不會拋出錯誤 (throw error),以確保整體廣播的健壯性。
       * @param {string} connectionId 
       * @param {any} data 
       */
      const sendToOne = async (connectionId, data) => {
          try {
              // 使用 v3 的 PostToConnectionCommand
              await APIGW_MANAGEMENT.send(new PostToConnectionCommand({
                  ConnectionId: connectionId,
                  Data: data,
              }));
          } catch (error) {
              // 處理連線可能已過期 (410 Gone)
              if (error.name === 'GoneException' || error.statusCode === 410) { 
                  console.log(`Found stale connection: ${connectionId}`);
      
                  // 💡 關鍵修正 1: 移除 await,讓清理作業在背景執行 (避免超時)
                  // 💡 關鍵修正 2: 使用 .catch() 捕獲並忽略清理過程中的任何錯誤 (如 AccessDeniedException)
                  DYNAMODB.send(new DeleteCommand({
                      TableName: TABLE_NAME,
                      Key: { connectionId: connectionId }
                  })).catch(dbError => {
                       // 記錄錯誤,但不將其向上拋出,確保 Promise 不會被拒絕
                       console.error(`ERROR: Failed to delete stale connection ${connectionId}:`, dbError);
                  });
      
                  // 這裡不拋出錯誤,讓 Promise 被視為已解決 (Resolved)
      
              } else {
                  // 處理非 410 的其他錯誤 (如權限不足、連線池問題等)
                  console.error(`Error sending message (non-410) to ${connectionId}:`, error);
                  // 🚨 關鍵修正 3: 移除 throw error。
                  // 確保單個推播失敗不會導致 Promise.allSettled 失敗,
                  // 最終使 handler 成功返回 200,解決前端顯示 500 錯誤的問題。
              }
          }
          // sendToOne 函式現在保證不會拋出錯誤,讓上層的 Promise.allSettled 順利完成
      };
      
      export const handler = async (event) => {
          try {
              // 🚨 步驟 1: 新增檢查,確保 body 存在 🚨
              if (!event.body) {
                  console.log("Received a non-body event or empty message. Ignoring.");
                  // 確保返回 200
                  return { statusCode: 200, body: 'Empty body ignored.' };
              }
      
              // 2. 解析傳入的訊息
              const body = JSON.parse(event.body);
      
              // 💡 直接使用純文字內容作為廣播訊息
              const messageString = body.message || 'No message provided'; 
      
              // 3. 掃描所有連線 ID (使用 v3 的 ScanCommand)
              const scanResult = await DYNAMODB.send(new ScanCommand({
                  TableName: TABLE_NAME,
                  ProjectionExpression: 'connectionId'
              }));
              const connections = scanResult.Items;
      
              // 4. 廣播訊息給所有連線
              const postCalls = connections.map(conn => 
                  // 💡 將純文字字串傳遞給 sendToOne
                  sendToOne(conn.connectionId, messageString) 
              );
      
              // 等待所有推播嘗試完成。由於 sendToOne 不拋出錯誤,這裡將只等待完成,而不是失敗。
              await Promise.allSettled(postCalls);
      
              return { statusCode: 200, body: 'Data sent.' };
      
          } catch (err) {
              // 只有 DynamoDB Scan 或 JSON.parse 等無法恢復的錯誤才會到達這裡
              console.error('Broadcasting fatal error:', err);
              return { statusCode: 500, body: 'Failed to process message.' };
          }
      };
      

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743jmEVpW06hq.png
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743NjondpWf5k.png

    環境變數:

    TABLE_NAME:DynamoDB 資料表名稱

    WS_ENDPOINT:部署 API Gateway WebSocket API 後所得到的 WebSocket URL(wss://{API_ID}.execute-api.{REGION}.amazonaws.com/dev)

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743AZWXF3wshS.png

    • WebSocket URL在哪?(要等到下一步創建完API Gateway後,再回來填)
      https://ithelp.ithome.com.tw/upload/images/20251009/20172743Rn5XjNgKXX.png
      調整超時時數:
      https://ithelp.ithome.com.tw/upload/images/20251009/201727439tKCHENzjq.png

三個Lambda分別的功能為:
https://ithelp.ithome.com.tw/upload/images/20251009/20172743CeESjUyQUE.png

4. 建立API Gateway WebSocket API

  1. 進入「API Gateway」頁面。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743ycrsY87xCk.png

  2. 創建一個新的API Gateway。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743AHhtEdkAdE.png

  3. 創建類型選擇WebSocket API。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743QpgNgezhJW.png

  4. 設定名稱及路由選擇表達式「$request.body.action」。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743F3hn8s5DR7.png

  5. 設定三個路由路徑。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743GDyEbRZ0ex.png

  6. 分別選擇剛剛創建的Lambda。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743fuBrGmi8M5.png

  7. 命名階段,並按下一步。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743uMDZiuNHre.png

  8. 完成創建。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743s93r9oTwjm.png

  9. 部署API。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743PzLt7E1ybA.png

  10. 輸入部署路徑,並確認部署。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743CkW6cEr4QR.png

3️⃣ 測試驗證

1. 下載wscat工具

  1. 安裝node

    brew install node
    
  2. 查看node版本

    node -v
    

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743hAjRf8NqRF.png

  3. 下載wscat

    npm install -g wscat
    

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743v2rpUZtbnT.png

2. 使用 wscat 測試連線:

# 替換為您部署階段的 WebSocket URL
wscat -c wss://{api-id}.execute-api.{region}.amazonaws.com/dev
  1. 開啟多個終端機:在兩個或多個終端機中執行上述 wscat 連線。

  2. 發送訊息:在其中一個終端機輸入訊息:

    > {"message": "Hello Everyone! (Test Broadcast)"}
    

    https://ithelp.ithome.com.tw/upload/images/20251009/20172743EYDENEujOE.png

3. 驗證結果:

  • 檢查所有連線:所有連線的終端機都應該即時收到 {"msg": "Broadcast: Hello Everyone! (Test Broadcast)"} 的推播訊息。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743HdNzANUN6o.png

  • 檢查 DynamoDB:會看到「回傳目前連線數量」,確認 Connections 表格中是否新增了連線 ID。當您關閉 wscat 時,連線 ID 應被移除。
    https://ithelp.ithome.com.tw/upload/images/20251009/20172743dDkQJwudZc.png

六、結語

今天的 Lab 展示了如何利用 API Gateway WebSocket 搭配 Lambda + DynamoDB,打造一個即時聊天與提醒系統。這不僅解決了 REST API 無法即時推送的痛點,也提供了可擴充的基礎,未來能延伸到通知系統、多人協作工具甚至遊戲對戰平台,讓 Serverless 架構更具互動性。


上一篇
Day 24 多管道通知整合:SNS x Lambda 打造訊息廣播系統(LINE)
下一篇
Day 26 服務健康監測:CloudWatch x Alarms 掌握關鍵指標
系列文
從一個網站的誕生,看懂 AWS 架構與自動化的全流程! 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言