Serverless Frameworkを使ったローカル環境構築と、AWSへのデプロイ方法をまとめました。
言語はTypeScript、バンドラーにはesbuildを使っています。
結構長くなったので、各章ごとに分けました。
1章~4章までやれば、API Gateway + Lambda + DynamoDB を使った簡単なAPI/バッチ/管理画面が作れます。
目次
1章. ローカル開発環境を構築し、AWSにデプロイしてみる
2章. DynamoDBを使って、データの登録/更新/削除 をしてみる
3章. バッチ処理を作ってみる
4章. 簡易的な管理画面を作ってみる
1章. ローカル開発環境を構築し、AWSにデプロイしてみる
開発環境
OS
- Windows11上のWSL2で動作するUbuntu
以下がインストールされている事
- node.js(v20.11.0以上)
- npm(v10.2.4以上)
- Java(v11.0.25以上)
準備
デプロイ先のAWSでIAMユーザーを作成しておく。その際の権限は「AdministratorAccess」にしておく。
次に以下のようにローカルでAWSプロファイルを作成する。
$ aws configure --profile sample-profile <===== sample-profileとしているがお好みで
AWS Access Key ID [None]: <上記で作成したIAMのアクセスキーを入力>
AWS Secret Access Key [None]: <上記で作成したIAMのシークレットキーを入力>
Default region name [None]: ap-northeast-1 <===== 東京リージョンを指定
Default output format [None]: json <===== jsonを指定
プロジェクトの作成
任意ディレクトリに移り、以下を実行して「プロジェクト」を作成する
$ cd 任意のディレクトリ
$ npx serverless <===== 入力してEnter
? What do you want to make?
AWS - Node.js - Starter
AWS - Node.js - HTTP API
AWS - Node.js - Scheduled Task
AWS - Node.js - SQS Worker
❯ AWS - Node.js - Express API <===== 選択してEnter
AWS - Node.js - Express API with DynamoDB
AWS - Python - Starter
AWS - Python - HTTP API
AWS - Python - Scheduled Task
AWS - Python - SQS Worker
AWS - Python - Flask API
AWS - Python - Flask API with DynamoDB
Other
? What do you want to make? AWS - Node.js - Express API
? What do you want to call this project? sample-api <===== プロジェクト名「sample-api」と入力してEnter
✔ Project successfully created in sample-api folder
? Register or Login to Serverless Framework (Y/n) <===== Y を入力してEnter(ブラウザで「serverless framework」が立ち上がるので、RegisterまたはLoginしておく)
Logging into the Serverless Framework via the browser
If your browser does not open automatically, please open this URL:
https://app.serverless.com?client=cli&transactionId=Cp1NijbiQh_jggbWDHDvL
✔ You are now logged into the Serverless Framework
✔ Your project is ready to be deployed to Serverless Dashboard (org: "xxxxxx", app: "sample-api")
? Do you want to deploy now? (Y/n) <===== n を入力してEnter
What next?
Run these commands in the project directory:
serverless deploy Deploy changes
serverless info View deployed endpoints and resources
serverless invoke Invoke deployed functions
serverless --help Discover more commands
プロジェクトの確認
作成したプロジェクトディレクトリに移動し、treeコマンドでプロジェクトの初期構成を確認する。
$ cd sample-api <===== 入力してEnter
$ tree -L 1 <===== 入力してEnter(treeコマンドが無い場合には sudo apt install tree -y でインストールする)
├── README.md
├── index.js
├── node_modules
├── package-lock.json
├── package.json
└── serverless.yml
$cat package.json
↓ 初期のpackage.json。ライブラリをインストールする度に、ここに自動で追記されていく。
{
"name": "sample-api",
"version": "1.0.0",
"description": "",
"dependencies": {
"express": "^4.18.2",
"serverless-http": "^3.1.1"
}
}
各種ライブラリのインストール
$ npm i -D serverless@3 <===== serverless ver3 コマンド
$ npm i -D serverless-offline@13 <===== serverlessをローカルで使う為(@13はserverless ver3に対応したバージョン13の意味)
$ npm i -D esbuild serverless-esbuild typescript @types/express @types/node <===== TypeScript化用
$ npm i @vendia/serverless-express body-parser <===== serverless express用
$ npm i -D @types/aws-lambda
$ npm i date-fns
$ npm rm serverless-http <===== serverless express を使用するので削除
ローカル起動コマンドの追加
{
"name": "sample-api",
"version": "1.0.0",
"description": "",
"scripts": { <====== 追記する
"dev": "serverless offline" <====== 追記する
},
"dependencies": {
"@vendia/serverless-express": "^4.12.6",
"body-parser": "^1.20.3",
"date-fns": "^4.1.0",
"express": "^4.18.2"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.147",
"@types/express": "^5.0.0",
"@types/node": "^22.10.5",
"esbuild": "^0.24.2",
"serverless": "^3.40.0",
"serverless-esbuild": "^1.54.6",
"serverless-offline": "^13.9.0",
"typescript": "^5.7.3"
}
}
}
esbuildを使ってTypeScript化させる
org: xxxxxx
app: sample-api
service: sample-api
frameworkVersion: "3"
↓ここから追記
package:
individually: true
custom:
defaultStage: local
esbuild:
bundle: true
minify: false
sourcemap: false
exclude: ["aws-sdk"]
target: "node20" <===== インストールされているnode.jsのバージョン
define: { "require.resolve": undefined }
platform: "node"
concurrency: 10
watch:
pattern: ["src/**/*.ts"]
ignore: ["temp/**/*"]
region:
local: "local"
dev: "ap-northeast-1"
↑ここまで
provider:
name: aws
↓ここから追記
logs:
restApi: false
region: ${self:custom.region.${opt:stage, 'local'}}
stage: ${opt:stage, self:custom.defaultStage}
↑ここまで
runtime: nodejs20.x <===== nodejs20 に変更(Lambda側はこのバージョンでデプロイされる)
functions:
# API処理
api:
↓ここから変更
handler: src/api/index.handler
events:
- http:
method: ANY
path: "/"
cors: true
- http:
method: ANY
path: "/{any+}"
cors: true
↑ここまで
plugins: <===== 追記する
- serverless-esbuild <===== 追記する
- serverless-offline <===== 追記する
tsconfig.jsonの作成
serverless.ymlと同じディレクトリに、tsconfig.jsonを新規作成する
{
"compilerOptions": {
"lib": ["ESNext"],
"moduleResolution": "node",
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"preserveConstEnums": true,
"strictNullChecks": true,
"allowJs": true,
"sourceMap": false,
"target": "ES2020",
"outDir": "lib"
},
"include": ["src/**/*.ts", "serverless.ts"],
"exclude": ["node_modules/**/*", ".serverless/**/*", ".webpack/**/*", "_warmup/**/*", ".vscode/**/*"]
}
APIファイルの作成
serverless.ymlのあるディレクトリから、index.js を削除しておく。
その後、src/api/フォルダを新規作成し、その中に index.ts を新規作成する。
import serverlessExpress from "@vendia/serverless-express";
import express, { Request, Response, NextFunction } from "express";
import bodyParser from "body-parser";
const app = express();
const router = express.Router();
router.use(bodyParser.json());
router.use(bodyParser.urlencoded({ extended: true }));
router.get("/user", async (_req: Request, res: Response, _next: NextFunction) => {
const json = { message: "Hello from path!" };
res.status(200).json(json);
});
// 404エラーハンドラ(未定義ルート用)
router.use((_req: Request, res: Response) => {
res.status(404).json({
error: "404 Not Found",
});
});
// 500エラーハンドラ(すべてのルートの後に配置)
router.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
console.error(err.stack);
res.status(500).send("System Error");
});
app.use("/", router);
export const handler = serverlessExpress({ app });
ローカル環境を起動してみる
$ npm run dev <===== 入力してEnter
> sample-api@1.0.0 dev
> serverless offline
Warning: Invalid configuration encountered
at 'provider.region': must be equal to one of the allowed values [us-east-1, us-east-2, us-gov-east-1, us-gov-west-1, us-iso-east-1, us-iso-west-1, us-isob-east-1, us-west-1, us-west-2, af-south-1, ap-east-1, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2, ap-southeast-1, ap-southeast-2, ap-southeast-3, ap-southeast-4, ca-central-1, cn-north-1, cn-northwest-1, eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2, eu-west-3, il-central-1, me-central-1, me-south-1, sa-east-1]
Learn more about configuration validation here: http://slss.io/configuration-validation
Starting Offline at stage local (local)
Offline [http for lambda] listening on http://localhost:3002
Function names exposed for local invocation by aws-sdk:
* api: sample-api-local-api
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ANY | http://localhost:3000/local │
│ POST | http://localhost:3000/2015-03-31/functions/api/invocations │
│ ANY | http://localhost:3000/local/{any*} │
│ POST | http://localhost:3000/2015-03-31/functions/api/invocations │
│ │
└───────────────────────────────────────────────────────────────────────┘
Server ready: http://localhost:3000 🚀
ローカル環境のAPIに接続してみる
$ curl http://localhost:3000/local/user <===== 入力してEnter
{"message":"Hello from path!"} <===== 表示されればOK
AWSにdev環境としてデプロイしてみる
$ serverless deploy --stage dev --aws-profile sample-profile <===== 入力してEnter
すると、<プロジェクト名>-<環境名>-api という名前のLambda関数ができる。
例)sample-api-dev-api
また、<環境名>-<プロジェクト名> という名前のAPI Gatewayができる。
例)dev-sample-api
デプロイしたAPIに接続してみる
API Gatewayのコンソールに入り、左ペイン「ステージ」をクリックして、ステージ「dev」の「URLを呼び出す」に記載のURLをコピー。
curl <コピーしたURL>/user <====== 入力してEnter
{"message":"Hello from path!"} <===== 表示されればOK
デプロイ用コマンドの作成
AWSへのデプロイもコマンド1つで簡単に出来てしまいますが、それがかえって怖いので、
AWS環境へのデプロイおよび環境削除については、間違ってデプロイしないように、Yes/Noの確認をするロジックを入れたいと思います。
sample-apiディレクトリに入り、まずはデプロイ用スクリプト「confirmDeploy.js」を新規作成する。
const readline = require("readline");
const { exec } = require("child_process");
const environment = process.argv[2]; // 第一パラメーターを環境名として取得
const profile = process.argv[3]; // 第二パラメーターをプロファイル名として取得
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(`${environment}へデプロイしますか? (yes/no) `, (answer) => {
if (answer.toLowerCase() === "yes") {
exec(`serverless deploy --stage ${environment} --aws-profile ${profile}`, (error, stdout, stderr) => {
if (error) {
console.log(`error: ${error.message}`);
return;
}
if (stderr) {
console.log(`stderr: ${stderr}`);
return;
}
console.log(`stdout: ${stdout}`);
});
} else {
console.log("キャンセルしました。");
}
rl.close();
});
次に同じ場所に、環境削除用スクリプト「confirmRemove.js」を新規作成する。
const readline = require("readline");
const { exec } = require("child_process");
const environment = process.argv[2]; // 第一パラメーターを環境名として取得
const profile = process.argv[3]; // 第二パラメーターをプロファイル名として取得
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(`${environment}を削除しますか? (yes/no) `, (answer) => {
if (answer.toLowerCase() === "yes") {
exec(`serverless remove --stage ${environment} --aws-profile ${profile}`, (error, stdout, stderr) => {
if (error) {
console.log(`error: ${error.message}`);
return;
}
if (stderr) {
console.log(`stderr: ${stderr}`);
return;
}
console.log(`stdout: ${stdout}`);
});
} else {
console.log("キャンセルしました。");
}
rl.close();
});
package.json に上記2つのコマンドを追加
{
"name": "sample-api",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "serverless offline", <===== カンマ「,」を追記する
"deploy:dev": "node confirmDeploy.js dev sample-profile", <===== 追記する
"remove:dev": "node comfirmRemove.js dev sample-profile" <===== 追記する
},
:
:
これで、デプロイするときには、npm run deploy:dev と入力すれば「デプロイしますか?」と聞いてきて、逆に削除するときは、npm run deploy:remove と入力すれば「削除しますか?」と聞いてきてくれます。
誤ってデプロイor削除しないように事前に確認できます。
TypeScriptの型チェックコマンド
esbuildはTypeScriptの型チェックをしてくれる訳ではないので、型チェックコマンドも追加します。
package.json に 型チェックコマンドを追加
{
"name": "sample-api",
"version": "1.0.0",
"description": "",
"scripts": {
"type-check": "tsc --noEmit", <===== 追記する
"dev": "serverless offline",
},
:
:
これで「npm run type-check」と入力すると型チェックができます。
今のところのディレクトリ構成は以下になります。
├── README.md
├── confirmDeploy.js
├── confirmRemove.js
├── node_modules
├── package-lock.json
├── package.json
├── serverless.yml
├── src
└── tsconfig.json
2章. DynamoDBを使って、データの登録/更新/削除 をしてみる
DynamoDBにはRDMSのようなユニーク制約が無く、間違って同じレコードを重複登録しないように、疑似的なユニーク制約も作ってみます。
DynamoDBには以下のようなテーブルを作成します。
また、signatureで検索できるようにするため、GSIも作成します。
テーブル名には環境変数を使用して、環境毎のプレフィックスを指定します。
{
"id": "<UUID>", ※パーティションキー
"signature": "<ユーザーを識別する署名>", ※今回は署名の改ざんチェックなどはなく、単純な識別子とします。
"name": "山田",
"gender": "男性",
"created_at": "2024-01-16 01:00:00",
"updated_at": "2024-01-16 01:00:00"
}
DynamoDBと必要なライブラリをインストール
$ npm i @aws-sdk/client-dynamodb uuid
$ npm i -D serverless-dynamodb
serverless.yml にプラグインを追加
:
:
plugins:
- serverless-dynamodb <===== 追加する
- serverless-esbuild
- serverless-offline
package.jsonにDynamoDB関連コマンドを追加
:
:
"scripts": {
"type-check": "tsc --noEmit",
"dev": "serverless offline",
"deploy:dev": "node confirmDeploy.js dev sample-profile",
"remove:dev": "node comfirmRemove.js dev sample-profile", <===== カンマ「,」を追加する
"dynamodb:install": "serverless dynamodb install", <===== 追加する
"dynamodb:start": "serverless dynamodb start", <===== 追加する
"dynamodb:migrate": "serverless dynamodb migrate" <===== 追加する
},
:
:
DynamoDBの初期化
$ npm run dynamodb:install <===== 入力してEnter
> sample-api@1.0.0 dynamodb:install
> serverless dynamodb install
Installing DynamoDB Local from https://d1ni2b6xgvw0s0.cloudfront.net/dynamodb_local_latest.tar.gz...
Installation of DynamoDB Local complete.
serverless.yml にIAMロールとテーブルの定義
:
:
custom:
defaultStage: local
esbuild:
bundle: true
minify: false
sourcemap: false
exclude: ["aws-sdk"]
target: "node20"
define: { "require.resolve": undefined }
platform: "node"
concurrency: 10
watch:
pattern: ["src/**/*.ts"]
ignore: ["temp/**/*"]
↓ここから追記
dynamodb:
start:
port: 8000
inMemory: false
migrate: false
dbPath: "./.dynamodb"
↑ここまで
region:
local: "local"
dev: "ap-northeast-1"
provider:
name: aws
logs:
restApi: false
region: ${self:custom.region.${opt:stage, 'local'}}
stage: ${opt:stage, self:custom.defaultStage}
runtime: nodejs20.x
↓ここから追記
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
Resource:
- Fn::GetAtt: [User, Arn]
- !Sub "${User.Arn}/index/*"
- Fn::GetAtt: [Signature, Arn]
↑ここまで
:
:
plugins:
- serverless-dynamodb
- serverless-esbuild
- serverless-offline
↓ここから追記
resources:
Resources:
User: <===== 更新対象のテーブル
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.stage}-users
AttributeDefinitions:
- AttributeName: id
AttributeType: S
- AttributeName: signature
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
GlobalSecondaryIndexes:
- IndexName: signature_gsi
KeySchema:
- AttributeName: signature
KeyType: HASH
Projection:
ProjectionType: ALL
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
Signature: <===== 疑似的なユニーク制約を実現する為のテーブル
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.stage}-signatures
AttributeDefinitions:
- AttributeName: stext
AttributeType: S
KeySchema:
- AttributeName: stext
KeyType: HASH
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
↑ここまで
signature はクライアントから送信され、DB内では必ず一意になるようにします。
しかしながら DynamoDBのテーブル仕様の特性上、ユニーク制約が無いため、以下に記す「疑似的なユニーク制約」を実装します。
疑似的なユニーク制約とは
具体的には、usersテーブル(idをパーティションキー)と、signaturesテーブル(stextをパーティションキー)としたテーブルをトランザクションで囲って
signatures.stext が同じものが2つ以上登録されたら例外処理ではじいてトランザクションを終了することにより、usersテーブルの更新もはじくようにします。
※textは予約語なので、stextにしました。
DynamoDBの起動
別ウィンドウを開き以下を入力
$ npm run dynamodb:start <===== 入力してEnter
> sample-api@1.0.0 dynamodb:start
> serverless dynamodb start
Initializing DynamoDB Local with the following configuration:
Port: 8000
InMemory: false
Version: 1.25.1
DbPath: /home/chinone/xxxxxx/xxxxxx/xxxxxx/sample-api/.dynamodb
SharedDb: true
shouldDelayTransientStatuses: true
CorsParams: *
DynamoDBテーブルの作成(migrate)
別ウィンドウを開き以下を入力
$ npm run dynamodb:migrate <===== 入力してEnter
DynamoDB - created table local-signatures
DynamoDB - created table local-users
DynamoDB-ADMINの起動
別ウィンドウを開き以下を入力
$ npm install dynamodb-admin <===== 入力してEnter(インストール)
$ DYNAMO_ENDPOINT=http://localhost:8000 dynamodb-admin <===== 入力してEnter(起動)
ブラウザを http://localhost:8001/ で開いて画面表示できたら成功
local-sigunatures と local-users テーブルが出来ていればOK
DynamoDBローカルの注意点
以下のページでも言われていますが、DynamoDBローカルはパーティションキーを指定しているにも関わらず、稀に重複登録されることがあります。
その際はこちらのページを参考ください。
https://qiita.com/nozaki-amazia/items/c8c58f7647c4fec927ce
環境変数の設定
src ディレクトリの下に config ディレクトリを作り、その中に以下環境変数ファイルを作る。
ENV: local
TZ: Asia/Tokyo
ENV: dev
TZ: Asia/Tokyo
serverless.ymlに以下追記
:
region:
local: "local"
dev: "ap-northeast-1"
↓ここから追記
otherfile:
environment:
local: ${file(./src/config/local.yml)}
dev: ${file(./src/config/dev.yml)}
↑ここまで
:
functions:
# API処理
api:
handler: src/api/index.handler
↓ここから追記
timeout: 30 <===== APIのタイムアウト拡張
environment:
ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
↑ここまで
:
Modelファイルの実装
src ディレクトリの下に model ディレクトリを作り、その中に User.ts ファイルを新規作る。
import { DynamoDBClient, QueryCommand, QueryCommandInput, UpdateItemCommand, ReturnValue, DeleteItemCommand, ScanCommand, ScanCommandInput, ScanCommandOutput } from "@aws-sdk/client-dynamodb";
import { TransactWriteCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { Request } from "express";
import { v4 as uuidv4 } from "uuid";
import { format } from "date-fns";
export class User {
private userTableName: string = process.env.ENV + "-users";
private signatureTableName: string = process.env.ENV + "-signatures";
private indexName: string = "signature_gsi";
private client: DynamoDBClient;
private documentClient: DynamoDBDocumentClient;
constructor() {
if (global.dynamodbClient) {
this.client = global.dynamodbClient; // <===== https://qiita.com/chinone0711/items/13d57f9dc6cd13e5a2b8 の対応
} else {
this.client = new DynamoDBClient({
region: process.env.AWS_REGION,
});
}
this.documentClient = DynamoDBDocumentClient.from(this.client);
}
/**
* SignatureからユーザーIDを取得
* @param signature
* @returns
*/
getIdBySignature = async (signature: string) => {
let ret: string = "";
const params: QueryCommandInput = {
TableName: this.userTableName,
IndexName: this.indexName,
KeyConditionExpression: "#gsiKey = :gsiValue",
ExpressionAttributeNames: {
"#gsiKey": "signature",
},
ExpressionAttributeValues: {
":gsiValue": { S: signature },
},
};
try {
const command = new QueryCommand(params);
const data = await this.client.send(command);
if (data && data.Items) {
for (const item of data.Items) {
ret = item.id.S?.toString() || "";
break;
}
}
} catch (err) {
console.error("Unable to query. Error users(1):", JSON.stringify(err, null, 2));
throw new Error(`Unable to query. Error users(1): ${err.message}`);
}
return ret;
};
/**
* ユーザーを作成
* @param signature
* @param name
* @param gender
* @returns
*/
createUser = async (signature: string, name: string, gender: string) => {
let ret: string = "";
const id = uuidv4();
const date = new Date();
const now = format(date, "yyyy-MM-dd HH:mm:ss");
const params = {
TransactItems: [
{
Put: {
TableName: this.userTableName,
Item: {
id: id,
signature: signature,
name: name,
gender: gender,
created_at: now,
updated_at: now,
},
// ユニーク制約:id が存在しないことを確認
ConditionExpression: "attribute_not_exists(id)",
},
},
{
Put: {
TableName: this.signatureTableName,
Item: {
stext: signature,
},
// ユニーク制約:stext が存在しないことを確認
ConditionExpression: "attribute_not_exists(stext)",
},
},
],
};
try {
const command = new TransactWriteCommand(params);
await this.documentClient.send(command);
ret = id;
} catch (err) {
console.error("Unable to put item. Error users(2):", JSON.stringify(err, null, 2));
throw new Error(`Unable to put item. Error users(2): ${err.message}`);
}
return ret;
};
/**
* ユーザー名更新
* @param signature
* @param name
*/
updateUserById = async (req) => {
const userId = req.userId!;
const postData = req.body!;
const name = postData.user.name;
const gender = postData.user.gender;
const params = {
TableName: this.userTableName,
Key: {
id: { S: userId },
},
ExpressionAttributeNames: {
"#Y1": "name",
"#Y2": "gender",
"#Y3": "updated_at",
},
ExpressionAttributeValues: {
":y1": { S: name },
":y2": { S: gender },
":y3": { S: format(new Date(), "yyyy-MM-dd HH:mm:ss") },
},
UpdateExpression: "SET #Y1 = :y1, #Y2 = :y2, #Y3 = :y3",
ReturnValues: "UPDATED_NEW" as ReturnValue,
};
try {
const command = new UpdateItemCommand(params);
this.documentClient.send(command);
} catch (err) {
console.error("Unable to put item. Error users(3):", JSON.stringify(err, null, 2));
throw new Error(`Unable to put item. Error users(3): ${err.message}`);
}
};
/**
* ユーザー削除
* @param signature
*/
deleteUserById = async (req: Request) => {
const signature = req.headers["signature"]!.toString();
const userId = req.userId!;
const userParams = {
TableName: this.userTableName,
Key: {
id: { S: userId },
},
};
const signatureParams = {
TableName: this.signatureTableName,
Key: {
stext: { S: signature },
},
};
try {
const command1 = new DeleteItemCommand(userParams);
this.documentClient.send(command1);
const command2 = new DeleteItemCommand(signatureParams);
this.documentClient.send(command2);
} catch (err) {
console.error("Unable to delete item. Error users(4):", JSON.stringify(err, null, 2));
throw new Error(`Unable to delete item. Error users(4): ${err.message}`);
}
};
}
Serviceファイルの実装
src ディレクトリの下に service ディレクトリを作り、その中に SignatureService.ts ファイルを作る。
import { Request } from "express";
import { User } from "../model/User";
export class SignatureService {
/**
* SignatureからユーザーIDを取得、存在しない場合は新規作成
* @param req
* @returns
*/
getUserId = async (req: Request) => {
let userId: string = "";
const signature = req.headers["signature"];
if (signature !== undefined) {
if (typeof signature === "string") {
const user = new User();
userId = await user.getIdBySignature(signature);
if (userId === "") {
const postData = req.body!;
const name = postData.user.name;
const gender = postData.user.gender;
userId = await user.createUser(signature, name, gender);
console.log("ユーザーID:" + userId + " を作成。");
} else {
console.log("ユーザーID:" + userId + " を取得。");
}
}
}
return userId;
};
}
index.tsの修正
src/api/index.tsを以下のように書き換える
import serverlessExpress from "@vendia/serverless-express";
import express, { Request, Response, NextFunction } from "express";
import bodyParser from "body-parser";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { SignatureService } from "../service/SignatureService";
import { User } from "../model/User";
const app = express();
const router = express.Router();
router.use(bodyParser.json());
router.use(bodyParser.urlencoded({ extended: true }));
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
/**
* トップページ
* ※認証処理対象外
*/
router.get("/top", async (_req: Request, res: Response, _next: NextFunction) => {
const json = { message: "Top Page!" };
res.status(200).json(json);
});
/**
* 認証処理(API共通のミドルウェア)
* @param req
* @param res
* @param next
*/
router.use(async (req: Request, res: Response, next: NextFunction) => {
console.log("signature", req.headers["signature"]);
console.log("リクエスト", req.body);
const signatureService = new SignatureService();
const userId = await signatureService.getUserId(req);
if (userId) {
req.userId = userId;
next();
} else {
if (req.path === "/favicon.ico") {
res.status(201).json(null);
}
res.status(401).json({
error: "401 Unauthorized",
});
}
});
/**
* ユーザー作成orログイン
*/
router.post("/user", async (_req: Request, res: Response, _next: NextFunction) => {
const json = { message: "User create or login" };
res.status(200).json(json);
});
/**
* ユーザー更新
*/
router.patch("/user", async (req: Request, res: Response, _next: NextFunction) => {
const user = new User();
await user.updateUserById(req);
const json = { message: "User update" };
res.status(200).json(json);
});
/**
* ユーザー削除
*/
router.delete("/user", async (req: Request, res: Response, _next: NextFunction) => {
const user = new User();
await user.deleteUserById(req);
const json = { message: "User delete" };
res.status(200).json(json);
});
/**
* 404エラーハンドラ(未定義ルート用)
*/
router.use((_req: Request, res: Response) => {
res.status(404).json({
error: "404 Not Found",
});
});
/**
* 500エラーハンドラ(すべてのルートの後に配置)
*/
router.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
console.error(err.stack);
res.status(500).send("System Error");
});
app.use("/", router);
global.dynamodbClient = new DynamoDBClient({
region: process.env.AWS_REGION, // <===== https://qiita.com/chinone0711/items/13d57f9dc6cd13e5a2b8 の対応
});
export const handler = serverlessExpress({ app });
動作確認
再度 npm run dev を実行。
postman等のクライアントソフトで以下を設定して送信。
トップページの動作確認
URL | メソッド | ボディ(Json) | OK時の結果 |
---|---|---|---|
http://localhost:3000/local/top | GET | なし | { "message": "Top Page!" } レスポンスが返却 |
ユーザーの新規作成orログイン
URL | メソッド | ヘッダー | POSTボディ(Json) | OK時の結果 |
---|---|---|---|---|
http://localhost:3000/local/user | POST | signature に任意の値(※) |
{ "user": { "name": "山田", "gender": "男性" } } |
DynamoDBへの データ登録 |
ユーザー情報の更新
URL | メソッド | ヘッダー | PATCHボディ(Json) | OK時の結果 |
---|---|---|---|---|
http://localhost:3000/local/user | PATCH | ※と同じ値 | { "user": { "name": "鈴木", "gender": "女性" } } |
DynamoDBの データ更新 |
ユーザー情報の削除
URL | メソッド | ヘッダー | DELETEボディ(Json) | OK時の結果 |
---|---|---|---|---|
http://localhost:3000/local/user | DELETE | ※と同じ値 | なし | DynamoDBの データ削除 |
3章. バッチ処理を作ってみる
バッチの起動設定
:
functions:
# API処理
api:
handler: src/api/index.handler
timeout: 30
environment:
ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
events:
- http:
method: ANY
path: "/"
cors: true
- http:
method: ANY
path: "/{any+}"
cors: true
↓ここから追記
# バッチ処理
printPerMinute:
handler: src/batch/index.printPerMinute
timeout: 60
events:
- schedule: rate(1 minute)
environment:
ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
↑ここまで
:
バッチ処理本体の作成
srcディレクトリの下に batch ディレクトリを作りその中に index.ts を作る
import { format } from "date-fns";
/**
* バッチ処理サンプル
* @param event
*/
export const printPerMinute = async (_event: any): Promise<any> => {
console.log("1分周期で起動:" + format(new Date(), "yyyy-MM-dd HH:mm:ss"));
};
バッチ処理の起動
$ npm run dev
> sample-api@1.0.0 dev
> serverless offline
Warning: Invalid configuration encountered
at 'provider.region': must be equal to one of the allowed values [us-east-1, us-east-2, us-gov-east-1, us-gov-west-1, us-iso-east-1, us-iso-west-1, us-isob-east-1, us-west-1, us-west-2, af-south-1, ap-east-1, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2, ap-southeast-1, ap-southeast-2, ap-southeast-3, ap-southeast-4, ca-central-1, cn-north-1, cn-northwest-1, eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2, eu-west-3, il-central-1, me-central-1, me-south-1, sa-east-1]
Learn more about configuration validation here: http://slss.io/configuration-validation
Starting Offline at stage local (local)
Offline [http for lambda] listening on http://localhost:3002
Function names exposed for local invocation by aws-sdk:
* api: sample-api-local-api
* printPerMinute: sample-api-local-printPerMinute
Scheduling [printPerMinute] cron: [*/1 * * * *] <====== cron式でスケジューリングされている
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ANY | http://localhost:3000/local │
│ POST | http://localhost:3000/2015-03-31/functions/api/invocations │
│ ANY | http://localhost:3000/local/{any*} │
│ POST | http://localhost:3000/2015-03-31/functions/api/invocations │
│ │
└───────────────────────────────────────────────────────────────────────┘
↓このようなプリント文が表示されればOK
1分周期で起動:2025-01-10 17:57:00
(λ: printPerMinute) RequestId: 80332a53-98ba-4264-926c-64c4448c26ba Duration: 38.21 ms Billed Duration: 39 ms
Successfully invoked scheduled function: [printPerMinute]
1分周期で起動:2025-01-10 17:58:00
(λ: printPerMinute) RequestId: a741a7f3-c721-415c-9a95-bc0964f31cb3 Duration: 1.49 ms Billed Duration: 2 ms
Successfully invoked scheduled function: [printPerMinute]
AWSにデプロイすると、EventBridgeが自動設定されて、定期的にLambda関数を実行するようになる。
EventBridge名は
<プロジェクト名>-<環境名>-<Lambda関数名>EventsRuleSchedulexxxxxxxxxxxxx となる。
例)sample-api-dev-PrintPerMinuteEventsRuleSchedule1-t7ddqi1VnUha
ちなみに、ローカルで特定の関数を手動実行したい場合には以下コマンドになる。
$ npx serverless invoke local -f <バッチ関数>
4章. 簡易的な管理画面を作ってみる
ユーザーデータをCSVでダウンロードするWebページを作成します。
serverless.ymlの修正
:
functions:
↓ここから追記
# ADMIN処理
admin:
handler: src/admin/index.handler
timeout: 30
environment:
ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
events:
- http:
method: ANY
path: "/admin"
cors: true
response:
headers:
Content-Type: "'text/html'"
template: $input.path('$')
↑ここまで
# API処理
api:
handler: src/api/index.handler
:
Modelファイルの修正
src/model/User.tsに以下を追加
/**
* 全てのユーザー情報を取得
* ※DynamoDBは1回のScanの最大サイズが1MBなので、続きがある場合はLastEvaluatedKeyを使用して取得する
* @returns
*/
getAll = async () => {
let items: any[] = [];
let lastEvaluatedKey: { [key: string]: any } | undefined = undefined;
do {
const params: ScanCommandInput = {
TableName: this.userTableName,
ExclusiveStartKey: lastEvaluatedKey,
};
try {
const command = new ScanCommand(params);
const data: ScanCommandOutput = await this.client.send(command);
if (data.Items) {
items = items.concat(data.Items);
}
lastEvaluatedKey = data.LastEvaluatedKey;
} catch (err: any) {
console.error("Unable to scan. Error users(5):", JSON.stringify(err, null, 2));
throw new Error(`Unable to scan. Error users(5): ${err.message}`);
}
} while (lastEvaluatedKey);
return items;
};
必要なライブラリをインストール
$ npm i iconv-lite
$ npm i json2csv
管理画面本体のプログラムを作成
srcディレクトリの下に admin ディレクトリを作りその中に index.ts を作る
import { Parser } from "json2csv";
import * as iconv from "iconv-lite";
import { User } from "../model/User";
import querystring from "querystring";
module.exports.handler = async (event, _context) => {
const postData = querystring.parse(event.body);
let jsonData;
let outputFile = "";
let fields: string[] = [];
const user = new User();
// kind別の処理
if (postData.kind == "userdata") {
jsonData = await user.getAll();
console.log(jsonData);
for (let i = 0; i < jsonData.length; i++) {
jsonData[i].id = jsonData[i].id.S;
jsonData[i].name = jsonData[i].name.S;
jsonData[i].gender = jsonData[i].gender.S;
jsonData[i].created_at = jsonData[i].created_at.S;
}
fields = ["id", "name", "gender", "created_at"];
outputFile = "user.csv";
const json2csvParser = new Parser({ fields });
let csvData = json2csvParser.parse(jsonData);
csvData = iconv.encode(csvData, "Shift_JIS");
return {
statusCode: 200,
headers: {
"Content-Type": "text/csv; charset=Shift_JIS",
"Content-Disposition": "attachment; filename=" + outputFile,
"Content-Encoding": "base64",
},
body: csvData.toString("base64"),
isBase64Encoded: true,
};
}
var renderedPage = renderFullPage();
return {
statusCode: 200,
headers: { "Content-Type": "text/html" },
body: renderedPage,
};
};
function renderFullPage() {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="icon" href="/favicon.ico">
</head>
<body>
<a href="" style="text-decoration:none;" title="管理画面"><h1>管理画面</h1></a>
<h2>ユーザーデータのダウンロード</h2>
<form action="" method="post">
<input type="submit" value="ダウンロード">
<input type="hidden" name="kind" value="userdata">
</form>
</body>
</html>`;
}
動作確認
ブラウザで http://localhost:3000/local/admin にアクセスして以下のような画面が表示され、「ダウンロード」ボタンクリックで、user.csv がダウンロードできればOK
参考
以上