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)で GitLabのCI/CDを使って Git pushをトリガーにUnitテストとAWSへの自動デプロイをしてみる。

Last updated at Posted at 2025-02-27

Serverless Frameworkを使ったローカル環境構築、AWSデプロイ、GitLabを使ったCI/CDをまとめました。
言語はTypeScript、バンドラーにはesbuildを使っています。
以前書いた記事とかぶる部分がありますが、1つの記事にまとめたほうが見やすいので、CI/CDを参考にする場合にはこちらの記事をご覧ください。

目次

1章. ローカル開発環境を構築し、AWSにデプロイしてみる
2章. DynamoDBを使って、データの登録/更新/削除 をしてみる
3章. JestによるUnitテスト(ローカル版)
4章. JestによるUnitテスト&デプロイ(GitLabにPushでCI/CDが発火)

1章. ローカル開発環境を構築し、AWSにデプロイしてみる

開発環境

OS

  • Windows11上のWSL2で動作するUbuntu

以下がインストールされている事

  • node.js(v20.11.0以上)
  • npm(v10.2.4以上)
  • Java(v11.0.25以上)

前提

プロジェクト名は serverless-cicd として進めます。
AWSプロファイルは sample-profile として進めます。

準備

デプロイ先のAWSでIAMユーザーを作成しておく。その際の権限は「AdministratorAccess」にしておく。
次に以下のようにローカルでAWSプロファイルを作成する。

$ aws configure --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

GitLabでプロジェクトを作成して Cloneしておく

GitLabサイトでプロジェクトを新規作成し、その後 tmp_dir のディレクトリに cloneしておく。

$ git clone http://xxxxxxxxxxxxxx.git tmp_dir

Serverless Framework プロジェクトを作成

以下を入力して、serverless-cicd プロジェクトを作成する。

$ npx serverless      # 入力してEnter

Creating a new serverless project

? 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

Creating a new serverless project

? What do you want to make? AWS - Node.js - Express API
? What do you want to call this project? serverless-cicd     # 入力してEnter ※プロジェクト名を serverless-cicdとする

? What org do you want to add this service to? (Use arrow keys)
  sample     # sample=組織名[Skip]     # 選択してEnter

? Do you want to deploy now? (Y/n) n     # 入力してEnter

Git関連のファイルコピー

npx serverless コマンドで作成した serverless-cicd ディレクトリに、tmp_dir ディレクトリから Git関連ファイルを移動する。
その後不要になった tmp_dir ディレクトリ を削除しておく。

$ cd serverless-cicd
$ mv ../tmp_dir/.git .
$ mv ../tmp_dir/README.md .
$ rmdir ../tmp_dir

.gitignoreファイルへの追加

後ほど自動生成される「.dynamodb」「.esbuild」を .gitignoreに含める。

.gitignore
node_modules
.serverless
+ .dynamodb
+ .esbuild

GitLabにGit push

現時点のファイルを GitLabに git pushしておく。
また、現時点のファイル構成は以下となる。

$ tree -L 1
├── README.md
├── index.js
├── node_modules
├── package-lock.json
├── package.json
└── serverless.yml

現時点のpackage.jsonは以下になる

package.json
{
  "name": "serverless-cicd",
  "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          # Lambda関連
$ npm i date-fns                      # 日付関連
$ npm rm serverless-http              # serverless express を使用するので削除
$ npm i aws-cli                       # AWS CLI用
$ npm i @aws-sdk/client-dynamodb uuid # DynamoDB操作用、UUID用
$ npm i @aws-sdk/lib-dynamodb         # DynamoDB操作用
$ npm i -D serverless-dynamodb        # serverless DynamoDB
$ npm i dynamodb-admin                # DynamoDB-Admin用

Unitテスト用ライブラリのインストール

$ npm i -g jest
$ npm i -D jest @types/jest
$ npm i -D esbuild-jest
$ npm i -D supertest @types/supertest

package.jsonにコマンドを追加

package.json
{
  "name": "serverless-cicd",
  "version": "1.0.0",
  "description": "",
+ "scripts": {
+   "dev": "serverless offline"
  },
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.751.0",
    "@aws-sdk/lib-dynamodb": "^3.751.0",
    "@vendia/serverless-express": "^4.12.6",
    "aws-cli": "^0.0.2",
    "body-parser": "^1.20.3",
    "date-fns": "^4.1.0",
    "express": "^4.18.2",
    "uuid": "^11.1.0"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.147",
    "@types/express": "^5.0.0",
    "@types/jest": "^29.5.14",
    "@types/node": "^22.13.4",
    "@types/supertest": "^6.0.2",
    "esbuild": "^0.24.2",
    "esbuild-jest": "^0.5.0",
    "jest": "^29.7.0",
    "serverless": "^3.40.0",
    "serverless-esbuild": "^1.54.6",
    "serverless-offline": "^13.9.0",
    "supertest": "^7.0.0",
    "typescript": "^5.7.3"
  }
}

