この記事は ぷりぷりあぷりけーしょんず Advent Calendar 2020 の8日目の記事です。
はじめに
久しぶりにCDKでLambdaを構築しようとしたらNodejsFunctionというConstructが増えていて、少し触ってみたので記事にまとめます。
今回はサンプルとしてToDoアプリ用のREST APIをサーバーレスで作ってみましたが、この記事ではその一部を紹介しようと思います。ソース全体は以下のGitHubに公開していますのでご興味のある方はご参照いただければと思います。
https://github.com/misaosyushi/todo
NodejsFunctionとは?
NodejsFunctionとは、Parcelを使用してバンドルされたNode.js製Lambda Functionを構築するためのモジュールです。@aws-cdk/aws-lambda-nodejs を追加することで使用できるようになります。
こちらを使うことで、cdk deploy
時にLambdaのソースを自動でトランスパイル&バンドルしてデプロイできるようになります。
いままではLambdaのコードをTypeScriptで書いたときはデプロイ前に事前にトランスパイルする必要があったり、外部パッケージを使用していたらnode_modules
をデプロイパッケージに含めるか、Lamda Layersに事前に指定の構成でのzipを作成する必要があったりと、かゆいところに手が届かないような状態だったと思います。それが、NodejsFunctionを使うことで解消できるのでとても素敵な機能かと個人的には感じています。
ただし2020/12/8現在、NodejsFunctionは実験的機能となっているのでプロダクションへの導入には注意が必要そうです。
ディレクトリ構成
ディレクトリは最終的には以下のようになってます。CDKプロジェクトの作成方法などは紹介しませんので、初期化手順などはこちらの公式手順をご覧ください。
├── bin
│ └── todo.ts
├── cdk.json
├── docs
│ └── api-spec.md
├── jest.config.js
├── lambda
│ └── api
│ ├── config.ts
│ └── todo
│ ├── delete.ts
│ ├── get.ts
│ ├── list.ts
│ ├── post.ts
│ ├── put.ts
│ └── todo.ts
├── lib
│ └── todo-stack.ts
├── package-lock.json
├── package.json
├── test
│ └── todo.test.ts
└── tsconfig.json
必要なパッケージをインストール
今回はLambdaとAPI GatewayとDynamoDBを使用しました。
npm i @aws-cdk/aws-lambda-nodejs @aws-cdk/aws-dynamodb @aws-cdk/aws-apigateway
DynamoDBを定義する
lib/todo-stack.ts
にDynamoDBのテーブルを作成するためのコードを追加します。
addGlobalSecondaryIndex
は名前の通りGSIを作成しています。これでToDoテーブルのtitle
という項目でQuery(検索)が実行できるようになります。
import * as cdk from '@aws-cdk/core';
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs';
import { AttributeType, Table } from '@aws-cdk/aws-dynamodb';
import { ApiKeySourceType, LambdaIntegration, RestApi, Cors } from '@aws-cdk/aws-apigateway';
export class TodoStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const tableName = 'ToDoTable';
const primaryKey = 'id';
const indexName = 'title-index';
const todoTable = new Table(this, 'toDoTable', {
tableName: tableName,
partitionKey: {
name: primaryKey,
type: AttributeType.STRING,
},
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
todoTable.addGlobalSecondaryIndex({
indexName: indexName,
partitionKey: {
name: 'title',
type: AttributeType.STRING
},
});
}
}
Lambdaを定義する
さて、本題のNodejsFunctionを使用したLambdaの定義です。といってもNodejsFunctionはlamda.Function
クラスを継承しているクラスなので、基本的には同じように使えます。
entry
にはlambdaの実装ファイルまでものパスを指定します。jsまたはtsが指定できます。
const getToDoFunction = new NodejsFunction(this, 'getToDoFunction', {
entry: 'lambda/api/todo/get.ts',
handler: 'getHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'PRIMARY_KEY': primaryKey,
},
});
const listToDoFunction = new NodejsFunction(this, 'listToDoFunction', {
entry: 'lambda/api/todo/list.ts',
handler: 'listHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'INDEX_NAME': indexName,
},
});
const postToDoFunction = new NodejsFunction(this, 'postToDoFunction', {
entry: 'lambda/api/todo/post.ts',
handler: 'postHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
},
});
const putToDoFunction = new NodejsFunction(this, 'putToDoFunction', {
entry: 'lambda/api/todo/put.ts',
handler: 'putHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'PRIMARY_KEY': primaryKey,
},
});
const deleteToDoFunction = new NodejsFunction(this, 'deleteToDoFunction', {
entry: 'lambda/api/todo/delete.ts',
handler: 'deleteHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'PRIMARY_KEY': primaryKey,
},
});
Lambdaの実装
ここではToDo APIのPOSTメソッドに使用するFunctionを例として紹介します。もし他のメソッドが気になる方はGitHubをご参照ください🙇♀️
このFunctionは外部パッケージを参照しているので、従来であればデプロイパッケージにnode_modules
を含めるか、Lambda Layersにzipをアップロードしてそれを参照するようにしないと実行時にパッケージが参照できなくてエラーになっていました。
ですが、NodejsFunctionを使用することでトランスパイルとバンドルをやってからデプロイしてくれるので、そういった事前準備が不要となり、以下のようなコードでもそのままデプロイコマンド実行してOKになります。
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { v4 as uuid } from 'uuid';
import { DB } from '../config';
import { ToDo, HEADER } from './todo';
export async function postHandler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
if (!event.body) {
return {
statusCode: 400,
body: 'Error: bad request',
};
}
const toDo: ToDo = JSON.parse(event.body)
const params = {
TableName: process.env.TABLE_NAME,
Item: {
'id': uuid(),
'title': toDo.title,
'detail': toDo.detail,
'deadlineDate': toDo.deadlineDate,
'status': toDo.status,
}
}
try {
await DB.put(params).promise();
return {
statusCode: 200,
headers: HEADER,
body: 'Success',
};
} catch (dbError) {
return {statusCode: 500, body: JSON.stringify(dbError)};
}
}
API Gatewayを定義する
API Gatewayのリソースを追加します。簡易的な認証としてAPIキー必須にし、APIキーに対しては利用制限をかけるようにしています。
const api = new RestApi(this, "todoApi", {
restApiName: "ToDoAPI",
apiKeySourceType: ApiKeySourceType.HEADER,
defaultCorsPreflightOptions: {
allowOrigins: Cors.ALL_ORIGINS,
allowMethods: Cors.ALL_METHODS,
allowHeaders: Cors.DEFAULT_HEADERS,
statusCode: 200,
}
});
const todos = api.root.addResource("todo");
// list
todos.addMethod("GET", new LambdaIntegration(listToDoFunction), {
apiKeyRequired: true,
});
// post
todos.addMethod("POST", new LambdaIntegration(postToDoFunction), {
apiKeyRequired: true,
});
const todo = todos.addResource("{id}");
// get
todo.addMethod("GET", new LambdaIntegration(getToDoFunction), {
apiKeyRequired: true,
});
// put
todo.addMethod("PUT", new LambdaIntegration(putToDoFunction), {
apiKeyRequired: true,
});
// delete
todo.addMethod("DELETE", new LambdaIntegration(deleteToDoFunction), {
apiKeyRequired: true,
})
// APIキーの追加
const apiKey = api.addApiKey('apiKey', {
apiKeyName: 'ToDoAPIKey',
})
// 追加したAPIキーに対して使用量の制限を付与する
api.addUsagePlan('forAPIKey', {
apiKey,
throttle: {
rateLimit: 20,
burstLimit: 200
},
}).addApiStage({
stage: api.deploymentStage
})
全体
lib/todo-stack.ts
の全体像としては以下になります。
import * as cdk from '@aws-cdk/core';
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs';
import { AttributeType, Table } from '@aws-cdk/aws-dynamodb';
import { ApiKeySourceType, LambdaIntegration, RestApi, Cors } from '@aws-cdk/aws-apigateway';
export class TodoStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const tableName = 'ToDoTable';
const primaryKey = 'id';
const indexName = 'title-index';
const todoTable = new Table(this, 'toDoTable', {
tableName: tableName,
partitionKey: {
name: primaryKey,
type: AttributeType.STRING,
},
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
todoTable.addGlobalSecondaryIndex({
indexName: indexName,
partitionKey: {
name: 'title',
type: AttributeType.STRING
},
});
const getToDoFunction = new NodejsFunction(this, 'getToDoFunction', {
entry: 'lambda/api/todo/get.ts',
handler: 'getHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'PRIMARY_KEY': primaryKey,
},
});
const listToDoFunction = new NodejsFunction(this, 'listToDoFunction', {
entry: 'lambda/api/todo/list.ts',
handler: 'listHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'INDEX_NAME': indexName,
},
});
const postToDoFunction = new NodejsFunction(this, 'postToDoFunction', {
entry: 'lambda/api/todo/post.ts',
handler: 'postHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
},
});
const putToDoFunction = new NodejsFunction(this, 'putToDoFunction', {
entry: 'lambda/api/todo/put.ts',
handler: 'putHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'PRIMARY_KEY': primaryKey,
},
});
const deleteToDoFunction = new NodejsFunction(this, 'deleteToDoFunction', {
entry: 'lambda/api/todo/delete.ts',
handler: 'deleteHandler',
timeout: cdk.Duration.seconds(30),
environment: {
'TABLE_NAME': tableName,
'PRIMARY_KEY': primaryKey,
},
});
todoTable.grantReadData(getToDoFunction)
todoTable.grantReadData(listToDoFunction)
todoTable.grantWriteData(postToDoFunction)
todoTable.grantWriteData(putToDoFunction)
todoTable.grantWriteData(deleteToDoFunction)
const api = new RestApi(this, "todoApi", {
restApiName: "ToDoAPI",
apiKeySourceType: ApiKeySourceType.HEADER,
defaultCorsPreflightOptions: {
allowOrigins: Cors.ALL_ORIGINS,
allowMethods: Cors.ALL_METHODS,
allowHeaders: Cors.DEFAULT_HEADERS,
statusCode: 200,
}
});
const todos = api.root.addResource("todo");
// list
todos.addMethod("GET", new LambdaIntegration(listToDoFunction), {
apiKeyRequired: true,
});
// post
todos.addMethod("POST", new LambdaIntegration(postToDoFunction), {
apiKeyRequired: true,
});
const todo = todos.addResource("{id}");
// get
todo.addMethod("GET", new LambdaIntegration(getToDoFunction), {
apiKeyRequired: true,
});
// put
todo.addMethod("PUT", new LambdaIntegration(putToDoFunction), {
apiKeyRequired: true,
});
// delete
todo.addMethod("DELETE", new LambdaIntegration(deleteToDoFunction), {
apiKeyRequired: true,
});
const apiKey = api.addApiKey('apiKey', {
apiKeyName: 'ToDoAPIKey',
})
api.addUsagePlan('forAPIKey', {
apiKey,
throttle: {
rateLimit: 20,
burstLimit: 200
},
}).addApiStage({
stage: api.deploymentStage
})
}
}
デプロイ
cdk deploy
普通のLambda Functionのデプロイより少し時間はかかりますが、デプロイコマンド実行後にトランスパイルし忘れてた!と気づいてやり直したりしていたことを考えれば圧倒的に楽かと思います。
デプロイされたLambdaのコードはこんな感じです。行数が多いので一部のスクショしか載せませんが、ちゃんとバンドルされたコードがデプロイされました🎉
まとめ
NodejsFunctionを使用することで開発者はコード書くことだけに集中することができるので、いままで以上に開発が捗るのではないでしょうか。あと、とりあえずサーバーレス&CDKでインフラ構築すれば簡単なAPIなら爆速で作れてしまうので、個人的にこういった構成は大好きです。気になる方は是非触ってみてください!