iT邦幫忙

2024 iThome 鐵人賽

DAY 16
4
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 19

nestjs 系統設計 - 活動訂票管理系統 - Event Module part 2

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - Event Module part 2

目標

image

今天目標會是先處理, EventModule 的關於資料儲存部份。

概念

之前的實作中, EventsService 主要的職責是處理 Event 資訊的處理,其中關於存儲的部份是透過 EventStore 這個 InMemory 的實作來處理。當伺服器一但重新啟動,原本存儲的狀態就會消失了。

這邊會跟之前 UserModule 的 UsersService 類似,透過 Repository 這個對資料做操作的抽象介面,來替換真正存取資料庫的部份。

分析

根據之前分析的 Entity 關係圖如下
image

今天會處理的是 Event 這個 Entity 。這邊會基於一些重要需要捕捉的屬性,來添加或減少 Entity 的欄位。

新增 typeorm 屬性到 eventEntity

import { IsDate, IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
import { Column, CreateDateColumn, Entity, PrimaryColumn, Unique, UpdateDateColumn } from 'typeorm';

@Unique('unique_event_condition', ['name', 'location', 'startDate'])
@Entity('events', { schema: 'public' })
export class EventEntity {
  @PrimaryColumn({
    type: 'uuid',
    name: 'id'
  })
  @IsUUID()
  id: string;
  @Column({
    unique: true,
    type: 'varchar',
    length: '200',
    name: 'name'
  })
  @IsNotEmpty()
  @IsString()
  name: string;
  @Column({
    type: 'varchar',
    length: '200',
    name: 'location',
    nullable: false,
  })
  @IsNotEmpty()
  @IsString()
  location: string;
  @Column({
    type: 'timestamp without time zone',
    name: 'start_date',
    nullable: false,
  })
  @IsDate()
  startDate: Date;
  @Column({
    type: 'bigint',
    name: 'number_of_days',
    nullable: false,
    default: 1,
  })
  @IsNumber()
  numberOfDays: number = 1;
  @CreateDateColumn({
    type: 'timestamp without time zone',
    name: 'created_at',
    nullable: false,
    default: 'now()',
  })
  @IsDate()
  createdAt: Date;
  @UpdateDateColumn({
    type: 'timestamp without time zone',
    name: 'updated_at',
    nullable: false,
    default: 'now()',
  })
  @IsDate()
  updatedAt: Date;
}

建立 migration script

  1. 使用 shell 自動建立檔案範本
npm run typeorm:create-migration --name=EVENT
  1. 根據 Entity 修改範本
import { MigrationInterface, QueryRunner, Table } from 'typeorm';

export class EVENT1725356821827 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        schema: 'public',
        name: 'events',
        columns: [
          {
            name: 'id',
            type: 'uuid',
            isPrimary: true,
          },
          {
            name: 'name',
            type: 'varchar',
            length: '200',
            isNullable: false,
          },
          {
            name: 'location',
            type: 'varchar',
            length: '200',
            isNullable: false,
          },
          {
            name: 'start_date',
            type: 'timestamp without time zone',
            isNullable: false,
          },
          {
            name: 'number_of_days',
            type: 'bigint',
            default: 1,
            isNullable: false,
          },
          {
            name: 'created_at',
            type: 'timestamp without time zone',
            isNullable: false,
            default: 'now()',
          },
          {
            name: 'updated_at',
            type: 'timestamp without time zone',
            isNullable: false,
            default: 'now()',
          }
        ],
        uniques: [{
          name: 'unique_event_condition',
          columnNames: ['name', 'location', 'start_date'],
        }]
      })
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable('public.events', true, true, true);
  }
}

實作 EventDbStore

import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateEventDto, PageInfoRequestDto, EventsResponse } from './dto/event.dto';
import { EventsRepository } from './events.repository';
import { EventEntity } from './schema/event.entity';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
@Injectable()
export class EventDbStore implements EventsRepository {
  constructor(
    @InjectRepository(EventEntity)
    private readonly eventRepo: Repository<EventEntity>
  ) {}
  async save(eventInfo: CreateEventDto): Promise<EventEntity> {
    const event = new EventEntity();
    event.id = crypto.randomUUID();
    event.name = eventInfo.name;
    event.location = eventInfo.location;
    event.startDate = eventInfo.startDate;
    if (eventInfo.numberOfDays) {
      event.numberOfDays = eventInfo.numberOfDays;
    }
    await this.eventRepo.save(event);
    return event;
  }
  async findOne(criteria: Partial<EventEntity>): Promise<EventEntity> {
    const event = await this.eventRepo.findOneBy(criteria);
    if (!event) {
      throw new NotFoundException('event not found');
    }
    return event;
  }
  async find(criteria: Partial<EventEntity>, pageInfo: PageInfoRequestDto): Promise<EventsResponse> {
    let queryBuilder = this.eventRepo.createQueryBuilder('events');
    const offset = pageInfo.offset;
    const limit = pageInfo.limit;
    let whereCount = 0;
    if (criteria.location) {
      queryBuilder = (whereCount == 0)? 
      queryBuilder.where('events.location = :location', { location: criteria.location})
      :queryBuilder.andWhere('events.location = :location', { location: criteria.location});
      whereCount++;
    }
    if (criteria.name) {
      queryBuilder = (whereCount == 0)? 
      queryBuilder.where('events.name = :name',{name: criteria.name})
      :queryBuilder.andWhere('events.name = :name',{name: criteria.name});
      whereCount++;
    }
    if (criteria.startDate) {
      queryBuilder = (whereCount == 0)?
      queryBuilder.where('events.start_date = :startDate', {startDate: criteria.startDate})
      :queryBuilder.andWhere('events.start_date = :startDate', {startDate: criteria.startDate});
      whereCount++;
    }
    queryBuilder = queryBuilder.offset(offset);
    queryBuilder = queryBuilder.limit(limit);
    queryBuilder = queryBuilder.orderBy('start_date', 'ASC');
    const [events, total] = await queryBuilder.getManyAndCount();
    return {
      events,
      pageInfo: {
        total: total,
        offset: pageInfo.offset,
        limit: pageInfo.limit,
      }
    }
  }
  async update(criteria: Partial<EventEntity>, data: Partial<EventEntity>): Promise<EventEntity> {
    const queryBuilder = this.eventRepo.createQueryBuilder('events');
    const result = await queryBuilder.update<EventEntity>(EventEntity, data)
    .where(criteria).updateEntity(true).execute();
    const model: EventEntity = result.raw[0] as EventEntity;
    return model;
  }
  async delete(criteria: Partial<EventEntity>): Promise<string> {
    const queryBuilder = this.eventRepo.createQueryBuilder('events');
    const result = await queryBuilder.delete().where(criteria).execute();
    if (result.affected == 0) {
      throw new NotFoundException('delete target not found');
    }
    return criteria.id;
  }
}

