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 架構與自動化的全流程!26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言