Originally published on Medium on November 11, 2023.

在本指南中,我們將探討使用 AWS Lambda 與 NestJS 在分散式系統中進行通訊。

延續我們在先前探索中,將 NestJS 與 AWS Lambda 整合並在 Docker 中設定除錯與熱重載,本指南邁出下一個邏輯步驟。我們將使用相同的基礎設定,並加以強化以實現 Lambda 之間的無縫通訊。

本指南中的方法受到過去使用 Dapr 搭配 C# 微服務的專案啟發。Dapr 簡化了建置微服務所面臨的挑戰。例如,Dapr 提供直接的方法來設定服務間通訊,而非手動操作。這讓開發人員能專注於撰寫實際功能,而無需陷入服務間通訊的技術細節。此 Lambda + NestJS 通訊策略深受 Dapr 的簡潔與效能啟發。

若您有興趣進一步了解 Dapr 與 C#,請參閱我的 其他存放庫

本指南聚焦於同步請求,我將在後續指南中介紹使用 SQS 的非同步請求。

您可以在此存放庫中找到示範應用程式https://github.com/VenyaBrodetskiy/Lambda-NestJS-Demo

該示範包含:

  • 依照先前指南設定為可在本地運作的應用程式。
  • 實作 Lambda 之間的通訊,使其能在本地及部署後正常運作(本指南重點)
  • 如何在您的解決方案中加入佇列(SQS)

在後續章節中,我們將逐步說明建立 Lambda 之間通訊的步驟:

  1. 了解簡化 Lambda 通訊的服務設計
  2. 建立 Lambda 通訊服務
  3. 使用重試機制強化 Lambda 通訊服務
  4. 實作 Lambda Factory 以支援本地與雲端環境

讓我們開始吧!

第一部分:了解簡化 Lambda 通訊的服務設計

概念:目標是建立一個封裝複雜邏輯的服務,使其如同 Dapr 一樣易用且直觀。簡言之,這個服務扮演橋樑角色,簡化系統不同元件之間的通訊。

實務範例:讓我們透過一個簡單的 NestJS 控制器來說明此服務的運作方式。此控制器處理外部請求(例如來自前端應用程式或 Postman),並與另一個 Lambda 函數通訊以從資料庫取得資料。

以下是 PlanController 的程式碼:

import { LambdaCommunicationService } from 'src/core/modules/communication';

@Controller('plan')
export class PlanController {
  private readonly logger = new Logger(PlanController.name);
  constructor(
    private readonly lambdaService: LambdaCommunicationService,
  ) {}

  @Get('/:id')
  public async getPlanById(@Param('id') id: string): Promise<PlanRes> {
    this.logger.log(`Inside ${this.getPlanById.name}, id: ${id}`);

    // service to call other lambda
    const result: PlanRes = await this.lambdaService.invoke<PlanRes>(
      Accessor.Plan,          // name of lambda to be called. `Accessor` is an enum representing different lambda functions
      `/planaccessor/${id}`,  // path of request
      HttpMethod.Get,         // the HTTP method for the request
    );
    return result;
  }

Enter fullscreen mode Exit fullscreen mode

lambdaService.invoke 方法的簽章:

LambdaCommunicationService.invoke<TResponse>(
  service: string, 
  path: string, 
  httpMethod?: HttpMethod, 
  payload?: object): Promise<TResponse>

Enter fullscreen mode Exit fullscreen mode

  • service:識別要呼叫的 Lambda 函數名稱。
  • path:指定請求路徑。
  • httpMethod:請求使用的 HTTP 方法,若未指定則預設為 GET。
  • payload:選擇性物件,包含要傳送至 Lambda 的資料。

優點:透過此類服務,呼叫其他 Lambda 變得簡單明瞭。您只需提供函數名稱、呼叫路徑、方法,以及必要時的酬載。

此設計大幅縮短開發時間,因為無需撰寫並維護複雜的 Lambda 互動程式碼。它也透過標準化服務通訊方式來減少錯誤,確保一致性與可靠性。此外,此類服務提升程式碼可讀性與可維護性,讓團隊更容易理解並長期修改程式碼庫。

關於錯誤處理的注意事項: 您可能注意到缺少 try-catch 區塊。這是因為 NestJS 內建例外過濾器,能優雅地處理錯誤。您可以在 NestJS 例外過濾器文件中了解更多此功能。此外,我的 Lambda-NestJS 示範存放庫 包含自訂例外過濾器的實作,不過這已超出本指南範疇。

第二部分:建立 Lambda 通訊服務

在本部分,我們將實作第一部分所述的服務:

@Injectable()
export class LambdaCommunicationService {
  private readonly logger = new Logger(LambdaCommunicationService.name);
  constructor(private lambdaFactory: LambdaFactory) {}

