Serverless Operations, inc

>_cd /blog/id_ol6dnv4yae4

title

AWS LambdaのNode.jsを使った開発の基礎知識

summary

本記事ではAWS LambdaをNode.jsで開発する場合に必要となる基礎知識やツールを紹介しています。どのようなプログラミング言語であっても、パッケージマネージャーやフレームワークなど、開発を進めていくために必要な基礎概念は大きく変わりません。しかし、ツールやそれぞれの細かい部分でのプラクティスやトレンドは言語によって変わります。特にNode.jsは世界的にも人気のあるプログラミング言語です。多くのクラウドベンダーが、FaaSをリリースする際にはNode.jsのラインタイムを先にリリースします。世界的に一番メジャーな言語と言って過言はないでしょう。弊社でも開発のご支援を行う際はpythonがNode.jsのどちらかをお客様が選定されることが多いです。本記事を読んで、AWS LambdaをNode.jsで開発する際のトレンドと必要なツールを把握してもらえればと思います。

TypeScriptを使うべきかどうか?

現時点ではそのままのNode.jsを使うよりもTypeScriptを使った方が良いでしょう。TypeScriptは静的型付け言語であり、コンパイル時に型の不整合を検出してくれます。また、最近ではVSCodeなどのエディタやIDEを使用すれば開発しながら型のエラーや変数名の間違いの検出や補完をしてくれるため非常に生産性が高くなります。例えば、以下の関数は引数にnumber型を、返り値もnumber型が返ってくるように型を宣言しています。もし、文字列の"10"を渡すとIDEがその場でエラーを表示してくれるため、すぐに修正が可能です。

function add(a: number, b: number): number {
  return a + b;
}

console.log(add(5, "10"));

JavaScriptのように、実行するまでエラーが分からないといった問題がなくなり、大量のコードを書いた後にエラーを必死に探す手間も減ります。さらに型システムがあることで自然とソースコードも読みやすくなり大人数での大規模開発もやりやすくなります。初学者の方には型のシステムに慣れるのに時間がかかってしまうかもしれませんが一度慣れてしまえば大きな生産性の向上につながるでしょう。

パッケージマネージャー

どの言語でもパッケージマネージャーを導入してアプリケーション開発に使うライブラリやツールの管理をすると思いますが、Node.jsの場合はnpmpnpm を使うのがベターな選択でしょう。

npmによるパッケージ管理

npmは最も歴史の古いパッケージ管理システムです。npm でパッケージをインストールすると、プロジェクト内の node_modules/ フォルダに保存されます。そして、package.json ファイルにてライブラリの依存関係や実行したいスクリプトを定義します。

{
  "name": "my-project",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "jest": "^27.0.6"
  },
  "scripts": {
    "start": "node app.js",
    "test": "jest"
  }
}

以下のコマンドでライブラリのインストールや削除など行います。これらのコマンドを把握していればnpmを使いこなす上でそんなに困ることは無いでしょう

npm i # npm install
npm ci # (npm clean install) package-lock.json に明示されているバージョン情報をもとにインストールする
npm i package-name # 通常のインストール
npm i -g package-name # グローバルインストール
npm i --save-dev package-name # 開発用依存関係 (devDependencies) に追加
npm uninstall package-name # パッケージの削除
npm run script-name # package.json の "scripts" に定義したコマンドを実行

また、package-lock.json は依存関係のあるパッケージのバージョンの詳細までを記載したファイルです。これをGitにコミットしておくことで、別の開発者がnpm installを行うとpackage-lock.jsonにあるバージョンのライブラリをインストールしてくれます。これにより正確にバージョン単位で同じパッケージがインストール可能なため、開発者間で同じ開発環境を作成することができます。Gitには必ずこのファイルもコミットしましょう。

pnpmを使ったパッケージ管理

pnpmはnpmと同じパッケージ管理ツールですが、主にディスク容量の節約とインストールの高速化、依存関係の厳格化に重きを置かれているツールです。公式サイトでは高速、かつディスク容量効率が良いパッケージマネージャーという表現がされています。

まず、pnpmはローカルPCのストレージを効率的に使います。npmを使用してそのプロジェクト内に依存関係が100個ある場合、npmは実体のファイルを100個コピーします。しかし、pnpmは依存関係を探索可能なストアに格納します。ファイルの実態自体はディスクの一箇所に保存されます。パッケージが インストールされると、そのパッケージのファイルは 1 か所からハードリンクされ、追加のディスク領域を消費しません。 これにより、同じバージョンの依存をプロジェクト間で共有できます。これらの結果、 プロジェクトと依存関係の数に比例してディスク上の領域を節約し、インストールが非常に高速になります。

