iT邦幫忙

2024 iThome 鐵人賽

DAY 16
2
Software Development

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

nestjs 系統設計 - 活動訂票管理系統 - User Module part 3

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - User Module part 3

目標

image

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

概念

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

為了能夠讓資料狀態能夠持久保存,需要透過資料庫這類存儲應用程式。資料庫這種存儲應用程式提供持久保存的服務,可以讓資料以特定格式儲存到硬碟,並且提供優化查詢的結構以及方便開發者與使用者的介面。

在這邊因為要儲存的資料具有結構化,且需要具有一致性狀態的考量,所以選擇關聯式資料庫來簡化一致性的設計。並且使用 Postgresql 這個開源的關聯式資料庫。

分析

其中一些重要業務狀態 Entity 大致可以補捉關係如下
image

首先會從 User 的這個 Entity 開始實作。為了簡化,搭建方式採用 docker 。 因為業務流程比較簡單,這裡打算透過 typeorm 來做為 model 的方式,降低實作細節的複雜度。

postgresql 設置

設定 db instance name 為 ticket-system-db ,並且設定 postgressql db 的 admin user 與 password

services:
  db:
    container_name: ticket-system-db
    image: postgres:14
    ports:
      - 5433:5432
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWD}
      - POSTGRES_DB=ticket-system-db
    healthcheck:
      test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_USER} -d ticket-system-db'"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      driver: json-file
      options:
        max-size: 1k
        max-file: 3

特別注意到,這邊為了讓其他服務能夠檢測到這個 postgresql instance 可以做連線,做了 healthcheck 設置。這樣在其他服務可以 depend_on 這個服務的狀態來做連線。

typeorm 設置

這篇將使用 typeorm 的 migration 機制,來從 entity schema 來做 schema 管控。

  1. 安裝套件
pnpm i -S @nestjs/typeorm typeorm pg
  1. 設置連線

為了方便使用需要在 app module 設定全域。預設 pg 套件具有連線池的設定,而 nestjs/typeorm 有針對這些設定做配置。如下

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { validateSchema } from './validate.schema';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: validateSchema,
    }),
    TypeOrmModule.forRootAsync({
      useFactory(configService: ConfigService) {
        const IS_DB_SSL_MODE = configService.get<string>('NODE_ENV', 'dev') == 'production';
        return {
          ssl: IS_DB_SSL_MODE,
          extra: {
            ssl: IS_DB_SSL_MODE ? { rejectUnauthorized: false } : null,
            poolSize: 5,
            idleTimeoutMillis: 3600000,
          },
          type: 'postgres',
          url: configService.getOrThrow<string>('DB_URI', ''),
          synchronize: false,
          autoLoadEntities: true,
        }
      },
      inject:[ConfigService]
    }),
    UsersModule, AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

這個設定,是 typeorm 用來建立連線的選項,連線池是為了限制 server 連線資料庫的最大連線數目。避免耗費太多資源,儘可能復用資源。

  1. 設置 typeorm migration 設定

目標是把 migration 設定放在 project root 之下的 data_source 資料夾,然後 migration 的檔案放到 project root 之下的 data_source 資料夾。
image

typeorm.migration.ts 內容如下

import { ConfigService } from '@nestjs/config';
import { config } from 'dotenv';
import { DataSource } from 'typeorm';

config();
const configService = new ConfigService();
const IS_DB_SSL_MODE = configService.getOrThrow<string>(
  'NODE_ENV',
  'dev'
) == 'production';

export default new DataSource({
  type: 'postgres',
  url: configService.getOrThrow<string>('DB_URI', ''),
  ssl: IS_DB_SSL_MODE,
  extra: {
    ssl: IS_DB_SSL_MODE ? { rejectUnauthorized: false } : null,
  },
  migrations: ['migrations/*.ts'],
  migrationsRun: true,
  entities: ['src/**/*.entity.ts'],
});
  1. 配置 migration 腳本到 package.json

新增以下指令:

   "typeorm": "ts-node ./node_modules/typeorm/cli",
    "schema:sync": "pnpm run typeorm schema:sync -d data_source/typeorm.migration.ts",
    "schema:drop": "pnpm run typeorm schema:drop -d data_source/typeorm.migration.ts",
    "schema:log": "pnpm run typeorm schema:log -d data_source/typeorm.migration.ts",
    "typeorm:show": "pnpm run typeorm migration:show -d data_source/typeorm.migration.ts",
    "typeorm:run-migrations": "pnpm run typeorm migration:run -d data_source/typeorm.migration.ts",
    "typeorm:create-migration": "npm run typeorm -- migration:create migrations/$npm_config_name",
    "typeorm:generate-migration": "npm run typeorm -- migration:generate -d data_source/typeorm.migration.ts migrations/$npm_config_name",
    "typeorm:revert-migration": "pnpm run typeorm migration:revert -d data_source/typeorm.migration.ts"
  1. 建立 migration

  2. 建立 Users Migrations