esbuildを使ってTypeScript化させる

serverless.yml
service: serverless-cicd
frameworkVersion: '3'

+ package:
+   individually: true
+ 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/**/*"]
+   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   # 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の新規作成

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/**/*"]
}

TypeScriptなので、index.jsは削除しておく

$ rm index.js

TypeScript用ファイル作成

$ mkdir -p src/api
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

> serverless-cicd@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: serverless-cicd-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

<プロジェクト名>-<環境名>-api という名前のLambda関数ができる。
例)serverless-cicd-dev-api

<環境名>-<プロジェクト名> という名前のAPI Gatewayができる。
例)dev-serverless-cicd

<プロジェクト名>-<環境名> という名前の CloudFormationスタックができる。
例)serverless-cicd-dev

デプロイしたAPIに接続してみる

API Gatewayのコンソールに入り、左ペイン「ステージ」をクリックして、ステージ「dev」の「URLを呼び出す」に記載のURLをコピー。

$ curl <コピーしたURL>/user      # 入力してEnter
{"message":"Hello from path!"}  # 表示されればOK

developブランチの作成

$ git branch develop
$ git checkout develop

その後コミット&プッシュしておく。以降はdevelopブランチで実施する。

環境構築(デプロイ)用と、環境削除用のコマンドの作成

AWSへのデプロイもコマンド1つで簡単に出来てしまいますが、それがかえって怖いので、
AWS環境へのデプロイおよび環境削除については、間違ってデプロイしないように、Yes/Noの確認をするロジックを入れたいと思います。

環境構築(デプロイ)用コマンドの新規作成

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に上記コマンドを追加

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",
    "deploy:dev": "node confirmDeploy.js dev sample-profile",
    "remove:dev": "node comfirmRemove.js dev sample-profile"
  },
    
    

これで「npm run type-check」と入力すると型チェックができます。

今のところのディレクトリ構成は以下になります。

$ tree -L 1
├── 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": "2025-02-26 00:00:00",
    "updated_at": "2025-02-26 00:00:00"
}

serverless.yml に DynamoDBプラグインを追加

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.    # 表示されればOK

serverless.yml にIAMロールとテーブルの定義を追加

serverless.yml
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

> serverless-cicd@1.0.0 dynamodb:start
> serverless dynamodb start

Initializing DynamoDB Local with the following configuration:
Port:   8000
InMemory:       false
Version:        1.25.1
DbPath: /xxxxxx/xxxxxx/xxxxxx/serverless-cicd/.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の起動

別ウィンドウを開き以下を入力

$ 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 ディレクトリを作る。

$ mkdir -p src/config

ローカル用の環境変数ファイルを新規作成

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

Dev用の環境変数ファイルを新規作成

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}    
        events:
            - http:
                  method: ANY
                  path: "/"
                  cors: true
            - http:
                  method: ANY
                  path: "/{any+}"
                  cors: true

Modelファイルの実装

src ディレクトリの下に model ディレクトリを作り、その中に User.ts ファイルを新規作成する。