pnpmをインストールするにはnpmを使います。

npm install -g pnpm

そして基本的なパッケージをインストールしたり削除したりといった基本的なコマンドは以下のとおりです。

pnpm add package-name        # 通常の依存関係の追加
pnpm add -D package-name     # devDependencies に依存関係追加
pnpm remove package-name     # パッケージ削除
pnpm install                 # パッケージのインストール

AWS CDKを使ったデプロイ

AWS Lambdaをデプロイする場合に使用されるツールと言えば、Serverless Framework、SAM、AWS CDKとありますが、TypeScriptでAWS Lambdaを書いている場合にはAWS CDKのTypeScriptで書いてしまうのが個人的には一番良いように思います。特にTypeScriptの静的型付けはCDKでAWSリソースを定義していく際、その仕組との相性が良いと感じます。AWS CDK自体がTypeScriptをメジャーな言語として開発しており、チュートリアルなどもTypeScriptが多いです。好みにもよりますがこれらの理由によりCDK(TypeScript)を選択するのが自然な流れではないでしょうか。

CDKでLambdaを記述する例

import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class MyLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new lambda.Function(this, 'MyLambdaFunction', {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda'), // ディレクトリを指定
    });
  }
}

AWS Lambda Powertools for TypeScriptを使ったLambda実装

AWS Lmabda PowetoolsとはLambda実装のベストプラクティスに、より簡単に沿えるようなユーティリティが含まれているライブラリです。必要なユーティリティを個別にインストールができるようになっています。

npm i @aws-lambda-powertools/logger   #loggerのインストール
npm i @aws-lambda-powertools/metrics    #metricsのインストール
npm i @aws-lambda-powertools/tracer     #tracerのインストール

ユーティリティ

説明

Tracer

イベントのトレースを行う

Logger

構造化ログを記録し、Lambda のコンテキスト情報を自動的に追加するミドルウェア

Metrics

CloudWatch Embedded Metric Format (EMF) を使用して、カスタムメトリクスを記録。

Parameters

AWS SSM Parameter Store、AWS Secrets Manager、AWS AppConfig、Amazon DynamoDB からパラメータを取得するための関数を提供。

Idempotency

ペイロードの内容に基づいて Lambda の重複実行を防ぐ。

Batch Processing

Amazon SQS、Amazon Kinesis Data Streams、Amazon DynamoDB Streams からのバッチ処理時に部分的な失敗を適切に処理するためのユーティリティ。

Parser

TypeScript 向けのスキーマ定義・バリデーションライブラリ「Zod」を使用して、AWS Lambda のイベントペイロードを解析・検証するためのユーティリティ。

例えば、Loggerを使って構造化のログを吐き出したい場合には以下のような実装を行います。構造化することでログレベルに応じて後からCloudWatch Logs Insightなどでの調査がやりやすくなるメリットがあります。

import { Logger } from "@aws-lambda-powertools/logger";

const logger = new Logger({ serviceName: "my-service" });

export const handler = async (event: any) => {
  logger.info("Processing event", { event });
  return { message: "Hello World" };
};

ログの出力結果

{
  "level": "INFO",
  "service": "my-service",
  "message": "Processing event",
  "event": { ... },
  "timestamp": "2024-02-13T12:00:00.000Z"
}

Jest/vitestを使ったユニットテスト

現状、TypeScriptで実装をするなら、 Jestvitestを使用するのがメジャーな選択肢だと思います。ユニットテストを書くことは開発者にとって負担のかかる作業の一つだとは思いますが、Jestやvitestはシンプルな設定で導入ができたり、ユニットテストに必要なモックやスナップショットといった、複雑になりがちな部分も簡単に使用することができます。

Jest の場合、TypeScriptでテストをするためにはts-jestを一緒にインストールします。

npm install --save-dev jest ts-jest @types/jest

そして、package.jsonにテストの実行スクリプトを追記します。

"scripts": {
  "test": "jest"
}

ts-jest を使うことでTypeScriptのまま(コンパイル無し)でテストが実行できますし、型のチェックもテスト内で同時に行うことができます。例えば以下のような関数があったとします。

// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

以下のようなテストコードを書くと自動でトランスパイルしてテストが実行できます。

// math.test.ts
import { add } from "./math";

test("adds two numbers", () => {
  expect(add(2, 3)).toBe(5);
});