npm run typeorm:create-migration --name=USER

然後就可以看到 migrations 資料夾下多了一個檔案內容如下

import { MigrationInterface, QueryRunner, Table } from "typeorm";

export class USER1725000424970 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        schema: 'public',
        name: 'users',
        columns: [
          {
            name: 'id',
            type: 'uuid',
            isPrimary: true,
          },
          {
            name: 'email',
            type: 'varchar',
            length: '200',
            isUnique: true,
            isNullable: false,
          },
          {
            name: 'password',
            type: 'varchar',
            length: '500',
            isNullable: false,
          },
          {
            name: 'role',
            type: 'varchar',
            length: '60',
            isNullable: false,
            default: "'attendee'"
          },
          {
            name: 'refresh_token',
            type: 'varchar',
            length: '500',
            isNullable: true,
            default: null,
          },
          {
            name: 'creeated_at',
            type: 'timestamp without time zone',
            isNullable: false,
            default: 'now()',
          },
          {
            name: 'updated_at',
            type: 'timestamp without time zone',
            isNullable: false,
            default: 'now()',
          }
        ]
      })
    );
  }

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

}
  1. 執行 migration 修改 schema
pnpm run typeorm:run-migrations

執行成功後就會發現,剛剛的修改生成了 users table 如下:
image

並且可以看到 migrations 資料表有紀錄一筆修改的紀錄:
image

Migration 流程(理想上)

migration 完成後,修改 AP

  1. 新增 UserDbStore 實作 UserRepostiory 介面
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UserEntity } from './schema/user.entity';
import { UserRepository } from './users.repository';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@Injectable()
export class UserDBStore implements UserRepository {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>
  ) {}
  async save(userInfo: CreateUserDto): Promise<UserEntity> {
    const user = new UserEntity();
    user.id = crypto.randomUUID();
    user.email = userInfo.email;
    user.password = userInfo.password;
    if (userInfo.role) {
      user.role = userInfo.role;
    }
    await this.userRepo.save(user);
    return user;
  }
  async findOne(criteria: Partial<UserEntity>): Promise<UserEntity> {
    const user = await this.userRepo.findOneBy(criteria);
    if (!user) {
      throw new NotFoundException(`user not found`);
    }
    return user;
  }
 async find(criteria: Partial<UserEntity>): Promise<UserEntity[]> {
    const users = await this.userRepo.findBy(criteria);
    if (users.length == 0) {
      throw new NotFoundException(`user not found`);
    }
    return users;
  }
  async update(criteria: Partial<UserEntity>, data: Partial<UserEntity>): Promise<UserEntity> {
    const queryBuilder = this.userRepo.createQueryBuilder('users');
    const result = await queryBuilder.update<UserEntity>(UserEntity, data)
      .where(criteria).updateEntity(true).execute();
    const model: UserEntity = result.raw[0] as UserEntity;
    return model;
  }
}
  1. 修改 UserService 引入的 Store 為 UserDBStore
import { Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';
import { UserRepository } from './users.repository';
import { UserEntity } from './schema/user.entity';
import { UserDBStore } from './users-db.store';
@Injectable()
export class UsersService {
  constructor(
    @Inject(UserDBStore)
    private readonly userRepo: UserRepository,
  ) {}
  async createUser(userInfo: CreateUserDto) {
    const result = await this.userRepo.save({
      email: userInfo.email,
      password: await bcrypt.hash(userInfo.password, 10),
      role: userInfo.role
    })
    return {id: result.id }
  }
  async findUser(userInfo: Partial<UserEntity>) {
    const result = await this.userRepo.findOne(userInfo);
    return result;
  }
  async updateUser(criteria: Partial<UserEntity>, data: Partial<UserEntity>) {
    const result = await this.userRepo.update(criteria, data);
    return result;
  }
}
  1. 修改 UsersModule 內的 Provider 為 UserDBStore
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtAuthStrategy } from '../auth/strategies/jwt-auth.strategy';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './schema/user.entity';
import { UserDBStore } from './users-db.store';

@Module({
  imports: [ TypeOrmModule.forFeature([UserEntity])],
  providers: [UsersService, JwtAuthGuard, JwtAuthStrategy, UserDBStore],
  controllers: [UsersController],
  exports: [UsersService, UserDBStore]
})
export class UsersModule {}

Postman 驗證

  1. register
    image
  2. login
    image
  3. refresh
    image

加入 test conainter

加入了 Postgresql 當作 persist storage 後,每次在 e2e 測試可以發現。因為每次打入的資料都會存在。所以當下次重複測試相同測資時,就會出現資料已存在這樣的錯誤。

為了能夠更方便去做環境隔離,使用 test container 可以讓整個測試環境更好控制。每次測試啟動前,先設置一台全新 Postgresql container ,並且透過 typeorm 去做初始化。

