0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Serverless Framework + TypeScript(esbuild)で ローカル開発環境構築と、AWS(API Gateway + Lambda + DynamoDB)へのデプロイをしてみる

Last updated at Posted at 2025-01-14

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 を使用するので削除

ローカル起動コマンドの追加

package.json
{
  "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化させる

serverless.yml
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を新規作成する

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 を新規作成する。

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」を新規作成する。

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」を新規作成する。

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つのコマンドを追加

package.json
{
  "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 に 型チェックコマンドを追加

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 にプラグインを追加

serverless.yml
 :
 :
plugins:
    - serverless-dynamodb <===== 追加する
    - serverless-esbuild
    - serverless-offline

package.jsonにDynamoDB関連コマンドを追加

package.json
 :
 :
    "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ロールとテーブルの定義

serverless.yaml
 :
 :
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 ディレクトリを作り、その中に以下環境変数ファイルを作る。

src/config/local.yml
ENV: local
TZ: Asia/Tokyo
src/config/dev.yml
ENV: dev
TZ: Asia/Tokyo

serverless.ymlに以下追記

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 ファイルを新規作る。

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 ファイルを作る。

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を以下のように書き換える

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章. バッチ処理を作ってみる

バッチの起動設定

serverless.yml
 :
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 を作る

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の修正

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に以下を追加

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 を作る

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
admin.png

参考

以上

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?