$ mkdir -p src/model
src/model/User.ts
import { DynamoDBClient, QueryCommand, QueryCommandInput, UpdateItemCommand, ReturnValue, DeleteItemCommand } from "@aws-sdk/client-dynamodb";
import { TransactWriteCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
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 {
            if (process.env.ENV === "local") {
                const endpoint = process.env.CI ? "http://dynamodb:8000" : "http://localhost:8000";
                this.client = new DynamoDBClient({
                    region: process.env.AWS_REGION,
                    endpoint: endpoint, // <===== これを指定しないと、JestでlocalのDynamoDBアクセス時、AWS profile(./aws/credentials)のデフォルトを見てAWS側にアクセスしてしまうので注意!
                    credentials: {
                        accessKeyId: "dummy",
                        secretAccessKey: "dummy",
                    },
                });
                });
            } 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;
    };
    
    
    /**
     * IDからユーザーを取得
     * @param id
     * @returns
     */
    getById = async (id: string) => {
        let ret;
        const params: QueryCommandInput = {
            TableName: this.userTableName,
            KeyConditionExpression: "#key = :value",
            ExpressionAttributeNames: {
                "#key": "id",
            },
            ExpressionAttributeValues: {
                ":value": { S: id },
            },
        };

        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;
                    break;
                }
            }
        } catch (err) {
            console.error("Unable to query. Error users(2):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to query. Error users(2): ${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(3):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to put item. Error users(3): ${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(4):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to put item. Error users(4): ${err.message}`);
        }
    };

    /**
     * ユーザー削除
     * @param signature
     */
    deleteUserById = async (req) => {
        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(5):", JSON.stringify(err, null, 2));
            throw new Error(`Unable to delete item. Error users(5): ${err.message}`);
        }
    };
}

Serviceファイルの実装

src ディレクトリの下に service ディレクトリを作り、その中に SignatureService.ts ファイルを新規作成する。

$ mkdir -p src/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を以下のように書き換える。

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 });

APIの動作確認

再度 npm run dev を実行。
postman等のクライアントソフトで以下を設定して送信。

トップページの動作確認

名称
アクセスURL http://localhost:3000/local/top
メソッド GET
ボディ(Json) なし
OK時の結果 {
 "message": "Top Page!"
}

ユーザーの新規作成orログインの動作確認

名称
アクセスURL http://localhost:3000/local/user
メソッド POST
ヘッダー signatureに任意の値
ボディ(Json) {
 "user": {
  "name": "山田",
  "gender": "男性"
 }
}
OK時の結果 DynamoDBへのデータ登録

ユーザー情報の更新の動作確認

名称
アクセスURL http://localhost:3000/local/user
メソッド PATCH
ヘッダー signatureに任意の値
ボディ(Json) {
 "user": {
  "name": "鈴木",
  "gender": "女性"
 }
}
OK時の結果 DynamoDBのデータ更新

ユーザー情報の削除の動作確認

名称
アクセスURL http://localhost:3000/local/user
メソッド DELETE
ヘッダー signatureに任意の値
ボディ(Json) なし
OK時の結果 DynamoDBのデータ削除

3章. JestによるUnitテスト(ローカル版)

Jest関連ライブラリをインストール

$ npm i -g jest
$ npm i -D jest @types/jest
$ npm i -D esbuild-jest

Jestの設定ファイルを新規作成

jest.config.js
module.exports = {
  testEnvironment: 'node', // テスト環境
  transform: {
    "^.+\\.tsx?$": "esbuild-jest" // transformerにesbuild-jest
  },
  globalSetup: "./testEnv.ts", // テスト用の環境変数ファイルを取得
};

テスト用の環境変数ファイルを新規作成

※ローカルテストの環境は「local」とします。

testEnv.ts
export default (): void => {
    console.log("\nSetup test environment");
    process.env.ENV = "local";
    process.env.TZ = "Asia/Tokyo";
    process.env.AWS_REGION = "ap-northeast-1";
    console.log("process.env.ENV", process.env.ENV);
    console.log("process.env.TZ", process.env.TZ);
    console.log("process.env.AWS_REGION", process.env.AWS_REGION);
    return;
};

Userモデルの Unitテストを書いてみる

ローカルのDynamoDBに対して、以下を順番に実施します。

  1. User登録に関するテスト
  2. User取得に関するテスト
  3. User更新に関するテスト
  4. User削除に関するテスト

補足説明

describe : テストのグルーピングに使います。
it : testと同じ意味で、テスト関数になります。

テストディレクトリを作成する。

$ mkdir test

テストコードを新規作成する。

test/User.test.ts
import { User } from "../src/model/User"; // テストするModelを読み込みます
import { setTimeout } from "node:timers/promises"; // スリープ用のsetTimeoutを読み込みます

