iT邦幫忙

2024 iThome 鐵人賽

DAY 16
2
Software Development

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

nestjs 系統設計 - 活動訂票管理系統 - Ticket Module part2

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - Ticket Module part2

目標

image

今天目標會是先處理, TicketModule 的關於資料儲存部份,與 Counter 同步的問題。

概念

TicketService 的職責,主要是負責處理關於 Ticket 狀態的管理更新。目前是使用 TicketStore 這個來作狀態儲存。當伺服器一旦重新啟動,則原本的狀態就會消失了。

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

EventCounterService 的職責,主要是負責處理統計 Event 得票存狀態。這個部份,現在透過 InMemory 的 EventCounterStore 來作狀態存儲,並且以 Singleton 方式來確保整個 App 在單一個 nestjs 應用內只有單一來源。在這個實做中,假設遇到需要作 scale 時,就必須要想辦法廣播當下 Event 統計資料到多個 nestjs 應用上。以及限制併發存取只能有單一個 thread 去修改。

為了要能讓多個服務一同存取 Event 統計資料。因此,採取 Redis 作為狀態分享 storage 。而限制併發更新統計量也是透過 Redis 的 lua script 去達成。

但由於這部份內容比較多,所以今天先實做到 TypeOrm Entity 的部份。

分析

當下需要關注的 Entity 主要有以下兩個:

image

然而,根據情境所述。 eventId 來自於 Event entity , userId 來自於 User entity 。 所以,其 Entity 關係圖如下:

image

今天會先處理的部份會,如下

  1. 新增對應 entity 的 db migration
  2. 實做對應的 repository

redis 與 rabbitmq 的部份打算放到之後章節在繼續

新增 typeorm 屬性到 Ticket Entity

import { IsBoolean, IsDate, IsNumber, IsOptional, IsPositive, IsUUID } from 'class-validator';
import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';

@Entity('tickets', { schema: 'public' })
export class TicketEntity {
  @PrimaryColumn({
    type: 'uuid',
    name: 'id'
  })
  @IsUUID()
  id: string;
  @Column({
    type: 'uuid',
    name: 'event_id',
    nullable: false,
  })
  @IsUUID()
  eventId: string;
  @Column({
    type: 'uuid',
    name: 'user_id',
    nullable: false
  })
  @IsUUID()
  userId: string;
  @Column({
    type: 'boolean',
    name: 'entered',
    default: false,
    nullable: false,
  })
  @IsBoolean()
  @IsOptional()
  entered?: boolean = false;
  @Column({
    type: 'bigint',
    name: 'ticket_number',
    default: 1,
    nullable: false,
  })
  @IsPositive()
  @IsNumber()
  @IsOptional()
  ticketNumber?: 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=TICKET
  1. 根據 Entity 修改範本
import { MigrationInterface, QueryRunner, Table } from 'typeorm';

export class TICKET1726456107308 implements MigrationInterface {

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        schema: 'public',
        name: 'tickets',
        columns: [
          {
            name: 'id',
            type: 'uuid',
            isPrimary: true,
          },
          {
            name: 'event_id',
            type: 'uuid',
            isNullable: false,
          },
          {
            name: 'user_id',
            type: 'uuid',
            isNullable: false,
          },
          {
            name: 'entered',
            type: 'boolean',
            isNullable: false,
            default: false,
          },
          {
            name: 'ticket_number',
            type: 'bigint',
            isNullable: false,
            default: 1,
          },
          {
            name: 'created_at',
            type: 'timestamp without time zone',
            isNullable: false,
            default: 'now()'
          },
          {
            name: 'updated_at',
            type: 'timestamp without time zone',
            isNullable: false,
            default: 'now()'
          }
        ],
        foreignKeys: [
          {
            name: 'event_id_reference',
            columnNames: ['event_id'],
            referencedColumnNames: ['id'],
            referencedTableName: 'public.events',
            onDelete: 'CASCADE',
            onUpdate: 'CASCADE'
          },
          {
            name: 'user_id_reference',
            columnNames: ['user_id'],
            referencedColumnNames: ['id'],
            referencedTableName: 'public.users',
            onDelete: 'CASCADE',
            onUpdate: 'CASCADE'
          }
        ]
      })
    );
  }

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

實做 TicketDbStore

import { PageInfoRequestDto } from 'src/pagination.dto';
import { CreateTicketDto, TicketsResponse, TicketsCountResponseDto } from './dto/ticket.dto';
import { TicketEntity } from './schema/ticket.entity';
import { TicketsRepository } from './tickets.repository';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';