接著再去執行 e2e ,等整個測試都跑完,在 teardown 整個環境。

  1. 安裝 testcontainer @testcontainer/postgresql
pnpm i -D testcontainer @testcontainer/postgresql
  1. 設定建構 postgresql DB 設定
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { getDataSource } from '../data_source/test_container.source';
export const initPostgresql = async() => {
  const postgresql = await new PostgreSqlContainer('postgres:14')
    .withExposedPorts(5432, 5432)
    .withUsername('admin')
    .withPassword('password')
    .withDatabase('ticket_system_db')
    .start();
  global.postgresql = postgresql;
  const DB_URI = global.postgresql.getConnectionUri();
  process.env.DB_URI = DB_URI;
  const datasource = await getDataSource(DB_URI);
  await datasource.runMigrations();
}
const init =  async () => {
  await initPostgresql();
}
export default init;
  1. 撰寫 getDataSoure 程式
import { DataSource } from 'typeorm';

export const getDataSource = async (dburi: string) => {
  const datasource =  new DataSource({
    type: 'postgres',
    url: dburi,
    ssl: false,
    extra: {
      ssl: null,
    },
    migrations: ['migrations/*.ts'],
    migrationsRun: true,
    entities: ['src/**/*.entity.ts'],
  });
  await datasource.initialize();
  return datasource;
}
  1. 調整 e2e
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { isUUID } from 'class-validator';
import { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
describe('AppController (e2e)', () => {
  let app: INestApplication;
  let userId: string;
  let refreshToken: string;
  let accessToken: string;
  let postgresql: StartedPostgreSqlContainer;
  beforeAll(async () => {
    postgresql = global.postgresql;
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      transform: true,
    }))
    await app.init();
  });
  afterAll(async () => {
    await app.close();
    await postgresql.stop();
  })

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('ticket-booking-system');
  });
  it('/auth/register (POST) with failed', () => {
    return request(app.getHttpServer())
      .post('/auth/register')
      .send({
        email: 'yu',
        password: '123'
      })
      .expect(400);
  });
  it('/auth/register (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/register')
      .send({
        email: 'yu@hotmail.com',
        password: '1@q#Abz%',
        role: 'admin'
      })
      .expect(201)
      .expect(({ body }) => {
        userId = body.id;
        expect(isUUID(body.id)).toBeTruthy()
      });
  });
  // given login with correct credential
  it('/auth/login (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/login')
      .send({
        email: 'yu@hotmail.com',
        password: '1@q#Abz%'
      })
      .expect(201)
      .expect(({body}) => {
        refreshToken = body.refresh_token;
        accessToken = body.access_token;
        expect(body).toHaveProperty('access_token');
        expect(body).toHaveProperty('refresh_token');
      })
  })
  it('/users/:userId (GET)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .get(`/users/${userId}`)
      .set('Authorization', accessToken)
      .expect(200)
      .expect(({ body }) => {
        expect(body.email).toEqual('yu@hotmail.com')
      });
  })
  it('/users/:userId (GET) with failed', () => {
    const agent = request(app.getHttpServer());
    return agent
      .get(`/users/${crypto.randomUUID()}`)
      .set('Authorization', accessToken)
      .expect(404);
  })
  // given login with wrong credential with Unauthorizatiion
  it('/auth/login (POST) reject with Unauthorization', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/login')
      .send({
        email: 'yu@hotmail.com',
        password: '1@q#Abz%1'
      })
      .expect(401)
  })
  // given refesh token with exist users
  it('/auth/refresh (POST)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .post('/auth/refresh')
      .set('Authorization', refreshToken)
      .expect(201)
  })
});

  1. 設定初始化 script
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
  "globalSetup": "../test_container/init_for_postgresql.ts"
}
  1. 執行測試

image

到此為止,目前已經把前面登入與驗證的部份寫完。然而,這邊會發現,其實有一個初始的狀態並沒有設定好,也就是整個訂票系統沒有最初設定一個基礎的 admin user。目前是透過先從資料庫介面去做修改。明天會做一個正式寫法與說明。

結論

這次的系統實作,為了改善以往都是從資料表 schema 開始設計。導致真正的商流最後都變成解法導向,因此特別先以 in-memory 方式處理狀態存儲。來先把重要的商流行為實作出來,最後在透過 DI 的方式去更換原本的實作為 Db store 。算是有發揮到了 nestjs 的 DI 容器化的好處。

後需還有蠻多地方,可以再繼續完善,比如系統 logger 等等。這次儘量會以主題式的說明分析。雖然有時候,不小心寫著一個主題就偏到其他細節。但真正重要的其實都在前幾章內容。後續的實作與說明,都是為了將最前面的主題闡述清楚。


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

1 則留言

0
Calvin
iT邦新手 3 級 ‧ 2024-09-16 00:04:59

我要留言

立即登入留言