iT邦幫忙

2024 iThome 鐵人賽

DAY 14
1
Software Development

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

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

  • 分享至 

  • xImage
  •  

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

目標

昨天已經把活動訂票管理系統做了初步的分析,與職責區分如下。

image

今天目標會是先規劃, UserModule 的部份。

概念

UserModule 的部份主要是針對使用者身份驗證相關的處理。主要項目有以下:

  1. 使用者註冊
  2. 使用者登入
  3. 使用者身份驗證

因為昨天的系統發想有特別指定情境,希望使用者是使用手機來作為用戶端操作。所以這邊設計,不能使用原本網頁才能使用 cookie 來讓存放使用者狀態 session id 。

因此,這邊會採用 jwt 的技術,來做存取授權 token 與更新授權 token 。來做權限驗證。從 spec 來看,需要有兩種權限身份:活動管理者,一般參加者。活動管理者可以針對活動做管理,而一般參加者只能參加活動或是管理自己參加過的活動票券。

設計規範

  1. 對系統註冊一位使用者,系統必須出現該使用者
  2. 使用者登入系統之後,系統必須能夠讀取到登入資訊
  3. 當使用者無法做出不符合權限的操作。

針對以上規範想出的程式介面

image

建構專案

nest new ticket-booking-system

建構 auth module, user module 與內部結構

nest g mo auth
nest g controller auth
nest g mo users
nest g s users
nest g controller users

先撰寫驗證

為了能夠讓設計能夠被檢驗,所以先從想檢驗的部份開始撰寫

  1. service 部份
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { isUUID } from 'class-validator';
import { UserStore } from './users.store';

describe('UsersService', () => {
  let service: UsersService;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UsersService, UserStore],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
  /**  given a valid user info object
  {
    "email": "bda605@hotmail.com",
    "password": "password" 
  }
  ===>
  {
    "id": uuid format string
  }
  */
  it('should return an created hashed', async () => {
    const userInfo = {
      email: "bda605@hotmail.com",
      password: "password"
    }
    const result = await service.createUser(userInfo);
    expect(result).toHaveProperty('id');
    expect(isUUID(result['id'], 4)).toBeTruthy();
  });
});

  1. controller 部份
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { UsersService } from '../users/users.service';

describe('AuthController', () => {
  let controller: AuthController;
  let service: UsersService;
  const mockUserService = {
    createUser: jest.fn().mockResolvedValue({})
  }
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [AuthController],
      providers: [{
        provide: UsersService,
        useValue: mockUserService,
      }]
    }).compile();

    controller = module.get<AuthController>(AuthController);
    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
   /**
   given a userInfo Object
  {
    "email": "bda605@hotmail.com",
    "password": "password" 
  }
   */
  it('service.createUser should be call once', async () => {
    const userInfo = {
      email: "bda605@hotmail.com",
      password: "password" 
    };
    await controller.register(userInfo);
    expect(service.createUser).toHaveBeenCalledTimes(1);
    expect(service.createUser).toHaveBeenCalledWith(userInfo);
  });
});

實作

針對需要的行為,做最基礎的實踐。然後再逐步改進重構。

  1. 在 UsersService 建構 createUser 方法
  2. 實踐回傳值為 uuid 的 id
  3. 根據互動的介面界定 DTO
import { Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';
import { UserStore } from './users.store';
import { UserRepository } from './users.repository';
import { UserEntity } from './schema/user.entity';
@Injectable()
export class UsersService {
  constructor(
    @Inject(UserStore)
    private readonly userRepo: UserRepository,
  ) {}
  async createUser(userInfo: CreateUserDto) {
    const result = await this.userRepo.save({
      email: userInfo.email,
      password: await bcrypt.hash(userInfo.password, 10)
    })
    return {id: result.id }
  }
  async findUser(userInfo: Partial<UserEntity>) {
    const result = await this.userRepo.findOne(userInfo);
    return result;
  }
}

對於建立使用者資料,預設需要 email, password 來建立資料,設定 DTO 如下

import { IsEmail, IsNotEmpty, IsString, IsStrongPassword } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;
  @IsString()
  @IsNotEmpty()
  @IsStrongPassword()
  password: string;
}

預設需要針對,使用者存放有以下行為,建一個 Repository 作為行為介面如下

import { CreateUserDto } from './dto/create-user.dto';
import { UserEntity } from './schema/user.entity';

export abstract class UserRepository {
  abstract save(userInfo: CreateUserDto): Promise<UserEntity>
  abstract findOne(criteria: Partial<UserEntity>): Promise<UserEntity>
  abstract find(criteria: Partial<UserEntity>): Promise<UserEntity[]>
} 

而今天會先以 In Memory 方式來實作這個介面。如下:

import { NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UserEntity } from './schema/user.entity';
import { UserRepository } from './users.repository';