@Injectable()
export class TicketDbStore implements TicketsRepository {
  constructor(
    private readonly dataSource: DataSource,
    @InjectRepository(TicketEntity)
    private readonly ticketRepo: Repository<TicketEntity>,
  ) {}
  async save(ticketInfo: CreateTicketDto): Promise<TicketEntity> {
    const ticket = new TicketEntity();
    ticket.id = crypto.randomUUID();
    ticket.userId = ticketInfo.userId;
    ticket.eventId = ticketInfo.eventId;
    if (ticketInfo.ticketNumber) {
      ticket.ticketNumber = ticketInfo.ticketNumber;
    }
    await this.ticketRepo.save(ticket);
    return ticket;
  }
  async findOne(criteria: Partial<TicketEntity>): Promise<TicketEntity> {
    const ticket = await this.ticketRepo.findOneBy(criteria);
    if (!ticket) {
      throw new NotFoundException('ticket not found');
    }
    return ticket;
  }
  async find(criteria: Partial<TicketEntity>, pageInfo: PageInfoRequestDto): Promise<TicketsResponse> {
    let queryBuilder = this.ticketRepo.createQueryBuilder('tickets');
    const offset = pageInfo.offset;
    const limit = pageInfo.limit;
    let whereCount = 0;
    if (criteria.userId) {
      queryBuilder = (whereCount == 0)?
      queryBuilder.where('tickets.user_id = :userId', { userId: criteria.userId }):
      queryBuilder.andWhere('tickets.user_id = :userId', { userId: criteria.userId })
      whereCount++;
    }
    if (criteria.eventId) {
      queryBuilder = (whereCount == 0)?
      queryBuilder.where('tickets.event_id = : eventId', { eventId: criteria.eventId }):
      queryBuilder.andWhere('tickets.event_id = : eventId', { eventId: criteria.eventId });
      whereCount++;
    }
    queryBuilder = queryBuilder.offset(offset);
    queryBuilder = queryBuilder.limit(limit);
    const [tickets, total] = await queryBuilder.getManyAndCount();
    return {
      tickets,
      pageInfo: {
        total: total,
        offset: pageInfo.offset,
        limit: pageInfo.limit
      }
    }
  }
  async update(criteria: Partial<TicketEntity>, data: Partial<TicketEntity>): Promise<TicketEntity> {
    const queryBuilder = this.ticketRepo.createQueryBuilder('tickets');
    const result = await queryBuilder.update<TicketEntity>(TicketEntity, data)
    .where(criteria).returning(['id', 'eventId', 'userId', 'entered', 'ticketNumber', 'createdAt', 'updatedAt'])
    .updateEntity(true).execute();
    const model: TicketEntity = new TicketEntity();
    model.id = result.raw[0]['id'];
    model.eventId = result.raw[0]['event_id'];
    model.userId = result.raw[0]['user_id'];
    model.entered = result.raw[0]['entered'];
    model.createdAt = new Date(result.raw[0]['created_at']);
    model.updatedAt = new Date(result.raw[0]['updated_at']);
    return model; 
  }
  async getCounts(criteria: Partial<TicketEntity>): Promise<TicketsCountResponseDto> {
    const queryManager = this.dataSource.manager;
    const [totalResult, attendeeResult] = await Promise.all([
      queryManager.query<{total: number}>('SELECT COUNT(ticket_number) AS total FROM public.tickets where event_id=?', [criteria.eventId]),
      queryManager.query<{total: number}>('SELECT COUNT(ticket_number) AS total FROM public.tickets where event_id=? and entered=false', [criteria.eventId]),
    ]);
    const accumTickets = totalResult.total;
    const accumAttendee = attendeeResult.total;
    return {
      eventId: criteria.eventId,
      accumTickets: accumTickets,
      accumAttendee: accumAttendee
    }   
  }
}

修改 TicketModule 的 Dependency

import { Module } from '@nestjs/common';
import { TicketsService } from './tickets.service';
import { TicketsController } from './tickets.controller';
import { EventsCounterService } from './events-counter.service';
import { EventCounterStore } from './event-counter.store';
import { TicketDbStore } from './ticket-db.store';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TicketEntity } from './schema/ticket.entity';

@Module({
  imports: [ TypeOrmModule.forFeature([TicketEntity])],
  providers: [TicketsService, EventsCounterService, EventCounterStore, TicketDbStore],
  controllers: [TicketsController]
})
export class TicketsModule {}

驗證 unit test

  1. 執行驗證修改的部份行為
pnpm run test:watch
  1. 驗證結果如下

image

驗證 e2e test

  1. 執行驗證修改的部份,沒有破壞原本行為
pnpm run test:e2e
  1. 驗證結果如下
    image

結論

Ticket Module 是整個系統核心部份,包含的部份比較多。 Entity 與之前不同的是這邊的兩個 field userId 與 eventId 多了欄位限制。必須要是在另外兩個資料表存在的資訊才能,合法存入。

透過 Test container 這樣的技術,可以讓整個測試過程更加方便的來對系統作完整的測試。


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

尚未有邦友留言

立即登入留言