vitest の場合、npm i -D vitestとした後に以下の設定ファイルをプロジェクトルートに作成します。

import path from 'node:path'
import { configDefaults, defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    watch: false,
    clearMocks: true,
    coverage: {
      enabled: true,
      all: true,
      provider: 'v8',
      include: ['src'],
      reportsDirectory: '.coverage/vitest',
      exclude: [
        ...configDefaults.exclude
      ]
    },
    alias: {
      '~/': path.join(__dirname, './src/'),
      '~test/': path.join(__dirname, './test/')
    }
  }
})

あとは、package.json に以下を追加し、 npm run test するだけで、TypeScript で書かれたテストコードもそのまま実行が可能です。

"scripts": {
  "test": "vitest"
}

また、AWS Lambdaの実装においてAWS SDKをmockするテストを書くケース多いと思いますが、aws-sdk-client-mock というパッケージを使用することがおすすめです。LambdaのランタイムがNode.js18以上の場合、AWS SDK V3が同梱されています。このパッケージを使うことでAWS SDK V3のモックの機能を実現してくれます。例えば以下のようにSNSからメッセージを送信するだけの関数のテストを書いてみましょう

import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";

const snsClient = new SNSClient({ region: "us-east-1" });

export async function publishMessage(topicArn: string, message: string) {
  const command = new PublishCommand({
    TopicArn: topicArn,
    Message: message,
  });

  const response = await snsClient.send(command);
  return response;
}

SNSClientのモックを作成して、ダミーのレスポインスが返ってきたか、正しくPublishCommnadが呼び出されたのかをテストすることが可能になります。


import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
import { mockClient } from "aws-sdk-client-mock";
import { publishMessage } from "./snsService";

// SNS クライアントのモックを作成
const snsMock = mockClient(SNSClient);

beforeEach(() => {
  snsMock.reset(); // 毎回リセット
});

test("SNS publishMessage should succeed", async () => {
  // モックのレスポンスを設定
  snsMock.on(PublishCommand).resolves({
    MessageId: "12345",
  });

  // 関数を実行
  const response = await publishMessage("arn:aws:sns:us-east-1:123456789012:my-topic", "Hello SNS!");

  // レスポンスの確認
  expect(response.MessageId).toBe("12345");

  // コマンドが正しく呼ばれたか確認
  expect(snsMock).toHaveReceivedCommand(PublishCommand);
  expect(snsMock).toHaveReceivedCommandTimes(PublishCommand, 1);
});

esbuildによるトランスパイル

TypeScriptを使用した開発では実行時にJavaScriptに変換してくれるツールが必要になりますが、それらをバンドラーやトランスパイラと呼びます。トランスパイラにはWebpack、Gulp、Parcel、Rollupなどたくさんのものがありますが、今はesbuildが最もメジャーです。esbuildの一番の特徴は超高速にトランスパイル可能な点です。esbuildはGo言語製でネイティブコードにコンパイルされ並列処理をフル活用できるるため、他のJavaScript製バンドラーより圧倒的に高速です。さらにTypeScriptの公式コンパイラであるtscを使用していません。独自のパーサーを実装することで無駄な処理を排除しています。また、メモリ使用を効率化することでキャッシュ効率を向上させているなど、これらの最適化の組み合わせで従来のトランスパイラよりも10~100倍の速度を実現しています。また、AWS CDK と AWS SAM はどちらも、TypeScript コードの JavaScript へのトランスパイルに esbuild を使用しているため、その点でもesbuildを使ってしまったほうが良いと言えるでしょう。

import * as esbuild from 'esbuild'

const options: esbuild.BuildOptions = {
  bundle: true,
  platform: 'node',
  target: 'node22',
  format: 'esm',
  outExtension: { '.js': '.mjs' },
  tsconfig: 'tsconfig.json',
  minify: true,
  sourcemap: true,
  assetNames: '[name]',
  external: [ '@aws-sdk/*' ]
}

;(async () => {

  await esbuild.build({
    ...options,
    entryPoints : [ 'src/index.ts' ],
    outdir      : '.dist/',
  })

})();

開発環境への注意点

弊社では、Amazon EC2に開発環境を作成してVscodeで実装を行うケースが多いですが、Amazon linux2はNode18以上がインストールできません。Amazon linuxの2023以上を使って開発を行うようにしましょう。

Written by
CEO

堀家 隆宏

Takahiro Horike

  • Facebook->
  • X->
  • GitHub->

Share

Facebook->X->
Back
to list
<-