const USER_SIGNATURE = "test1234"; // 今回のSignature(本番データと被らなければなんでも良いです)

describe("User登録に関するテスト", () => {
    it("新規登録のテスト", async () => {
        const user = new User();

        // ユーザー作成
        const createdUserId = await user.createUser(USER_SIGNATURE, "山田", "男性");

        // SignatureからユーザーIDを取得
        const getUserId = await user.getIdBySignature(USER_SIGNATURE);

        // 作成したユーザーが存在するかチェック
        expect(createdUserId).toBe(getUserId);
    });

    it("二重登録エラーのテスト", async () => {
        const user = new User();

        // わざと同一のSignatureでユーザーを登録してエラーが出るかチェック
        try {
            const result = await user.createUser(USER_SIGNATURE, "山田", "男性");
        } catch (e) {
            // 許容するエラーメッセージ(Transaction cancelled ~)かチェック
            expect(e.message).toContain("Unable to put item. Error users(3): Transaction cancelled");
        }
    });
});

describe("User更新に関するテスト", () => {
    it("更新テスト", async () => {
        const user = new User();

        // SignatureからユーザーIDを取得
        const getUserId = await user.getIdBySignature(USER_SIGNATURE);

        // 更新後のユーザー情報
        const req = {
            userId: getUserId,
            body: {
                user: {
                    name: "鈴木",
                    gender: "女性",
                },
            },
        };

        // ユーザー情報の更新
        await user.updateUserById(req);

        // データ更新されるまで1秒待つ(ローカルのDynamoDBが更新に時間かかるので待つようにしました)
        await setTimeout(1000);

        // 更新されたユーザー情報をDBから取得
        const updatedUser = await user.getById(getUserId);

        // 正しく更新されたかチェック
        expect(updatedUser.name.S).toBe("鈴木");
        expect(updatedUser.gender.S).toBe("女性");
    });
});

describe("User削除に関するテスト", () => {
    it("削除テスト", async () => {
        const user = new User();

        // SignatureからユーザーIDを取得
        const getUserId = await user.getIdBySignature(USER_SIGNATURE);

        // 削除するユーザー情報
        const req = {
            headers: {
                signature: USER_SIGNATURE,
            },
            userId: getUserId,
        };

        // ユーザー情報の削除
        await user.deleteUserById(req);

        // データ削除されるまで1秒待つ(ローカルのDynamoDBが削除に時間かかるので待つようにしました)
        await setTimeout(1000);

        // 正しく削除されたかチェック(ユーザー情報が取れていない(空白)ならOK)
        const checkUserId = await user.getIdBySignature(USER_SIGNATURE);
        expect(checkUserId).toBe("");
    });
});

Unitテストコマンドの追加

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",
+       "test": "jest"
    },
     :
     :

Userモデルの Unitテストを実行してみる

$ npm run test   # 入力してEnter

> serverless-cicd@1.0.0 test
> jest

Determining test suites to run...
Setup test environment
process.env.ENV local
process.env.TZ Asia/Tokyo
process.env.AWS_REGION ap-northeast-1
 PASS  test/User.test.ts
  User登録に関するテスト
    ✓ 新規登録のテスト (283 ms)      # テスト成功
    ✓ 二重登録エラーのテスト (25 ms)  # テスト成功
  User更新に関するテスト
    ✓ 更新テスト (1017 ms)           # テスト成功
  User削除に関するテスト
    ✓ 削除テスト (1047 ms)           # テスト成功

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        2.792 s, estimated 3 s
Ran all test suites.

ソースを監視し、変更があると自動でテストを実行

以下コマンドを実行します。
すると、プログラム変更があったら自動でテストしてくれます。

$ jest --watchAll

4章. JestによるUnitテスト&デプロイ(GitLabにPushでCI/CDが発火)

GitLab CI/CD設定ファイルを新規作成

.gitlab-ci.yml
stages:
    - test
    - deploy

variables:
    ENV: "local"
    TZ: "Asia/Tokyo"
    DOCKER_TLS_CERTDIR: "" # TLSを無効化、これをしないとDockerデーモンがTLS経由で通信しようとし、エラーが発生する。
    DOCKER_HOST: tcp://docker:2375 # Dockerデーモンのホストを指定

