ローカルで TypeScript + AWS SAM (Lambda/DynamoDB) の開発環境を構築する
ローカルで TypeScript + AWS SAM (Lambda/DynamoDB) の開発環境を構築する:
ナビタイムジャパン/ACTS(研究開発部門)アプリグループの木村です。
私からは、来る12/15に行われる Developer Boost にて発表させて頂く、「サーバーレス+SPAでつくる!ユーザーご意見管理システム」のセッションから、
AWS SAMを用いたサーバーレスAPIの開発環境を構築する方法について紹介させていただきます。
普段サーバーサイドを開発している方でも、「サーバーレスアプリってどうやって作るの?」と疑問を持たれる方は少なくないと思います。(実際私もそうでした。)
この記事を読んで、開発のイメージを掴んでいただけるとうれしいです。
公式サイトには、以下のように記載されています。
つまるところ、サーバーレスアプリケーションを構成するために必要な各要素の関係・定義をまとめたものです。
例えば、AWS上でサーバーレスのAPIを構築する場合には、以下のようなサービスを用います。
しかし、アプリケーションがスケールしていくと、これらの設定を都度していくのは非常に運用コストがかかります。
特に、別環境に同じようなアプリケーションを構築する必要が出た場合、同じ作業をひたすら繰り返して…となると、つらみのある作業になりますよね。
AWS SAMでは、上に挙げたサービスがそれぞれどういった定義で、どういった関係を持っているのかをYAMLで定義することができ、CloudFormationを利用して一括でデプロイできる環境を提供してくれます。
なお、AWSだけでなくGCPやAzureでも利用できる、同じようなフレームワークとして、Serverlessと呼ばれるものも有名です。
前置きはこのあたりにして、実際にローカルでサーバーレスアプリケーションの開発環境を構築してみましょう。
今回は、よくあるTodo管理のAPIを題材として進めたいと思います。
流れは以下のような感じです。
事前に、開発するPCで下記のセットアップを済ませておきます。
※なお、今回の環境についてはMacで動作確認済です。(Windowsでは未確認です。スミマセン)
今回メインで実装するLambdaの実行環境は、Node.jsのv8.10を採用しました。
生のJavaScriptをゴリゴリ書いていくのもよいですが、最近はTypeScriptを用いて型のある幸せな世界で開発できる環境が整っていますので、
TypeScriptの開発環境を整えるには、Nodeのプロジェクトでwebpackをモジュールバンドラとして利用するのが世間一般的にもしっくり来るかと思いますので、そのように進めます。
まずは初期化。
続いて、必要なモジュールのインストールを行います。
※tslint, prettierの設定はお好みでどうぞ。
続いて、
今回は、APIの1エンドポイントにつき、1つのLambda関数を割り当てるような構成にしていきます。
通常ですと、webpackを用いるときは1つのentryに集約されることがおおいですが、今回は複数entryを扱えるような構成で設定を記述していきます。
最終的にバンドルされた結果(の各Lambda関数)が
ここの(関数名)の部分のディレクトリ構成をもう一段階掘って、
として出力するなど調整できます。
ディレクトリ構成が実行するLambda関数の命名になるので、各プロジェクトでどのように開発していくか次第でentryの解決方法を調整することをおすすめします。
次に、TypeScriptの設定を
こちらも、
最後に
ここまでで、プロジェクトの基本的なセットアップは完了です。
現段階での構成は↓のような感じ。
※
さて、ここからメインの実装に入ってきます。
まず最初は簡単に、
この関数は、引数に
今回はサンプルなので、パラメーターなどは特に意識せず、固定のJSONを返すようにします。
続いて、これを実際にAPIリクエストを受け取ったときに動作するよう、SAMの設定を追加していきます。
新たに、
これがSAMのテンプレートと呼ばれるものになります。
Resources配下に実行されるアプリケーションの処理のかたまりを定義していくイメージです。
今回の
(2)で実装したLambda関数は、webpackのバンドルが完了すると
ここまでで、SAMの設定は完了しているので、実際にAPIを動かしてみましょう。
事前にインストール済みの aws-sam-cli を介して、上記templateをもとにサーバーレスアプリケーションをAPIとして立ち上げます。
これで、
index.tsのresultで指定したJSONが返却されていますね!
ここまでで、プロジェクトの構成は以下のようになりました。
いよいよAPI実装らしい部分に突入します。
ローカルで擬似的にDynamoDBを立て、その中にあるテーブルに対してレコード取得・追加の操作を、APIを介して行ってみましょう。
ローカルでは、DynamoDBの仮想環境をDockerイメージを立ち上げることで実現します。
このDockerイメージはAWSが公式で配布してくれています。
※ちなみに、公式がDockerImageを提供する以前は
これを用いて、Todoリストのアイテムを管理するテーブルをDynamoDBに作成してみます。
Todoのテーブル定義は、
と簡単なものにしておきます。idをHashKeyとして定義し、doneとpriorityをRangeKeyとしたGSIも設定しておきます。
(doneの値がnumberになっているのは、BOOLだとGSIが設定できないためです。)
Dockerで立てたDynamoDBに対しては、AWS CLIを介してテーブル作成とダミーデータ入力を行います。
CLIで流し込むデータは、以下のようなJSONを用意しておきます。
これらを使ってDynamoを立ち上げるまでの処理は、シェルにまとめておくと楽です。
このシェルを
プロジェクトの構成は以下のようになっているはずです。
それでは、ここからDynamoDBへの操作をAPIを介して行ってみましょう。
APIに、以下の2つのエンドポイントを作成します。
注意点としては、
このうち、
これらの値がどのように効いてくるかは、このあとの説明で出てきます。
続いては、まずGETのAPIに相当するLambda関数を実装しましょう。
テーブルのレコードやAPIのレスポンスについては、ある程度型定義があったほうが良いので、これらは事前に別ファイルに定義しています。
さて、API側の実装説明にもどります。
これは、SAMの設定で
もし、クエリパラメーターを取得したい場合は、
ここで取得したTodoのIDを用いて、DynamoDBからレコードをGetします。
AWSのJavaScriptSDKで用意されている、DynamoDBへのアクセスを行う
DBへのアクセス処理そのものは、これまたDAOを作る感覚で別ファイルへ切り出しています。
(※無理やりキャストしたりしていますが、今回はサンプルなのでお許しください。)
さて、ここで思い出してほしいのは、SAMに記載した
これがどこに効いてくるかというと、 functionsの実装にある
その実装は以下のとおり。
AWS SAM CLIを用いてこれらの関数を実行する場合には、
このとき、configとしてendpointを指定してあげないと、AWSのSDKはどの場所のDynamoDBを見ればよいかわからず、DynamoDBへアクセスできない状態になります。
そのため、ローカルで実行する場合のみ、このconfig更新をしてあげる必要があります。
※実際にAWS環境で動かす場合は、同じアカウントの同じリージョンの情報を参照するので、これらの設定は不要です。
ここで指定した
では、SAMのEnvoronment側の値はどのように行うかというと、AWS SAM CLIでの実行時引数としてパラメーターを与えることができます。
このパラメーターは、以下のようなJSONとして定義しておきます。
これを、SAM CLIの実行時に、以下のように指定します。
すると、
なお、env.jsonで指定するローカルのアドレスは、
(SAMで実行しているLambdaもDockerコンテナ上で実行されているので、localhostで見る場所が変わってしまうためだと思われます。)
さて、それではTodoのアイテムを取得してみましょう。
事前にDBに入れた値がAPIのレスポンスとして取得できているでしょうか?
同じ容量で、
実行してみます。
追加したレコードを取得するまで行うことができました!
以下のようになりました。
いかがでしたでしょうか?
サーバーレスアプリケーションの開発では、とくにLambdaやDynamoDBが絡む部分が、マネジメントコンソール上だけでは非常に開発がしづらい部分かと思います。
これらをローカルで手軽に開発・実行できる環境が整うだけで、サーバーレスアプリケーションの開発のハードルが下がるのではないかと思っています。
ここで開発したものを実際にAWS環境へデプロイするためには、もうひと手間加える必要があります。
ですが、ローカルで構築したものをほぼ流用できるので、Swaggerの定義をつくること以外はほとんど手間にならないと思います。
環境ごとにSAMのテンプレートファイルを分割しなければならないので、そこだけがイケてない部分かなぁと思います…
ここについては、未だに良い方法が思い浮かんでないので、なにかアイデアがある方は共有いただけると嬉しいです!
EC2インスタンスを立ち上げてAPIを作るよりも、サーバーレスのほうが低コストで気軽に試すことができるのが大きなメリットになります。
なにか小さなシステムで利用するには最適ですので、ぜひお試しください!
はじめに
ナビタイムジャパン/ACTS(研究開発部門)アプリグループの木村です。私からは、来る12/15に行われる Developer Boost にて発表させて頂く、「サーバーレス+SPAでつくる!ユーザーご意見管理システム」のセッションから、
AWS SAMを用いたサーバーレスAPIの開発環境を構築する方法について紹介させていただきます。
普段サーバーサイドを開発している方でも、「サーバーレスアプリってどうやって作るの?」と疑問を持たれる方は少なくないと思います。(実際私もそうでした。)
この記事を読んで、開発のイメージを掴んでいただけるとうれしいです。
AWS SAMとは?
公式サイトには、以下のように記載されています。AWS サーバーレスアプリケーションモデル (AWS SAM) はサーバーレスアプリケーションを定義するモデルです。…そのまんまですね。
つまるところ、サーバーレスアプリケーションを構成するために必要な各要素の関係・定義をまとめたものです。
例えば、AWS上でサーバーレスのAPIを構築する場合には、以下のようなサービスを用います。
- API Gateway
- Lambda
- DynamoDB
しかし、アプリケーションがスケールしていくと、これらの設定を都度していくのは非常に運用コストがかかります。
特に、別環境に同じようなアプリケーションを構築する必要が出た場合、同じ作業をひたすら繰り返して…となると、つらみのある作業になりますよね。
AWS SAMでは、上に挙げたサービスがそれぞれどういった定義で、どういった関係を持っているのかをYAMLで定義することができ、CloudFormationを利用して一括でデプロイできる環境を提供してくれます。
なお、AWSだけでなくGCPやAzureでも利用できる、同じようなフレームワークとして、Serverlessと呼ばれるものも有名です。
実際に作ってみる
前置きはこのあたりにして、実際にローカルでサーバーレスアプリケーションの開発環境を構築してみましょう。今回は、よくあるTodo管理のAPIを題材として進めたいと思います。
流れは以下のような感じです。
- プロジェクトのベースをつくる
- APIの本体となるLambdaの実装をつくる
- SAMでAPI定義を作成し、APIを立ち上げる
- ローカルでDynamoDBを立ち上げ、連携する
必要な環境
事前に、開発するPCで下記のセットアップを済ませておきます。※なお、今回の環境についてはMacで動作確認済です。(Windowsでは未確認です。スミマセン)
- Node.js
- バージョンはとりあえずv.8系以降ならOK
- Docker
-
aws-cli
- Configureについては、サンプルのものをそのまま適用しておけばOKです。
- aws-sam-cli
(1)プロジェクトのベースをつくる
今回メインで実装するLambdaの実行環境は、Node.jsのv8.10を採用しました。生のJavaScriptをゴリゴリ書いていくのもよいですが、最近はTypeScriptを用いて型のある幸せな世界で開発できる環境が整っていますので、
- 開発はTypeScript
- 実行時はJavaScriptにトランスパイルしたものを利用
TypeScriptの開発環境を整えるには、Nodeのプロジェクトでwebpackをモジュールバンドラとして利用するのが世間一般的にもしっくり来るかと思いますので、そのように進めます。
まずは初期化。
$ mkdir aws-sam-local-api $ cd aws-sam-local-api $ npm init -y
$ npm i -D webpack webpack-cli typescript ts-node awesome-typescript-loader aws-sdk glob $ npm i -D @types/node @types/webpack @types/aws-lambda @types/glob
続いて、
webpack.config
の設定です。今回は、APIの1エンドポイントにつき、1つのLambda関数を割り当てるような構成にしていきます。
通常ですと、webpackを用いるときは1つのentryに集約されることがおおいですが、今回は複数entryを扱えるような構成で設定を記述していきます。
webpack.config.ts
import * as Webpack from 'webpack'; import {resolve} from 'path'; import {sync} from 'glob'; /** ビルド対象ルートディレクトリ */ const SRC_PATH = resolve(__dirname, './src/functions/'); /** entryとなるファイル名 */ const ENTRY_NAME = 'index.ts'; /** ビルド結果出力先 */ const BUILT_PATH = resolve(__dirname, './built'); /** ビルド種別 */ const BUILD_VARIANT = process.env.NODE_ENV; /** * ビルド対象のentryを解決する * @returns {Webpack.Entry} entry */ const resolveEntry = (): Webpack.Entry => { const entries: {[key: string]: string} = {}; const targets: string[] = sync(`${SRC_PATH}/**/${ENTRY_NAME}`); const pathRegex = new RegExp(`${SRC_PATH}/(.+?)/${ENTRY_NAME}`); targets.forEach((value: string) => { let key: string; switch (BUILD_VARIANT) { case 'production': key = value.replace(pathRegex, 'prd_$1_$2/index'); break; case 'development': key = value.replace(pathRegex, 'dev_$1_$2/index'); break; } entries[key] = value; }); return entries; }; const config: Webpack.Configuration = { target: 'node', mode: BUILD_VARIANT === 'production' ? 'production' : 'development', resolve: { extensions: ['.ts', '.js'] }, entry: resolveEntry(), output: { filename: '[name].js', path: BUILT_PATH, library: '[name]', libraryTarget: 'commonjs2' }, module: { rules: [ { test: /\.ts?$/, loader: 'awesome-typescript-loader' } ] } }; export default config;
src
配下のディレクトリ構成を、 src/functions/(関数名)/index.ts
となるように作成しておき、 index.ts
内にLambdaの実装をしていくことで、最終的にバンドルされた結果(の各Lambda関数)が
built/(関数名)/index.js
として出力されます。ここの(関数名)の部分のディレクトリ構成をもう一段階掘って、
src/functions/(分類)/(関数名)index.ts
→ built/(分類)_(関数名)/index.js
として出力するなど調整できます。
ディレクトリ構成が実行するLambda関数の命名になるので、各プロジェクトでどのように開発していくか次第でentryの解決方法を調整することをおすすめします。
次に、TypeScriptの設定を
tsconfig.json
に追加していきます。こちらも、
noImplictAny
などのオプションはお好みでどうぞ。tsconfig.json
{ "compilerOptions": { "module": "commonjs", "target": "es6", "noImplicitAny": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "outDir": "./built", "allowJs": true }, "include": [ "./src/**/*" ] }
package.json
に、実装したTSファイルを保存に自動でビルドするようにwatchタスクを追加しておきましょう。package.json
"scripts": { "watch": "NODE_ENV=development webpack --watch" }
現段階での構成は↓のような感じ。
. ├── package-lock.json ├── package.json ├── src ├── tsconfig.json ├── tslint.json └── webpack.config.ts
package-lock.json
は自動で生成されているはずです。
(2)APIの本体となるLambdaの実装をつくる
さて、ここからメインの実装に入ってきます。まず最初は簡単に、
GET /ping
のAPIの実装を作ってみましょう。src/functions/get_ping/
配下に、 index.ts
を作成します。src/functions/get_ping/index.ts
import {APIGatewayEvent, Context, Callback} from 'aws-lambda'; const handler = (event: APIGatewayEvent, context: Context, callback: Callback) => { const result = { status: 200, message: 'OK!!!!!' }; callback(null, { statusCode: 200, headers: { 'Content-Type': 'application/json;charset=UTF-8' }, body: JSON.stringify(result) }); }; export {handler};
handler
として実装している部分が、実際にLambda上で動作する関数になります。この関数は、引数に
- API Gatewayからのリクエストに関するイベント
- 実行環境のContext情報
- Lambda実行結果のCallback
event
から受け取ることが可能です。今回はサンプルなので、パラメーターなどは特に意識せず、固定のJSONを返すようにします。
callback()
の第2引数が実際のAPIレスポンスに関する値になりますが、ここで返すべき値は「ステータスコード」「レスポンスヘッダー」「レスポンスボディ」です。(詳細は公式ドキュメントも参照してください。)
(3)SAMでAPI定義を作成し、APIを立ち上げる
続いて、これを実際にAPIリクエストを受け取ったときに動作するよう、SAMの設定を追加していきます。新たに、
sam/dev/template.yaml
を作成し、以下のような設定を記載します。sam/dev/template.yaml
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Globals: Function: Timeout: 30 Resources: devPing: Type: 'AWS::Serverless::Function' Properties: Runtime: nodejs8.10 CodeUri: ../../built/dev_get_ping Handler: index.handler Events: Vote: Type: Api Properties: Path: /ping Method: get
Resources配下に実行されるアプリケーションの処理のかたまりを定義していくイメージです。
今回の
GET /ping
へのアクセス時に実行される処理を、 devPing
という名前で定義しておき、-
GET /ping
へのリクエストが来たとき
- Properties -> Events -> Vote 配下の記述
- 指定したLambda関数を実行する
- Properties -> Runtime の実行環境で CodeUri, Handler で指定したものを実行
(2)で実装したLambda関数は、webpackのバンドルが完了すると
built/dev_get_ping/index.js
として出力されており、その中で handler
がexportされているので、 index.handler
でその実装が指定されている、というわけです!ここまでで、SAMの設定は完了しているので、実際にAPIを動かしてみましょう。
事前にインストール済みの aws-sam-cli を介して、上記templateをもとにサーバーレスアプリケーションをAPIとして立ち上げます。
$ sam local start-api -p 3001 -t ./sam/dev/template.yaml
http://localhost:3001/ping
にリクエストしてみましょう。$ curl -X GET http://127.0.0.1:3001/ping {"status":200,"message":"OK!!!!!"}
ここまでで、プロジェクトの構成は以下のようになりました。
. ├── package-lock.json ├── package.json ├── sam │ └── dev │ └── template.yaml ├── src │ └── functions │ └── main │ └── get_ping │ └── index.ts ├── tsconfig.json ├── tslint.json └── webpack.config.ts
(4) ローカルでDynamoDBを立ち上げ、連携する
いよいよAPI実装らしい部分に突入します。ローカルで擬似的にDynamoDBを立て、その中にあるテーブルに対してレコード取得・追加の操作を、APIを介して行ってみましょう。
ローカルでのDynamoDB環境について
ローカルでは、DynamoDBの仮想環境をDockerイメージを立ち上げることで実現します。このDockerイメージはAWSが公式で配布してくれています。
※ちなみに、公式がDockerImageを提供する以前は
tray/dynamodb-local
というものがあり、そちらを利用していました。これを用いて、Todoリストのアイテムを管理するテーブルをDynamoDBに作成してみます。
Todoのテーブル定義は、
カラム名(*は必須) | 型 | 内容 |
---|---|---|
id * | string | ID |
title * | string | Todoのタイトル |
description | string | 内容 |
done * | number | 完了:1, 未完了:0 |
priority | number | 優先度 |
(doneの値がnumberになっているのは、BOOLだとGSIが設定できないためです。)
Dockerで立てたDynamoDBに対しては、AWS CLIを介してテーブル作成とダミーデータ入力を行います。
CLIで流し込むデータは、以下のようなJSONを用意しておきます。
sam/dev/dynamo/tbl_todo.json
{ "TableName": "tbl_todo", "AttributeDefinitions": [{ "AttributeName": "id", "AttributeType": "S" }, { "AttributeName": "done", "AttributeType": "N" }, { "AttributeName": "priority", "AttributeType": "N" } ], "KeySchema": [{ "AttributeName": "id", "KeyType": "HASH" }], "GlobalSecondaryIndexes": [{ "IndexName": "PriorityIndex", "KeySchema": [{ "AttributeName": "id", "KeyType": "HASH" }, { "AttributeName": "priority", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "WriteCapacityUnits": 1, "ReadCapacityUnits": 1 } }, { "IndexName": "DoneIndex", "KeySchema": [{ "AttributeName": "id", "KeyType": "HASH" }, { "AttributeName": "done", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "WriteCapacityUnits": 1, "ReadCapacityUnits": 1 } } ], "ProvisionedThroughput": { "WriteCapacityUnits": 1, "ReadCapacityUnits": 1 } }
sam/dev/dynamo/put_items.json
{ "tbl_todo": [{ "PutRequest": { "Item": { "id": { "S": "todo0001" }, "title": { "S": "Todo No.1" }, "description": { "S": "Todo Item 01" }, "done": { "N": "1" } } } }, { "PutRequest": { "Item": { "id": { "S": "todo0002" }, "title": { "S": "Todo No.2" }, "description": { "S": "Todo Item 02" }, "done": { "N": "0" }, "priority": { "N": "3" } } } } ] }
sam/dev/dynamo/setup.sh
# !/bin/bash set -e # 前回のコンテナが残っていた場合は一度破棄する REF=$(docker ps -q --filter ancestor=amazon/dynamodb-local) if [ -n "$REF" ]; then docker rm -f $REF fi docker run -d -p 8000:8000 amazon/dynamodb-local # create table aws dynamodb create-table \ --endpoint-url http://localhost:8000 \ --cli-input-json file://$(pwd)/sam/dev/dynamo/tbl_todo.json # insert dummy item aws dynamodb batch-write-item \ --endpoint-url http://localhost:8000 \ --request-items file://$(pwd)/sam/dev/dynamo/put_items.json
setup.sh
とし、 sam/dev/dynamo
配下にJSONと一緒にまとめておいて、npmのスクリプト経由で実行できるようにしておくのがおすすめです。package.json
"scripts": { "setup": "sh ./sam/dev/dynamo/setup.sh", "watch": "NODE_ENV=development webpack --watch" }
. ├── package-lock.json ├── package.json ├── sam │ └── dev │ ├── dynamo │ │ ├── put_items.json │ │ ├── setup.sh │ │ └── tbl_todo.json │ ├── env.json │ └── template.yaml ├── src │ └── functions │ └── get_ping │ └── index.ts ├── tsconfig.json ├── tslint.json └── webpack.config.ts
APIに、以下の2つのエンドポイントを作成します。
-
GET /todo/{id}
- 指定したIDのTodoアイテムを取得する
-
POST /todo
- 新たにTodoアイテムを登録する
sam/dev/template.yaml
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Globals: Function: Timeout: 30 Resources: # ...中略 getTodo: Type: 'AWS::Serverless::Function' Properties: Runtime: nodejs8.10 CodeUri: ../../built/dev_get_todo Handler: index.handler Policies: AmazonDynamoDBFullAccess Environment: Variables: LOCAL_URL: $LOCAL_URL REGION: $REGION Events: Vote: Type: Api Properties: Path: /todo/{id} Method: get postTodo: Type: 'AWS::Serverless::Function' Properties: Runtime: nodejs8.10 CodeUri: ../../built/dev_post_todo Handler: index.handler Policies: AmazonDynamoDBFullAccess Environment: Variables: LOCAL_URL: $LOCAL_URL REGION: $REGION Events: Vote: Type: Api Properties: Path: /todo Method: post
Policies
としてDynamoDBへのアクセスを追加していることと、Environment
にいくつか設定を追記している部分です。このうち、
Environment
の部分については、ローカルで立てたDynamoDBのDockerイメージへアクセスするために必要なものです。これらの値がどのように効いてくるかは、このあとの説明で出てきます。
続いては、まずGETのAPIに相当するLambda関数を実装しましょう。
src/functions/get_todo/index.ts
import {APIGatewayEvent, Context, Callback} from 'aws-lambda'; import {TodoItem, APIResponse, StatusCode, ContentType} from '../../types/todo_api'; import localConfigure from '../localConfigure'; import {getTodo} from '../../db/todo'; localConfigure(); const handler = async (event: APIGatewayEvent, context: Context, callback: Callback) => { const todoId = event.pathParameters.id; const todo = await getTodo(todoId); const items: TodoItem[] = []; if (todo) { items.push({ id: todo.id, title: todo.title, description: todo.description, done: todo.done === 1, priority: todo.priority }); } const result: APIResponse<TodoItem> = {items}; callback(null, { statusCode: StatusCode.OK, headers: { 'Content-Type': ContentType.APPLICATION_JSON }, body: JSON.stringify(result) }); }; export {handler};
src/types/todo_api.ts
/** APIレスポンスのベース */ export interface APIResponse<T> { items: T[]; } /** (共通)priority定義 */ export type TodoPriority = 1 | 2 | 3 | 4 | 5; export namespace Tables { /** Todoリストのレコード */ export interface TodoRecord { id: string; title: string; description?: string; done: number; priority?: TodoPriority; } } /** Todoリストのアイテム */ export interface TodoItem { id: string; title: string; description?: string; priority?: TodoPriority; done: boolean; } export namespace StatusCode { export const OK = 200; export const BAD_REQUEST = 400; export const INTERNAL_SERVER_ERROR = 500; } export namespace ContentType { export const APPLICATION_JSON = 'application/json;charset=UTF-8'; }
event.pathParameters
から、パスパラメーターを取得することができます。これは、SAMの設定で
Path: /todo/{id}
とした部分の {} 内がパスパラメーター扱いになります。もし、クエリパラメーターを取得したい場合は、
event.queryStringParameters
から取得することができます。ここで取得したTodoのIDを用いて、DynamoDBからレコードをGetします。
AWSのJavaScriptSDKで用意されている、DynamoDBへのアクセスを行う
DocumentClient
は、DBアクセスの非同期処理をPromiseで返してくれるよう実装されていますので、async/awaitを利用した実装ができます。DBへのアクセス処理そのものは、これまたDAOを作る感覚で別ファイルへ切り出しています。
src/db/todo.ts
import * as AWS from 'aws-sdk'; import {DocumentClient} from 'aws-sdk/lib/dynamodb/document_client'; import {Tables} from '../types/todo_api'; const TABLE_NAME = 'tbl_todo'; export const getTodo = async (id: string): Promise<Tables.TodoRecord> => { const dynamoClient: DocumentClient = new AWS.DynamoDB.DocumentClient(); const param: DocumentClient.GetItemInput = { TableName: TABLE_NAME, Key: {id} }; const record: DocumentClient.GetItemOutput = await dynamoClient.get(param).promise(); return record.Item as Tables.TodoRecord; };
さて、ここで思い出してほしいのは、SAMに記載した
Environment
の設定です。これがどこに効いてくるかというと、 functionsの実装にある
localConfigure()
の処理で効いてきます。その実装は以下のとおり。
src/functions/localConfigure.ts
import * as AWS from 'aws-sdk'; /** * SAMローカル実行時の設定 */ export default function localConfigure(): void { if (process.env.AWS_SAM_LOCAL) { AWS.config.update( { region: process.env.REGION, endpoint: process.env.LOCAL_URL }, true ); } }
process.env.AWS_SAM_LOCAL = true
となっています。このとき、configとしてendpointを指定してあげないと、AWSのSDKはどの場所のDynamoDBを見ればよいかわからず、DynamoDBへアクセスできない状態になります。
そのため、ローカルで実行する場合のみ、このconfig更新をしてあげる必要があります。
※実際にAWS環境で動かす場合は、同じアカウントの同じリージョンの情報を参照するので、これらの設定は不要です。
ここで指定した
REGION
と LOCAL_URL
は、SAMの Environment
に指定した値が有効となります。では、SAMのEnvoronment側の値はどのように行うかというと、AWS SAM CLIでの実行時引数としてパラメーターを与えることができます。
このパラメーターは、以下のようなJSONとして定義しておきます。
sam/dev/env.json
{ "getTodo": { "LOCAL_URL": "http://${YOUR_IP_ADDRESS}:8000", "REGION": "ap-northeast-1" } }
$ sam local start-api -p 3001 -t ./sam/dev/template.yaml --env-vars ./sam/dev/env.json
-
env.json
の "getTodo" の設定が、SAMのテンプレート内で"getTodo"と命名したブロック内のEnvironment
に適用される - SAMのテンプレート内の
Environment
が、Lambda実行時のprocess.env
に反映される
なお、env.jsonで指定するローカルのアドレスは、
localhost
指定ではなく、自身のマシンのネットワーク上のIPアドレスである必要があります。(SAMで実行しているLambdaもDockerコンテナ上で実行されているので、localhostで見る場所が変わってしまうためだと思われます。)
さて、それではTodoのアイテムを取得してみましょう。
$ curl -X GET http://127.0.0.1:3001/todo/todo0001 {"items":[{"id":"todo0001","title":"Todo No.1","description":"Todo Item 01","done":true}]}
同じ容量で、
POST /todo
も作っていきます。src/functions/post_todo/index.ts
import {APIGatewayEvent, Context, Callback} from 'aws-lambda'; import {Tables} from '../../types/todo_api'; import localConfigure from '../localConfigure'; import {insertTodo} from '../../db/todo'; localConfigure(); const handler = async (event: APIGatewayEvent, context: Context, callback: Callback) => { const todoId = new Date().getTime(); // 実際のIDはTimestampでやることはないですが… const reqBody = JSON.parse(event.body); const insertRecord: Tables.TodoRecord = { id: `${todoId}`, title: reqBody.title, done: 0 }; if (reqBody.description) { insertRecord.description = reqBody.description; } if (reqBody.priority) { insertRecord.priority = reqBody.priority; } const success = await insertTodo(insertRecord); if (success) { callback(null, { statusCode: 200, body: JSON.stringify(insertRecord) }); } else { callback(null, { statusCode: 500, body: JSON.stringify({ message: 'internal server error' }) }); } }; export {handler};
src/db/todo.ts
export const insertTodo = async (record: Tables.TodoRecord): Promise<boolean> => { const dynamoClient: DocumentClient = new AWS.DynamoDB.DocumentClient(); const param: DocumentClient.PutItemInput = { TableName: TABLE_NAME, Item: record }; console.log(param); let ret = true; try { await dynamoClient.put(param).promise(); } catch (e) { console.log(e); ret = false; } return ret; };
env.json
の修正もわすれずに。sam/dev/env.json
{ "getTodo": { "LOCAL_URL": "http://${YOUR_IP_ADDRESS}:8000", "REGION": "ap-northeast-1" }, "postTodo": { "LOCAL_URL": "http://${YOUR_IP_ADDRESS}:8000", "REGION": "ap-northeast-1" } }
$ curl -X POST http://127.0.0.1:3001/todo \ -d '{ "title": "from API", "description": "any message?", "priority": 5 }' {"id":"1542444222979","title":"from API","done":0,"description":"any message?","priority":5} # GETできるか確認 $ curl -X GET http://127.0.0.1:3001/todo/1542444222979 {"items":[{"id":"1542444222979","title":"from API","description":"any message?","done":false,"priority":5}]}
最終的なプロジェクト構成
以下のようになりました。. ├── package-lock.json ├── package.json ├── sam │ └── dev │ ├── dynamo │ │ ├── put_items.json │ │ ├── setup.sh │ │ └── tbl_todo.json │ ├── env.json │ └── template.yaml ├── src │ ├── db │ │ └── todo.ts │ ├── functions │ │ ├── localConfigure.ts │ │ └── main │ │ ├── get_ping │ │ │ └── index.ts │ │ ├── get_todo │ │ │ └── index.ts │ │ └── post_todo │ │ └── index.ts │ └── types │ └── todo_api.ts ├── tsconfig.json ├── tslint.json └── webpack.config.ts
まとめ
いかがでしたでしょうか?サーバーレスアプリケーションの開発では、とくにLambdaやDynamoDBが絡む部分が、マネジメントコンソール上だけでは非常に開発がしづらい部分かと思います。
これらをローカルで手軽に開発・実行できる環境が整うだけで、サーバーレスアプリケーションの開発のハードルが下がるのではないかと思っています。
ここで開発したものを実際にAWS環境へデプロイするためには、もうひと手間加える必要があります。
- API Gateway側の定義をつくる
- Swaggerを用いてAPI仕様を記述し、各エンドポイントに対して名前をつけておく
- SAMのテンプレートで、対応するAPIのエンドポイントの設定にSwaggerでつけた名前を反映
- CloudFormationを用いて、パッケージ化+デプロイ
ですが、ローカルで構築したものをほぼ流用できるので、Swaggerの定義をつくること以外はほとんど手間にならないと思います。
環境ごとにSAMのテンプレートファイルを分割しなければならないので、そこだけがイケてない部分かなぁと思います…
ここについては、未だに良い方法が思い浮かんでないので、なにかアイデアがある方は共有いただけると嬉しいです!
EC2インスタンスを立ち上げてAPIを作るよりも、サーバーレスのほうが低コストで気軽に試すことができるのが大きなメリットになります。
なにか小さなシステムで利用するには最適ですので、ぜひお試しください!
コメント
コメントを投稿