今天目標會是先處理, UserModule 的關於資料儲存部份。
之前的實作中, UsersService 主要的職責是處理 User 資訊的處理,其中關於存儲的部份是透過 UserStore 這個 InMemory 的實作來處理。當伺服器一但重新啟動,原本存儲的狀態就會消失了。
為了能夠讓資料狀態能夠持久保存,需要透過資料庫這類存儲應用程式。資料庫這種存儲應用程式提供持久保存的服務,可以讓資料以特定格式儲存到硬碟,並且提供優化查詢的結構以及方便開發者與使用者的介面。
在這邊因為要儲存的資料具有結構化,且需要具有一致性狀態的考量,所以選擇關聯式資料庫來簡化一致性的設計。並且使用 Postgresql 這個開源的關聯式資料庫。
其中一些重要業務狀態 Entity 大致可以補捉關係如下
首先會從 User 的這個 Entity 開始實作。為了簡化,搭建方式採用 docker 。 因為業務流程比較簡單,這裡打算透過 typeorm 來做為 model 的方式,降低實作細節的複雜度。
設定 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 的 migration 機制,來從 entity schema 來做 schema 管控。
pnpm i -S @nestjs/typeorm typeorm pg
為了方便使用需要在 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 連線資料庫的最大連線數目。避免耗費太多資源,儘可能復用資源。
目標是把 migration 設定放在 project root 之下的 data_source 資料夾,然後 migration 的檔案放到 project root 之下的 data_source 資料夾。
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'],
});
新增以下指令:
"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"
建立 migration
建立 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);
}
}
pnpm run typeorm:run-migrations
執行成功後就會發現,剛剛的修改生成了 users table 如下:
並且可以看到 migrations 資料表有紀錄一筆修改的紀錄:
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;
}
}
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;
}
}
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 {}
加入了 Postgresql 當作 persist storage 後,每次在 e2e 測試可以發現。因為每次打入的資料都會存在。所以當下次重複測試相同測資時,就會出現資料已存在這樣的錯誤。
為了能夠更方便去做環境隔離,使用 test container 可以讓整個測試環境更好控制。每次測試啟動前,先設置一台全新 Postgresql container ,並且透過 typeorm 去做初始化。
接著再去執行 e2e ,等整個測試都跑完,在 teardown 整個環境。
pnpm i -D testcontainer @testcontainer/postgresql
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;
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;
}
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)
})
});
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"globalSetup": "../test_container/init_for_postgresql.ts"
}
到此為止,目前已經把前面登入與驗證的部份寫完。然而,這邊會發現,其實有一個初始的狀態並沒有設定好,也就是整個訂票系統沒有最初設定一個基礎的 admin user。目前是透過先從資料庫介面去做修改。明天會做一個正式寫法與說明。
這次的系統實作,為了改善以往都是從資料表 schema 開始設計。導致真正的商流最後都變成解法導向,因此特別先以 in-memory 方式處理狀態存儲。來先把重要的商流行為實作出來,最後在透過 DI 的方式去更換原本的實作為 Db store 。算是有發揮到了 nestjs 的 DI 容器化的好處。
後需還有蠻多地方,可以再繼續完善,比如系統 logger 等等。這次儘量會以主題式的說明分析。雖然有時候,不小心寫著一個主題就偏到其他細節。但真正重要的其實都在前幾章內容。後續的實作與說明,都是為了將最前面的主題闡述清楚。