  public async invoke<TResponse>(
    service: string,
    path: string,
    httpMethod: HttpMethod = HttpMethod.Get,
    payload?: object,
  ): Promise<TResponse> {
    try {
      // get instance of lambda object
      const { lambda, functionName } = this.lambdaFactory.getLambda(service);

      // prepare payload
      const lambdaPayload: ICommunicationPayload = {
        httpMethod: httpMethod,
        path: path,
        body: payload ?? undefined,
        headers: {
          'Content-Type': 'application/json',
        },
      };

      const params: InvokeCommandInput = {
        FunctionName: functionName,
        Payload: JSON.stringify(lambdaPayload),
      };

      this.logger.debug(
        `Inside ${this.invoke.name}. Invoking function: ${params.FunctionName} with payload: ${params.Payload}`,
      );

      // call other lambda using aws-sdk
      const response: InvokeCommandOutput = await lambda.invoke(params);

      // handle the response
      const responsePayload = JSON.parse(Buffer.from(response.Payload).toString());
      if (RetriableStatusCodes.includes(responsePayload.statusCode)) {
        throw new CommunicationException(
          JSON.parse(responsePayload.body),
          responsePayload.statusCode,
        );
      }

      this.logger.debug(
        `Inside ${this.invoke.name}. Lambda invoke response payload: ${JSON.stringify(
          responsePayload,
          null,
          ' ',
        )}`,
      );

      // parse the response to expected type
      if (typeof responsePayload.body === 'string' && this.isJsonString(responsePayload.body)) {
        const result = JSON.parse(responsePayload.body) as TResponse;
        return result;
      }

      return responsePayload.body as TResponse;
    } catch (error: any) {
      if (error.code === 'ECONNREFUSED')
        throw new CommunicationException(
          `Failed to invoke lambda: ${service}`,
          HttpStatus.INTERNAL_SERVER_ERROR,
        );
      throw error;
    }
  }