修改 EventsService 的 Repostiroy 為 EventDbStore

import { Inject, Injectable } from '@nestjs/common';
import { EventsRepository } from './events.repository';
import { CreateEventDto, GetEventDto, GetEventsDto, PageInfoRequestDto, UpdateEventDto } from './dto/event.dto';
import { EventDbStore } from './event-db.store';

@Injectable()
export class EventsService {
  constructor(
    @Inject(EventDbStore)
    private readonly eventRepo: EventsRepository
  ) {}
  async createEvent(eventInfo: CreateEventDto) {
    const result = await this.eventRepo.save(eventInfo);
    return {id: result.id};
  }
  async getEvent(userInfo: GetEventDto) {
    return this.eventRepo.findOne(userInfo);
  }
  async getEvents(criteria: GetEventsDto, pageInfo: PageInfoRequestDto) {
    return this.eventRepo.find(criteria, pageInfo);
  }
  async updateEvent(eventId: string, updateData: UpdateEventDto) {
    return this.eventRepo.update({id: eventId},updateData);
  }
  async deleteEvent(eventId: string) {
    return this.eventRepo.delete({ id: eventId});
  }
}

修改 EventsModule 的 Provider

import { Module } from '@nestjs/common';
import { EventsService } from './events.service';
import { EventsController } from './events.controller';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtAuthStrategy } from '../auth/strategies/jwt-auth.strategy';
import { UsersModule } from '../users/users.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEntity } from './schema/event.entity';
import { EventDbStore } from './event-db.store';

@Module({
  imports: [ UsersModule, TypeOrmModule.forFeature([EventEntity])],
  providers: [EventsService, JwtAuthGuard, JwtAuthStrategy, EventDbStore],
  controllers: [EventsController]
})
export class EventsModule {}

執行 e2e test 來做驗證行為

  1. pnpm run test:e2e
pnpm run test:e2e
  1. 驗證結果如下
    image

執行 unit test 驗證依賴後的行為

  1. pnpm run test:watch
pnpm run test:watch

這個指令,會檢查修改過的測試,重新執行一次。

  1. 驗證結果如下:

image

Postman 驗證

  1. 註冊 event
    image

  2. 存取特定 event
    image

  3. 存取特定條件的 events
    image

  4. 更新特定 event
    image

  5. 刪除特定 event
    image

開發到這裡,基本上把 user 跟 event 兩個重要關鍵狀態操作完成。接續下去就是去實行 Ticket 管理的行為。

結論

以目前所開發的這些行為,基本上透過一些基礎的元件之間的互動,就可以完成。然而,如果需要一些更細緻狀態管理,比如說針對特定 Exception 需要做特殊的操作比如加特定的 error log 。就要額外的元件比如 ExceptionFilter 或是 Interceptor ,來做細緻流程管控。

因此,有了框架的幫助之後。軟體開發最重要的就是需求分析,接著把設計能夠符合需求的狀態管控。最後,再根據這些設計,逐一列出規格,來驗證系統行為。實作基本上都是前面步驟結束後才開始。

在沒有方向時,一定要先跟相關人員釐清需求。不論是使用哪一種方法論,確認大家俱備相同的理解後,再來做實踐。避免老是開發跟需求不一致。

開發出符合需求的軟體,才是叫作設計軟體。不然真的會變成碼農。


上一篇
nestjs 核心架構nestjs 系統設計 - 活動訂票管理系統 - Event Module part 1
下一篇
nestjs 系統設計 - 活動訂票管理系統 - Ticket Module part1
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加26
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
孤獨一隻雞
iT邦研究生 5 級 ‧ 2024-09-19 16:57:59

我要動力火車演唱會

雷N iT邦研究生 1 級 ‧ 2024-09-19 16:58:44 檢舉

我要五百

伍佰的演唱會要去唱歌給他聽

我要留言

立即登入留言