# テスト
test-job:
    image: node:alpine3.20
    stage: test
    services:
        - name: docker:dind
          command: ["--tls=false"] # TLS 無効化
        - name: amazon/dynamodb-local
          alias: dynamodb #  GitLab Runner から `dynamodb:8000` でアクセス可能
    before_script:
        - echo "before_script"

        # ダミーのAWS認証情報(これが無いとaws-cliがエラーを出す)
        - export AWS_ACCESS_KEY_ID=dummy # User.tsの中にあるAWS_ACCESS_KEY_IDと同じダミー値でないとデータ取得できないので注意!
        - export AWS_SECRET_ACCESS_KEY=dummy # User.tsの中にあるAWS_ACCESS_KEY_IDと同じダミー値でないとデータ取得できないので注意!
        - export AWS_DEFAULT_REGION=ap-northeast-1

        # Docker、docker-compose をインストール
        - apk update && apk add --no-cache docker docker-compose aws-cli curl
        - docker --version # Docker バージョンを確認
        - docker info

        # npm パッケージをインストール
        - npm ci

        # Docker Networkを作成(既に存在する場合はエラーを無視)
        - docker network create docker_network || true

        # DynamoDBの Dockerコンテナを起動
        - docker-compose up -d

        # DynamoDBの Dockerが healthy になるまで待機(5秒ごとに再試行)
        - until docker ps --format "{{.Status}}" | grep -q "(healthy)"; do
          echo "Waiting for Docker container to become healthy...";
          sleep 5;
          done

        # DynamoDB の起動待機後にテストアクセス
        - curl http://dynamodb:8000 || (echo "DynamoDB is not running!" && exit 1)

        # Dockerの状態表示
        #- docker ps

        # Dockerにログインしてテストアクセス
        #- docker exec dynamodb curl -v http://localhost:8000

        # Dockerのログを表示
        #- docker logs $(docker ps -q -f name=dynamodb)

        # DynamoDBコンテナにUnitテストで使用するテーブルを作成
        - aws dynamodb create-table --endpoint-url http://dynamodb:8000 --table-name local-users --attribute-definitions AttributeName=id,AttributeType=S AttributeName=signature,AttributeType=S --key-schema AttributeName=id,KeyType=HASH --billing-mode PAY_PER_REQUEST --global-secondary-indexes '[{"IndexName":"signature_gsi","KeySchema":[{ "AttributeName":"signature", "KeyType":"HASH"}],"Projection":{"ProjectionType":"ALL"}}]'
        - aws dynamodb create-table --endpoint-url http://dynamodb:8000 --table-name local-signatures --attribute-definitions AttributeName=stext,AttributeType=S --key-schema AttributeName=stext,KeyType=HASH --billing-mode PAY_PER_REQUEST

        # DynamoDBのテーブル作成は非同期の為、10秒待機
        - sleep 10

        # DynamoDBに作成したテーブル一覧を確認
        - aws dynamodb list-tables --endpoint-url http://dynamodb:8000
    script:
        - echo "script"

        # Unitテストを実行
        - npm run test
    only:
        # - merge_requests
        - develop
    dependencies: []

# デプロイ(Dockerコンテナ内からserverlessコマンドを実行)
deploy-job:
    stage: deploy
    before_script:
        - npm i -g serverless@3
        - serverless --version
        - npm ci
    script:
        # GitLabの変数から環境情報を取得
        - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
        - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
        - export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION
        - export SERVERLESS_ACCESS_KEY=$SERVERLESS_ACCESS_KEY
        
        # Dev環境へのデプロイ
        - serverless deploy --stage dev
    only:
        - develop
    dependencies: [test-job]

テスト用のDynamoDBコンテナを新規作成

docker-compose.yml
version: "3.8"

services:
    dynamodb:
        image: amazon/dynamodb-local
        container_name: dynamodb
        ports:
            - "8000:8000"
        command: "-jar DynamoDBLocal.jar -sharedDb -dbPath /data"
        volumes:
            - dynamodb_data:/data
        healthcheck:
            test: ["CMD", "sh", "-c", "curl -s http://localhost:8000 || exit 1"]
            interval: 5s
            retries: 5
            timeout: 5s
            start_period: 10s
        networks:
            - docker_network
networks:
    docker_network:
        driver: bridge