  private isJsonString(str: string): boolean {
    try {
      JSON.parse(str);
      return true;
    } catch (e) {
      return false;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

現在我將逐一拆解上述程式碼並加以說明。

  1. 第一步,我們使用 AWS SDK 建立 Lambda 物件。此物件負責呼叫其他 Lambda 函數。同時,我們也取得要呼叫的特定函數名稱。此 Lambda 物件的建立與管理由 LambdaFactory 有效處理。了解 LambdaFactory 的運作方式是我們實作的關鍵,將在後續章節(第四部分)中詳細探討:
const { lambda, functionName } = this.lambdaFactory.getLambda(service);

Enter fullscreen mode Exit fullscreen mode

2. 下一步是建立 Lambda 酬載與呼叫參數,並呼叫 Lambda:

const lambdaPayload: ICommunicationPayload = {
  ...
};

const params: InvokeCommandInput = {
  ...
};

const response: InvokeCommandOutput = await lambda.invoke(params);

Enter fullscreen mode Exit fullscreen mode

3. 處理回應

這是關鍵步驟。我們需要了解 responsePayload 中的內部狀態碼,以判斷呼叫是否成功:

const responsePayload = JSON.parse(Buffer.from(response.Payload).toString());

if (RetriableStatusCodes.includes(responsePayload.statusCode)) {
  throw new CommunicationException(
    ...
  );
}

Enter fullscreen mode Exit fullscreen mode

4. 解析回應

Lambda 的回應可能是開發人員定義的 TResponse 型別物件或字串。以下是我們的處理方式:

if (typeof responsePayload.body === 'string' && this.isJsonString(responsePayload.body)) {
  const result = JSON.parse(responsePayload.body) as TResponse;
  return result;
}

return responsePayload.body as TResponse;

Enter fullscreen mode Exit fullscreen mode

這段程式碼會檢查回應中 body 的型別,並據以解析。透過 TypeScript,我們可將回應轉型為開發人員指定的 TResponse 型別。

第三部分:使用重試機制強化 Lambda 通訊服務

在本部分,我們將為 Lambda 通訊服務加入重試功能。重試失敗請求的能力是一項關鍵功能,特別適用於處理暫時性網路問題或服務暫時無法使用的情境。

注意。然而,必須謹慎選擇 HTTP 方法,因為它們具有等冪性特質。GET 與 DELETE 主要適用於重試,因為重複執行不會造成導致副作用的狀態改變。PUT 與 PATCH 也可考慮用於重試,因為它們設計為等冪,確保重複請求會產生相同狀態。但 POST 請求需謹慎處理,因為它們通常會修改狀態或建立資源,若在未妥善處理等冪性的情況下重試,可能導致非預期的後果。

重試應針對指出暫時性問題或伺服器錯誤的狀態碼,此類錯誤重複請求可能成功。一般而言,5xx 系列錯誤(例如 500 Internal Server Error、502 Bad Gateway、503 Service Unavailable、504 Gateway Timeout)適合重試,因為它們表示伺服器端的暫時性問題,但 501 Not Implemented 不建議重試,因為此錯誤表示伺服器的永久性限制,後續重試不太可能成功。至於 4xx 系列錯誤(例如 400 Bad Request、401 Unauthorized、404 Not Found),它們通常表示用戶端問題,若未修改請求就重試不太可能解決。不過,408 Request Timeout、423 Locked 與 429 Too Many Requests 等暫時性錯誤是例外,使用退避策略重試可能成功。

為了整合此功能,我們將使用 async-retry npm 套件,它提供簡單的方式來實作重試邏輯。Lambda 通訊服務中的 invoke 方法將增強如下:

  1. 加入重試參數:invoke 方法現在包含額外的 retries 參數,預設值為 3。此參數決定請求的最大重試次數。
public async invoke<TResponse>(
    service: string,
    path: string,
    httpMethod: HttpMethod = HttpMethod.Get,
    payload?: object,
    retries: number = 3,  // new parameter
  ): Promise<TResponse> {

Enter fullscreen mode Exit fullscreen mode

2. 條件式重試邏輯:我們加入檢查,只對等冪請求啟用重試。對於其他 HTTP 方法,有效重試次數設為零。

// enable retries only for Retriable Http Methods requests
let effectiveRetries;
if (RetriableHttpMethods.includes(httpMethod)) {
  effectiveRetries = retries;
} else {
  effectiveRetries = 0;
}

Enter fullscreen mode Exit fullscreen mode

3. 重試機制:使用 async-retry 套件的 retry 函數,我們將實際的 Lambda 呼叫邏輯包裝起來。此函數將根據提供的重試條件自動重試呼叫。

let responsePayload;
await retry(
  async () => {
    this.logger.debug(
      `Inside ${this.invoke.name}. Invoking function: ${params.FunctionName} with payload: ${params.Payload}`,
    );

    const response = await lambda.invoke(params);
    responsePayload = JSON.parse(Buffer.from(response.Payload).toString());

    if (RetriableStatusCodes.includes(responsePayload.statusCode)) {
      throw new CommunicationException(
        JSON.parse(responsePayload.body),
        responsePayload.statusCode,
      );
    }
  },
  // retry configuration
  {
    retries: effectiveRetries,
    onRetry: (error) => {
      error &&
        this.logger.warn(`Error while calling lambda: ${params.FunctionName} with payload: ${
          params.Payload
        }, retrying...
        Error: ${JSON.stringify(error, null, ' ')}`);
    },
  },
);

Enter fullscreen mode Exit fullscreen mode

重試期間的錯誤處理:若呼叫嘗試期間發生錯誤,將產生錯誤記錄。這有助於監控與除錯與失敗 Lambda 呼叫相關的問題。

透過實作此重試機制,我們確保 Lambda 通訊服務更加穩健,並能更優雅地處理暫時性失敗,進而提升系統整體可靠性。

第四部分:實作 Lambda Factory 以支援本地與雲端環境

在前一節中,我們在 Lambda 通訊服務中引入了 LambdaFactory。

const { lambda, functionName } = this.lambdaFactory.getLambda(service);

Enter fullscreen mode Exit fullscreen mode

現在,讓我們探討其重要性,以及如何實作以確保在本地與雲端都能順利運作。

LambdaFactory 的角色是抽象化 AWS Lambda 實例的建立與設定。此抽象化至關重要,因為它讓我們的應用程式能動態適應不同環境(本地開發或雲端部署),而無需變更核心商業邏輯。

1. 了解 LambdaFactory 的實作

讓我們檢視 LambdaFactory 服務的主要元件。

lambda-factory.service.ts:

...
import { Configuration } from 'src/config';

interface ILambdaClient {
  lambda: Lambda;
  functionName: string;
}

@Injectable()
export class LambdaFactory {
  private lambdaInstance: Lambda;

  constructor(private config: Configuration) {}

  public getLambda(service: string): ILambdaClient {
    // get function name and endpoint from configuration
    const { name: functionName, endpoint: endpoint } = this.config.getService(service);

    // for cloud
    if (!this.config.IsOffline) {
      this.lambdaInstance = this.lambdaInstance ?? new Lambda({});

      return {
        lambda: this.lambdaInstance,
        functionName: functionName,
      };
    }

    // for local development
    this.lambdaInstance = new Lambda({
      endpoint: endpoint,
    });

    return {
      lambda: this.lambdaInstance,
      functionName: functionName,
    };
  }
}

Enter fullscreen mode Exit fullscreen mode

LambdaFactory 服務中的 getLambda 方法是根據 Configuration 服務的判斷,為本地開發或雲端部署設定 Lambda 用戶端實例的關鍵。此方法至關重要的兩個原因如下:

  1. 本地開發:對於本地測試,特別是使用 Serverless 框架時,每個 Lambda 函數通常需要獨立的端點。因此,此方法會為每次呼叫建立新的 Lambda 物件,確保準確的本地模擬。
  2. 雲端部署:在雲端,Lambda 函數由其名稱識別,而非端點。此處,此方法遵循單例模式,重複使用相同的 Lambda 實例以最佳化效能。

此方法確保不同環境間的彈性與一致性,簡化開發與部署流程。

2. 了解 Configuration 服務

在 LambdaFactory 中,我們使用 Configuration 服務的 getService 方法,根據已定義的列舉取得 LambdaCommunicationService 的特定設定。

configuration.service.ts:

@Injectable()
export class Configuration {
  constructor(private configService: ConfigService) {
    this.validateConfig();
  }

  get IsOffline(): boolean {
    return Boolean(this.configService.get<boolean>('IS_OFFLINE'));
  }

  public getService(service: string): IAcccessorConfig {
    try {
      switch (service) {
        case Accessor.Plan:
          return {
            name: this.configService.getOrThrow<string>('PLANACCESSOR_NAME'),
            endpoint: this.configService.getOrThrow<string>('PLANACCESSOR_ENDPOINT'),
          };
        // case Accessor.OtherAccessor:
        // ...
        default:
          throw new Error(
            `Unknown accessor type. Configuration.service misses accessor: ${service}`,
          );
      }
    } catch (e) {
      throw new Error(e);
    }
  }
  private validateConfig(): void {
    // when running application, this function checks that developer didn't forget to add necessary configs to configuration.service (mostly for enums)

    // Validate accessor configurations
    for (const accessor of Object.values(Accessor)) {
      this.getService(accessor as Accessor);
    }

    // Validate queue configuration not missed
    for (const queue of Object.values(Queue)) {
      this.getQueue(queue as Queue);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

您需要執行一些操作才能從使用 Configuration 服務中獲益:

  • 定義列舉:開發人員需要為不同服務建立列舉,以簡化設定擷取。
  • 維護 .env 檔案:他們必須設定並更新 .env 檔案,包含所有必要變數。

錯誤處理與驗證:此服務在 getService 方法中包含錯誤處理,並提供 validateConfig 方法以確保所有設定正確且完整。

3. 使用 LambdaFactory 的主要優點

總結來說,LambdaFactory 是一個強大的模式,能簡化環境特定設定的管理,並提升我們 Lambda 通訊服務的穩健性,使其能適應本地與雲端環境。讓我們總結此方法的優點:

  1. 環境無關:LambdaFactory 能在本地與雲端設定之間無縫切換,提升開發人員生產力並減少環境特定錯誤。
  2. 設定彈性:透過集中管理 Lambda 設定,可輕鬆更新與維護服務設定,而無需變更核心邏輯。
  3. 提升可讀性:清晰的職責分離與環境特定細節的抽象化,使程式碼更具可讀性與可維護性。

結論

當我們完成這份關於使用 NestJS 改善 AWS Lambda 通訊的指南時,我們已深入探討建立一個能在本地開發與雲端部署皆順利運作的通訊系統。

從了解服務設計、建立穩健的 Lambda 通訊服務,到實作 Lambda Factory 以適應環境,本指南已提供掌握分散式系統中 Lambda 通訊的完整途徑。

🔗 探索示範存放庫,查看所討論概念的實際實作。

🔍 若您想了解最初的起點,請重溫本系列探索的第一部分:「使用 AWS Lambda 與 NestJS 進行本地開發:Docker、除錯與熱重載」

🤝 您的回饋至關重要!歡迎留下評論、提出問題,或分享您的見解與最佳化。每項貢獻都有助於提升我們的集體知識,並建立資源豐富的開發者社群。

祝您編碼愉快,並期待在後續指南中探索使用 SQS 的非同步請求!🚀