export class UserStore implements UserRepository {
  users: UserEntity[] = [];
  async save(userInfo: CreateUserDto): Promise<UserEntity> {
    const id = crypto.randomUUID();
    const newUser = new UserEntity();
    newUser.id = id;
    newUser.email = userInfo.email;
    newUser.password = userInfo.password;
    this.users.push(newUser);
    return newUser;
  }
  async findOne(criteria: Partial<UserEntity>): Promise<UserEntity> {
    const user = this.users.find((item) => {
      return item.id ==  criteria.id || item.email == criteria.email
    });
    if (!user) {
      throw new NotFoundException({ message: `user with ${criteria.id} not found`});
    }
    return user;
  }
  async find(criteria: Partial<UserEntity>): Promise<UserEntity[]> {
    throw new Error('Method not implemented.');
  }
}

而預計存放的資料行別如下:

import { IsEmail, IsString, IsUUID } from 'class-validator';

export class UserEntity {
  @IsUUID()
  id: string;
  @IsEmail()
  email: string;
  @IsString()
  password: string;
}

新增驗證

為了能檢核使用者註冊後資料有被寫入,於是加入了一個 getUser 的介面在 UserController 如下

import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  const mockUserService = {
    createUser: jest.fn().mockResolvedValue({}),
    findUser: jest.fn().mockResolvedValue({})
  }
  let controller: UsersController;
  let service: UsersService;
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [{
        provide: UsersService,
        useValue: mockUserService,
      }]
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
  /**
   given a userInfo Object
  {
    "id": uuid format 
  }
   */
  it('service.findUser should be called once', async () => {
    const userId = crypto.randomUUID();
    await controller.getUser(userId);
    expect(service.findUser).toHaveBeenCalledTimes(1);
    expect(service.findUser).toHaveBeenCalledWith({id: userId});
  })
});

加入從端點打入的驗證。如下

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';
describe('AppController (e2e)', () => {
  let app: INestApplication;
  let userId: string;
  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      transform: true,
    }))
    await app.init();
  });

  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%'
      })
      .expect(201)
      .expect(({ body }) => {
        userId = body.id;
        expect(isUUID(body.id)).toBeTruthy()
      });
  });
  it('/users/:userId (GET)', () => {
    const agent = request(app.getHttpServer());
    return agent
      .get(`/users/${userId}`)
      .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()}`)
      .expect(404);
  })
});

實作 UserController 邏輯

import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService
  ) {}
  @Get(':id')
  async getUser(@Param('id', ParseUUIDPipe) id: string ) {
    return this.usersService.findUser({id: id});
  }
}

實作 AuthController 邏輯

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from '../users/dto/create-user.dto';
import { UsersService } from '../users/users.service';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly usersService: UsersService
  ) {}
  @Post('register')
  async register(@Body() userInfo: CreateUserDto) {
    return await this.usersService.createUser(userInfo);
  }
}

由於目前篇幅有點過長,所以 Jwt 驗證以及 Refresh 的設計實作部份挪移到下一篇來解說。

加入 validationPipe 到 app

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // validation logic
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    transform: true,
  }))
  await app.listen(3000);
}
bootstrap();

Postman sample

  1. Register Sample
    image
  2. Get User Sample
    image

https://documenter.getpostman.com/view/2441389/2sAXjGducf

設定 Module 相依性

在 UsersModule 會需要給外部使用的 Provider 有二個。 UsersService 與 UserStore 。放到 exports 欄位,這樣在其他需要使用的 module 就可以直接 import 來使用。

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UserStore } from './users.store';

@Module({
  providers: [UsersService, UserStore],
  controllers: [UsersController],
  exports: [UsersService, UserStore]
})
export class UsersModule {}

在 AuthModule 會需要引用 UsersModule 。使用到 UsersModule 內的 UsersService 。實作如下:

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { UsersService } from '../users/users.service';

@Module({
  imports: [
    UsersModule,
  ],
  providers: [UsersService],
  controllers: [AuthController]
})
export class AuthModule {}

結論

本篇透過 nestjs 本身測試框架來實行類似於 BDD 開發模式。先定義好系統行為,然後再從驗證開始思考如何設計系統。這種方法,好處是能夠透過宣告式的方式,把系統逐步來拆解分析。降低了一開始就去思考太細節的問題,著重在重要的需求解析。

這種開發模式,需要開發針對需求有系統性的思考。先把系統規範建立起來。實作遵循著以下流程:測試紅燈 -> 測試綠燈 -> 重構。逐步把整個系統從底層服務建構起來。

開發之困難在於分析系統是把需求由大而小分析。但系統建構卻是反著來,需要由小而大,由具體實作搭組合出抽象邏輯。 nestjs 提供 Provider 的依賴住入,讓使用者可以先透過抽象介面定義出行為,然後再根據行為實踐出具體邏輯。比較接近於設計流程。

後話

如果是先寫好實作再去補測試。那是不是反而是測試去屈就了實作,失去了測試的作用了呢?

image


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

尚未有邦友留言

立即登入留言