volumes:
    dynamodb_data:

AWS EC2に GitLabランナーのインストールと登録

以下GitLab設定画面の赤枠部分「registration token」をコピーしておく。
gitlab01.png

Amazon Linux 2 でEC2インスタンスを起動する(※Amazon Linux 2023だと動かなかった)
EC2にログインしたあと以下コマンドを実行

$ # gitlab-runnerのインストール
$ sudo yum update -y
$ sudo yum install -y git curl
$ curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | sudo bash
$ sudo yum install -y gitlab-runner

$ # Dockerのインストール
$ sudo amazon-linux-extras enable docker
$ sudo yum install -y docker
$ sudo usermod -aG docker gitlab-runner

$ # Dockerの有効化
$ sudo systemctl enable docker
$ sudo systemctl start docker

$ # GitLabランナーの登録
$ sudo gitlab-runner register  #基本は画面にしたがって

Enter the GitLab instance URL (for example, https://gitlab.com/):
<gitlabのURLを入力 ※パスは不要> #Enter
Enter the registration token:
<上記でコピーしていた「registration token」を貼り付け> # Enter
[ip-xx-xx-xx-xx.ap-northeast-1.compute.internal]:
<任意の名称 ※GitLab側で表示される名称、ここでは serverless-cicdとする> # Enter)
Enter tags for the runner (comma-separated):
<なにも入力しない> # Enter
Enter optional maintenance note for the runner:
<なにも入力しない> # Enter
Registering runner... succeeded                     runner=GR1348941UWSRzxK6
Enter an executor: parallels, docker-windows, docker+machine, kubernetes, shell, ssh, docker, docker-autoscaler, instance, custom, virtualbox:
docker  # Enter
Enter the default Docker image (for example, ruby:2.7):
node:alpine3.20  # Enter ※.gitlab-ci.ymlと合わせる


$ # GitLabランナーの起動と自動起動設定
$ sudo gitlab-runner start # 既に起動中の場合には restart
$ sudo systemctl enable gitlab-runner

すると、GitLab側で 以下図の赤枠の部分のように、「利用可能な Specific Runnner」が表示される。
gitlab02.png

GitLabの設定

以下画面の赤枠の「エンピツ」マークをクリック
gitlab03.png

すると以下画面になるので、「タグのないジョブの実行」に✓する。
gitlab04.png

GItLabランナーの設定編集

再びEC2に戻り、/etc/gitlab-runner/config.toml を以下のように編集する。
※GitLabのコンソールから「利用可能な Specific Runner」を削除しても、ここの定義は消えないので注意

/etc/gitlab-runner/config.toml
$ sudo vi /etc/gitlab-runner/config.toml
  :
  :
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "node:20"
+   privileged = true   # trueにする
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
  :
   :

Serverless Frameworkのアクセスキーを取得(v4から必須)

Serverless Framework v4からアクセスキーが必須になりました。
ローカルのターミナルから以下を入力し、Serverless Framework のページをブラウザで開く。

$ serverless login # ブラウザが自動で開くのでログインする

次に以下のURLにアクセスしすると、Access Keys生成ページが表示されるので、画面右上の「Add」ボタンクリックして、Nameに任意の名称(例:gitlab)を入力して「Create」ボタンクリック。
するとアクセスキーが表示されるのでコピーしておく

URLhttps://app.serverless.com/<組織名>/settings/accessKeys

GitLabの変数に、認証情報をセットする

GitLabの左メニューの設定>CI/CD>変数 を展開>「変数の追加」をクリック。
下図のように、以下に変数をセットする。

  • AWS_ACCESS_KEY_ID = <デプロイ先AWSのアクセスキー>
  • AWS_SECRET_ACCESS_KEY = <デプロイ先AWSのシークレット>
  • AWS_DEFAULT_REGION = <デプロイ先AWSのリージョン>
  • SERVERLESS_ACCESS_KEY = 上記ででコピーしたキー

gitlab5.png

GitLabにPushでCI/CDが実行

最後に、何かファイルを変更して、git commit し、GitLabにPushしてみる。
下図のように、TestとDeployが成功することを確認する。

gitlab6.png

念のため、AWS側でもCloudFormationがのスタック(serverless-cicd-dev)が更新されていることも確認する。

以上

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?