MBC CQRS サーバーレス フレームワーク TO-DO システム作成 2 – 書き込み処理追加
今回はTO-DOを登録するための処理を作成します。
CQRSパターンであるため、書き込み用データにTO-DOデータの書き込みコマンドを追加し、MBC CQRS サーバーレスフレームワークが自動的に読み込み用DBに反映するところまで確認出来ます。
このサンプルでは連携先をRDBにしていますが、DocumentDB等の別のDBに書き込んだり複数のDB/テーブルに書き込むことが可能です。
[option] 不要なマスタモジュールを削除
npm run offline:docker
と npm run offline:sls
を実行している場合は一旦中止します。
src/master フォルダに関して、今回のTO-DOシステムでは不要なため削除します。
masterモジュールの読込を削除するため、src/main.module.ts
を以下の通りとします。
import { Module } from '@nestjs/common'
import { CustomEventFactory } from './event-factory'
import { MasterModule } from './master/master.module' // ← 削除
import { prismaLoggingMiddleware, PrismaModule } from './prisma'
@Module({
imports: [
PrismaModule.forRoot({
isGlobal: true,
prismaServiceOptions: {
middlewares: [prismaLoggingMiddleware()],
prismaOptions: {
log:
process.env.NODE_ENV !== 'local'
? ['error']
: ['info', 'error', 'warn', 'query'],
},
explicitConnect: false,
},
}),
MasterModule, // ← 削除
],
providers: [CustomEventFactory],
})
export class MainModule {}
prisma/dynamodbs/cqrs.json
に master
が登録されているため以下のようにして登録を解除します。
[]
serverless の設定を修正
infra-local/serverless.yml
の functions
– main
– stream
にマスター用のDynamoDBStreamsあるので削除する
functions:
main:
handler: ../dist/main.handler
events:
- httpApi: # public api
method: GET
path: /
- httpApi:
method: ANY
path: '/swagger-ui/{proxy+}'
- httpApi: # protected api
method: ANY
path: '/{proxy+}'
authorizer:
name: localAuthorizer
# authorizer:
# name: keycloakAuthorizer
# - eventBridge:
# eventBus: marketing
# # run every 5 minutes
# schedule: "cron(0/5 * * * ? *)"
- sqs:
arn:
Fn::GetAtt:
- TaskActionQueue
- Arn
- sqs:
arn:
Fn::GetAtt:
- NotificationQueue
- Arn
- stream:
type: dynamodb
maximumRetryAttempts: 10
arn: ${env:LOCAL_DDB_MASTER_STREAM}
filterPatterns:
- eventName: [INSERT]
初期テーブルテーブルの削除
初期に作成されるテーブルを削除するため infra-local/docker-data
ディレクトリを削除します。
テーブルの再作成
npm run offline:docker
を実行しローカルのDynamoDBサービス等を起動してから npm run migrate
を実行しテーブルを再作成します。
> mbc-todo-sample@0.0.1 migrate
> npm run migrate:rds && npm run migrate:ddb
> mbc-todo-sample@0.0.1 migrate:rds
> prisma migrate deploy
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "cqrs" at "localhost:3306"
No migration found in prisma/migrations
No pending migrations to apply.
> mbc-todo-sample@0.0.1 migrate:ddb
> ts-node prisma/ddb.ts
Clear table stream arn in .env
creating table: sequences
creating table: tasks
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks `, and stream arn:` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks/stream/2024-10-06T05:34:11.334 `
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-sequences `, and stream arn:` undefined `
2 tables were created successfully
テーブルを再作成するとローカルのDynamoDB Admin(http://localhost:8001)に アクセスすると次のような画面になります。
テーブル定義を追加
TO-DO用のテーブルを追加します。
サンプルシステムなので説明、ステータス、期限を追加します。
既存で作成された定義は削除するので prisma/schema.prisma
ファイルを以下のようにします。
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-arm64-openssl-1.0.x"]
// binaryTargets = ["native", "rhel-openssl-1.0.x"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
enum TodoStatus {
PENDING
IN_PROGRESS
COMPLETED
CANCELED
}
model Todo {
id String @id
cpk String // コマンド用PK
csk String // コマンド用SK
pk String // データ用PK, TODO#mbc (テナントコード)
sk String // データ用SK, マスタ種別コード#マスタコード
tenantCode String @map("tenant_code") // テナントコード, 【テナントコードマスタ】
seq Int @default(0) // 並び順, 採番機能を使用する
code String // レコードのコード, マスタ種別コード#マスタコード
name String // レコード名, 名前
version Int // バージョン
isDeleted Boolean @default(false) @map("is_deleted") // 削除フラグ
createdBy String @default("") @map("created_by") // 作成者
createdIp String @default("") @map("created_ip") // 作成IP, IPv6も考慮する
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) // 作成日時
updatedBy String @default("") @map("updated_by") // 更新者
updatedIp String @default("") @map("updated_ip") // 更新IP, IPv6も考慮する
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(0) // 更新日時
description String @default("") @map("description")
status TodoStatus @default(PENDING) @map("status")
dueDate DateTime? @map("due_date")
@@unique([cpk, csk])
@@unique([pk, sk])
@@unique([tenantCode, code])
@@index([tenantCode, name])
@@map("todos")
}
prismaのスキーマ定義が完了したら以下のコマンドを実行しmigrateファイルを生成します。
npm run migrate:dev
> mbc-todo-sample@0.0.1 migrate:dev
> prisma migrate dev
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "cqrs" at "localhost:3306"
✔ Enter a name for the new migration: … create todo table
Applying migration `20241124010703_create_todo_table`
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20241129143303_create_todo_table/
└─ migration.sql
Your database is now in sync with your schema.
✔ Generated Prisma Client (v5.22.0) to ./node_modules/@prisma/client in 44ms
モジュールの追加
以下のコマンドを実行してモジュールを追加します。
nest g module todo
以下の結果が得られます。
CREATE src/todo/todo.module.ts (81 bytes)
UPDATE src/main.module.ts (670 bytes)
コントローラの追加
以下のコマンドを実行してコントローラを追加します。
nest g controller todo
以下の結果が得られます。
CREATE src/todo/todo.controller.spec.ts (478 bytes)
CREATE src/todo/todo.controller.ts (97 bytes)
UPDATE src/todo/todo.module.ts (166 bytes)
以下のコマンドを実行してサービスを追加します。
サービスの追加
nest g service todo
以下の結果が得られます。
CREATE src/todo/todo.service.spec.ts (446 bytes)
CREATE src/todo/todo.service.ts (88 bytes)
UPDATE src/todo/todo.module.ts (240 bytes)
ヘルパー関数を追加
DynamoDB等に定義する pk
や sk
の設定を一元的に管理出来るようにヘルパー関数を登録します。src/helpers/id.ts
を追加し、以下のように入力します。
import { KEY_SEPARATOR } from '@mbc-cqrs-serverless/core'
import { ulid } from 'ulid'
export const TODO_PK_PREFIX = 'TODO'
export function generateTodoPk(tenantCode: string): string {
return `${TODO_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`
}
export function generateTodoSk(): string {
return ulid()
}
export function parsePk(pk: string): { type: string; tenantCode: string } {
if (pk.split(KEY_SEPARATOR).length !== 2) {
throw new Error('Invalid PK')
}
const [type, tenantCode] = pk.split(KEY_SEPARATOR)
return {
type,
tenantCode,
}
}
上記ヘルパーを src/helper/index.ts
に登録します。以下のようになります。
export * from './get-order'
export * from './id'
属性用Dtoを追加
mbc-cqrs-serverlss ではDynamoDBの属性が予約語として決められております。そのため、個別に保存するデータを「attributes」に追加します。
そのため属性用のDtoを定義します。src/todo/dto/todo-attributes.dto.ts
を追加し、以下のように入力します。
import { ApiProperty } from '@nestjs/swagger'
import { TodoStatus } from '@prisma/client'
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'
export class TodoAttributes {
@IsOptional()
@IsString()
description?: string
@IsOptional()
@ApiProperty({ enum: TodoStatus })
@IsEnum(TodoStatus)
status?: TodoStatus
@IsOptional()
@IsDateString()
dueDate?: string
}
To-do 作成用のDtoを作成します。src/todo/dto/create-todo.dto.ts
を追加し、以下のように入力します。
import { Type } from 'class-transformer'
import { IsOptional, IsString, ValidateNested } from 'class-validator'
import { TodoAttributes } from './todo-attributes.dto'
export class CreateTodoDto {
@IsString()
name: string
@Type(() => TodoAttributes)
@ValidateNested()
@IsOptional()
attributes?: TodoAttributes
constructor(partial: Partial<CreateTodoDto>) {
Object.assign(this, partial)
}
}
Entityの作成
Todoを追加・更新・削除する際に使用するコマンドのEntityを作成します。src/todo/entity/todo-command.entity.ts
を追加し以下のように入力します。
import { CommandEntity } from '@mbc-cqrs-serverless/core'
import { TodoAttributes } from '../dto/todo-attributes.dto'
export class TodoCommandEntity extends CommandEntity {
attributes: TodoAttributes
constructor(partial: Partial<TodoCommandEntity>) {
super()
Object.assign(this, partial)
}
}
データ参照用のEntityを作成します。src/todo/entity/todo-data.entity.ts
を追加し以下のように入力します。
import { DataEntity } from '@mbc-cqrs-serverless/core'
import { TodoAttributes } from '../dto/todo-attributes.dto'
export class TodoDataEntity extends DataEntity {
attributes: TodoAttributes
constructor(partial: Partial<TodoDataEntity>) {
super(partial)
Object.assign(this, partial)
}
}
To-do用サービスに追加サービスを追加
src/todo/todo.service.ts
にTo-doサービスにログを登録します。
private readonly logger = new Logger(TodoService.name)
@mbc-cqrs-serverless/core
から CommandService
を追加した上で constructor
にコマンドサービスを追加します。
constructor(private readonly commandService: CommandService) {}
作成ロジックを作成します。
async create(
createDto: CreateTodoDto,
opts: { invokeContext: IInvoke },
): Promise<TodoDataEntity> {
const { tenantCode } = getUserContext(opts.invokeContext)
const pk = generateTodoPk(tenantCode)
const sk = generateTodoSk()
const todo = new TodoCommandEntity({
pk,
sk,
id: generateId(pk, sk),
tenantCode,
code: sk,
type: TODO_PK_PREFIX,
version: VERSION_FIRST,
name: createDto.name,
attributes: createDto.attributes,
})
const item = await this.commandService.publishAsync(todo, opts)
return new TodoDataEntity(item as TodoDataEntity)
}
src/todo/todo.service.ts
は以下の通りになります。
import {
CommandService,
generateId,
getUserContext,
IInvoke,
VERSION_FIRST,
} from '@mbc-cqrs-serverless/core'
import { Injectable, Logger } from '@nestjs/common'
import { generateTodoPk, generateTodoSk, TODO_PK_PREFIX } from '../helpers'
import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoCommandEntity } from './entity/todo-command.entity'
import { TodoDataEntity } from './entity/todo-data.entity'
@Injectable()
export class TodoService {
private readonly logger = new Logger(TodoService.name)
constructor(private readonly commandService: CommandService) {}
async create(
createDto: CreateTodoDto,
opts: { invokeContext: IInvoke },
): Promise<TodoDataEntity> {
const { tenantCode } = getUserContext(opts.invokeContext)
const pk = generateTodoPk(tenantCode)
const sk = generateTodoSk()
const todo = new TodoCommandEntity({
pk,
sk,
id: generateId(pk, sk),
tenantCode,
code: sk,
type: TODO_PK_PREFIX,
version: VERSION_FIRST,
name: createDto.name,
attributes: createDto.attributes,
})
const item = await this.commandService.publishAsync(todo, opts)
return new TodoDataEntity(item as TodoDataEntity)
}
}
コントローラに追加処理を追加します。
To-doコントローラにログを登録します。
private readonly logger = new Logger(TodoService.name)
todo.service
から TodoService
を追加した上で constructor
にコマンドサービスを追加します。
constructor(private readonly todoService: TodoService) {}
追加処理を追加します。
@Post('/')
async create(
@INVOKE_CONTEXT() invokeContext: IInvoke,
@Body() createDto: CreateTodoDto,
): Promise<TodoDataEntity> {
this.logger.debug('createDto:', createDto)
return this.todoService.create(createDto, { invokeContext })
}
todo のAPIを api/todo にパスを変更します。クラス名の前の @Controller
を修正します。
@Controller('api/todo')
APIドキュメントにタグを付けます。 @Controller
の直下に以下を追加します。
@ApiTags('todo')
src/todo/todo.controller.ts
は次の通りになります。
import { IInvoke, INVOKE_CONTEXT } from '@mbc-cqrs-serverless/core'
import { Body, Controller, Logger, Post } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoDataEntity } from './entity/todo-data.entity'
import { TodoService } from './todo.service'
@Controller('api/todo')
@ApiTags('todo')
export class TodoController {
private readonly logger = new Logger(TodoService.name)
constructor(private readonly todoService: TodoService) {}
@Post('/')
async create(
@INVOKE_CONTEXT() invokeContext: IInvoke,
@Body() createDto: CreateTodoDto,
): Promise<TodoDataEntity> {
this.logger.debug('createDto:', createDto)
return this.todoService.create(createDto, { invokeContext })
}
}
モジュールの設定
モジュールを設定します。
To-do コントローラを To-do モジュールに登録します。src/todo/todo.module.ts
を以下のように変更します。
import { CommandModule } from '@mbc-cqrs-serverless/core'
import { Module } from '@nestjs/common'
import { TodoController } from './todo.controller'
import { TodoService } from './todo.service'
@Module({
controllers: [TodoController],
providers: [TodoService],
})
export class TodoModule {}
prisma/dynamodbs/cqrs.json
に todo
登録します。
以下のようになります。
["todo"]
テーブルを追加
npm run migrate
以下の結果が表示されます。
> mbc-todo-sample@0.0.1 migrate
> npm run migrate:rds && npm run migrate:ddb
> mbc-todo-sample@0.0.1 migrate:rds
> prisma migrate deploy
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "cqrs" at "localhost:3306"
1 migration found in prisma/migrations
No pending migrations to apply.
> mbc-todo-sample@0.0.1 migrate:ddb
> ts-node prisma/ddb.ts
Clear table stream arn in .env
creating table: todo-command
creating table: todo-data
creating table: todo-history
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-command `, and stream arn:` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-command/stream/2024-11-24T01:27:14.639 `
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-history `, and stream arn:` undefined `
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-data `, and stream arn:` undefined `
creating table: sequences
creating table: tasks
table exists: arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks => stream: arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks/stream/2024-11-24T01:00:27.681
table exists: arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-sequences => stream: undefined
enable time to live for table: local-demo-tasks
enable time to live for table: local-demo-sequences
3 tables were created successfully
ブラウザーで ローカル DynamoDB のテーブルを見るとlocal-demo-todo-command
, local-demo-todo-data
, local-demo-todo-history
が作成されていることが分かります。
ローカルのDynamoDB Strems設定追加
infra-local/sererless.yml
の functions
– main
– strem
にTODOで作成したテーブルのSteam ARN を登録します。infra-local/sererless.yml
の functions
以下のようになります。
functions:
main:
handler: ../dist/main.handler
events:
- httpApi: # public api
method: GET
path: /
- httpApi:
method: ANY
path: '/swagger-ui/{proxy+}'
- httpApi: # protected api
method: ANY
path: '/{proxy+}'
authorizer:
name: localAuthorizer
# authorizer:
# name: keycloakAuthorizer
# - eventBridge:
# eventBus: marketing
# # run every 5 minutes
# schedule: "cron(0/5 * * * ? *)"
- sqs:
arn:
Fn::GetAtt:
- TaskActionQueue
- Arn
- sqs:
arn:
Fn::GetAtt:
- NotificationQueue
- Arn
- stream:
type: dynamodb
maximumRetryAttempts: 10
arn: ${env:LOCAL_DDB_TODO_STREAM}
filterPatterns:
- eventName: [INSERT]
データの追加テスト
API ドキュメントの参照
ブラウザーで http://0.0.0.0:3000/swagger-ui/ を参照するとAPIドキュメントを参照することが可能です。
Visual Studio Code の HTTP Clientでテスト
Visual Studio Code で test/api.http
を開きます。次のように入力します。
@endpoint = http://localhost:3000
@cognitoEndpoint = http://localhost:9229
@clientId = dnk8y7ii3wled35p3lw0l2cd7
# account's username
@username = admin2
# account's password
@password = admin1234
# account's email
@email = admin2@example.com
@apiBaseUrl = {{endpoint}}/api
@eventBaseUrl = {{endpoint}}/event
###
GET {{endpoint}} HTTP/1.1
###
# login
# @name login_cognito
POST {{cognitoEndpoint}}
Accept: application/json
Content-Type: application/x-amz-json-1.1
X-Amz-Target: AWSCognitoIdentityProviderService.InitiateAuth
{
"AuthFlow": "USER_PASSWORD_AUTH",
"ClientId": "{{clientId}}",
"AuthParameters": {
"USERNAME": "{{username}}",
"PASSWORD": "{{password}}"
},
"ClientMetadata": {}
}
###
@token = {{login_cognito.response.body.AuthenticationResult.IdToken}}
###
# Health
GET {{endpoint}}
Accept: application/json
Authorization: {{token}}
### Create To-do
POST {{apiBaseUrl}}/todo
Accept: application/json
Content-Type: application/json
Authorization: {{token}}
X-Tenant-Code: MBC
{
"name": "Test Task 1",
"attributes": {
"description": "desc",
"status": "PENDING"
}
}
login_cognito
とCreate To-do
を実行すると次のようなメッセージを取得出来ます。
HTTP/1.1 201 Created
x-powered-by: Express
access-control-allow-origin: *
content-type: application/json; charset=utf-8
content-length: 513
etag: W/"201-/VUxR0zeVMxX9C2BFysOjX8dBNo"
cache-control: no-cache
Date: Sun, 24 Nov 2024 02:17:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{
"pk": "TODO#MBC",
"sk": "01JDDZBD4J420A48AM498SX0EJ@1",
"id": "TODO#MBC#01JDDZBD4J420A48AM498SX0EJ",
"tenantCode": "MBC",
"code": "01JDDZBD4J420A48AM498SX0EJ",
"type": "TODO",
"version": 1,
"name": "Test Task 1",
"attributes": {
"description": "desc"
},
"requestId": "42668994-b6d2-48e3-88bc-66c8da0fa206",
"createdAt": "2024-11-24T02:17:29.490Z",
"updatedAt": "2024-11-24T02:17:29.490Z",
"createdBy": "92ca4f68-9ac6-4080-9ae2-2f02a86206a4",
"updatedBy": "92ca4f68-9ac6-4080-9ae2-2f02a86206a4",
"createdIp": "127.0.0.1",
"updatedIp": "127.0.0.1"
}
local-demo-todo-command
テーブルを確認すると以下のようにデータが登録されています。
Commandテーブルに追加した内容がDataテーブルに反映されていることを確認します。