iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Modern Web

NestJS 帶你飛!系列 第 29

[NestJS 帶你飛!] DAY29 - 實戰演練 (上)

這個系列文即將進入尾聲,是時候來驗收一下前面所學到的東西了,雖然不會所有的功能都在此次實戰演練中使用到,但我會盡量把一些我覺得很實用且常用的功能都納入考量,那就廢話不多說趕快開始吧!

系統規劃

這次的實戰演練會做一個簡單的「TodoList」,這個 TodoList 擁有基本的角色權限管理,並會有兩大資源,分別是:使用者 (user)待辦事項 (todo)。而角色共會分成 系統管理者(admin)管理員 (manager) 以及 成員 (member),他們各自擁有的操作權限如下:

  • 系統管理者:所有操作皆可使用。
  • 管理者:可以針對 Todo 的資源做操作以及取得使用者資訊,但沒有操作使用者相關的權限。
  • 成員:可以讀取、修改 Todo 以及取得使用者資訊,其餘操作皆沒有權限。

專案架構預計會採用下方的分類方式來進行,這裡僅列出重點項目:

.
├─ .env
├─ src
|  ├─ common/
|  ├─ configs/
|  ├─ core/
|  ├─ features/
|  ├─ app.module.ts
|  └─ main.ts
└─ rbac
   ├─ model.conf
   └─ policy.csv
  • .env:環境變數配置檔。
  • src/common:放一些共用的項目,如:constantsenumsmodels 等。
  • src/configs:放環境變數相關的工廠函式。
  • src/core:放一些與應用程式本身較有直接關聯的元件,如:guardsinterceptorspipes 等。
  • src/features:主要功能放在這裡,像是這次會用到的 usertodoauth 等。
  • src/app.module.ts:根模組。
  • src/main.ts:載入點。
  • rbac:放置 Casbin 使用到的 modelpolicy

建置專案

首先,透過 CLI 快速建立一個空白專案:

$ nest new <PROJECT_NAME>

接著,將我們會用到的相關套件透過 npm 進行安裝:

$ npm install @nestjs/config // 環境變數模組
$ npm install @nestjs/mapped-types // DTO 映射型別技巧用
$ npm install @nestjs/mongoose mongoose // 與 MongoDB 互動用
$ npm install @nestjs/passport passport // 身分驗證模組
$ npm install @nestjs/jwt passport-jwt // JWT 與它的驗證策略
$ npm install @types/passport-jwt -D // passport-jwt 的型別定義
$ npm install passport-local // 本地身分驗證策略
$ npm install @types/passport-local -D // 本地身分驗證策略的型別定義
$ npm install casbin // 授權套件
$ npm install class-validator class-transformer // DTO 使用的裝飾器

配置環境變數

在開發過程中,我們會需要將 MongoDB 相關的敏感資訊以及 JWT 密鑰放在環境變數,所以在 .env 檔案裡進行配置:

MONGO_USERNAME=<YOUR_USERNAME>
MONGO_PASSWORD=<YOUR_PASSWORD>
MONGO_RESOURCE=<YOUR_RESOURCE>

JWT_SECRET=<YOUR_JSW_SECRET_KEY>

提醒:詳細環境變數之配置可以參考 DAY16 - Configuration

配置 Mongoose

我們可以先將 MongooseModuleAppModule 做配置,運用工廠函式配置環境變數命名空間的技巧,將 MongoDB 的相關環境變數用 mongo 這個命名空間群組在一起。在 configs 資料夾底下新增 mongo.config.ts

import { registerAs } from '@nestjs/config';

export default registerAs('mongo', () => {
  const username = process.env.MONGO_USERNAME;
  const password = encodeURIComponent(process.env.MONGO_PASSWORD);
  const resource = process.env.MONGO_RESOURCE;
  const uri = `mongodb+srv://${username}:${password}@${resource}?retryWrites=true&w=majority`;
  return { username, password, resource, uri };
});

接著,在 AppModule 引入 ConfigModule 並進行相關配置,再將 MongoDB 需要用到的環境變數帶入 MongooseModule 中,進而建立連線:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import mongoConfigFactory from './configs/mongo.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
})
export class AppModule {}

提醒:詳細 mongoose 的使用方法可以參考 DAY22 - MongoDB,工廠函式配置環境變數命名空間可以參考 DAY16 - Configuration

配置密鑰

我們在實作身分驗證時會使用到 JWT,我們可以先把需要使用到的密鑰透過 secrets 這個命名空間來群組在一起。在 configs 資料夾中新增 secret.config.ts

import { registerAs } from '@nestjs/config';

export default registerAs('secrets', () => {
  const jwt = process.env.JWT_SECRET;
  return { jwt };
});

接著,調整在 AppModule 中的 ConfigModule,多添加一個工廠函式在 load 中:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
})
export class AppModule {}

實作全域 Pipe

可以透過 Pipe 幫助 API 進行型別檢查,這裡可以運用 ValidationPipe 配置在全域的技巧來達成,我們只需要修改 AppModule 即可,在 providers 中運用自訂 Provider 的技巧來進行配置,provide 指定為 APP_PIPE,而 useClass 指定為 ValidationPipe

import { APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

提醒:全域 Pipe 的使用方法可以參考 DAY10 - Pipe (下)

實作全域 Interceptor

我會希望我們的 API 回傳格式式統一的,這對使用 API 的人來說是很重要的,而統一回傳格式這件事情最適合用 Interceptor 來實作了,直接將其配置在全域就可以套用到所有 API 上,十分方便!而我預期的格式如下,statusCode 即 HttpCode,oData 即回傳的資料:

{
  "statusCode": 200,
  "oData": {}
}

透過 CLI 快速產生一個 ResponseInterceptorcore/interceptors 資料夾底下:

$ nest generate interceptor core/interceptors/response

接著,運用 RxJS 的 pipemap 來達到格式統一的效果:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class ResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const handler = next.handle();
    return handler.pipe(
      map((data) => {
        const response = context.switchToHttp().getResponse();
        return {
          statusCode: response.statusCode,
          oData: data,
        };
      }),
    );
  }
}

建立 index.ts 來做匯出管理:

export { ResponseInterceptor } from './response.interceptor';

最後,只需要在 AppModule 透過自訂 Provider 的方式進行全域配置即可:

import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import { ResponseInterceptor } from './core/interceptors';

import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ResponseInterceptor,
    },
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

提醒:Interceptor 的使用方法可以參考 DAY12 - Interceptor

配置全域路由前綴

我希望我們設計的 API 都可以用 /api 作為路由前綴,但又不想要設計一個 ApiController,這時候可以直接在 main.ts 使用 app.setGlobalPrefix('api') 來達到我們要的效果:

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  await app.listen(3000);
}
bootstrap();

Schema 設計

在設計 API 前,我們先把要存入 MongoDB 的資料設計好 Schema,好讓我們之後可以使用 Model 來操作資料庫,以這次要設計的系統來說,共需要設計兩個 Schema,分別為:usertodo

提醒:Schema 的設計方法可以參考 DAY22 - MongoDB

User Schema

這個專案所需的使用者資訊不必太多,只需要下方幾項即可:

  • username:使用者名稱,必填欄位,最小長度 6、最大長度 16
  • email:電子信箱,必填欄位。
  • password:密碼,必填欄位,最小長度 8、最大長度 20
  • role:角色,必填欄位,接受的值為:adminmanager 以及 member,預設值為 member

在開始設計 UserSchema 之前,可以先將欄位的最大值、最小值、角色列表設計成常數與列舉,這樣在其他地方也能夠使用相同的限制條件。在 common/constants 資料夾下建立一個 user.const.ts

export const USER_USERNAME_MIN_LEN = 6; // username 最小長度
export const USER_USERNAME_MAX_LEN = 16; // username 最大長度

export const USER_PASSWORD_MIN_LEN = 8; // password 最小長度
export const USER_PASSWORD_MAX_LEN = 20; // password 最大長度

接著,我們把角色列表做成列舉,在 common/enums 資料夾下新增 role.enum.ts

export enum Role {
  ADMIN = 'admin',
  MANAGER = 'manager',
  MEMBER = 'member',
}

最後,就是來設計我們的 UserSchema,在 common/models 資料夾下建立 user.schema.ts

import {
  ModelDefinition,
  Prop,
  raw,
  Schema,
  SchemaFactory,
} from '@nestjs/mongoose';
import { Document } from 'mongoose';

import {
  USER_USERNAME_MAX_LEN,
  USER_USERNAME_MIN_LEN,
} from '../constants/user.const';
import { Role } from '../enums/role.enum';

export type UserDocument = User & Document;

@Schema({ versionKey: false })
export class User {
  @Prop({
    required: true,
    minlength: USER_USERNAME_MIN_LEN,
    maxlength: USER_USERNAME_MAX_LEN,
  })
  username: string;

  @Prop({
    required: true,
  })
  email: string;

  @Prop({
    required: true,
    type: raw({
      hash: String,
      salt: String,
    }),
  })
  password: { hash: string; salt: string };

  @Prop({
    required: true,
    enum: Role,
    default: Role.MEMBER,
  })
  role: Role;
}

export const UserSchema = SchemaFactory.createForClass(User);

export const USER_MODEL_TOKEN = User.name;

export const UserDefinition: ModelDefinition = {
  name: USER_MODEL_TOKEN,
  schema: UserSchema,
};

會發現 password 並沒有用到我們定義好的限制條件,原因是存入資料庫的是 hashsalt,這個限制條件會放在 DTO 來做資料檢驗。

提醒:鹽加密的技巧可以參考 DAY23 - Authentication (上)

Todo Schema

以下為待辦事項所需的欄位:

  • title:待辦事項的標題,必填欄位,最小長度 3、最大長度 20
  • description:待辦事項的詳細描述,選填欄位,最大長度 200
  • completed:是否完成該待辦事項,必填欄位,預設為 false

將限制條件設計為常數,在 common/constants 資料夾下新增 todo.const.ts

export const TODO_TITLE_MIN_LEN = 3; // title 最小長度
export const TODO_TITLE_MAX_LEN = 20; // title 最大長度

export const TODO_DESCRIPTION_MAX_LEN = 200; // description 最大長度

最後,在 common/models 資料夾下新增 todo.model.ts

import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

import {
  TODO_DESCRIPTION_MAX_LEN,
  TODO_TITLE_MAX_LEN,
  TODO_TITLE_MIN_LEN,
} from '../constants/todo.const';

export type TodoDocument = Todo & Document;

@Schema({ versionKey: false })
export class Todo {
  @Prop({
    required: true,
    minlength: TODO_TITLE_MIN_LEN,
    maxlength: TODO_TITLE_MAX_LEN,
  })
  title: string;

  @Prop({
    maxlength: TODO_DESCRIPTION_MAX_LEN,
  })
  description?: string;

  @Prop({
    required: true,
    default: false,
  })
  completed: boolean;
}

export const TodoSchema = SchemaFactory.createForClass(Todo);

export const TODO_MODEL_TOKEN = Todo.name;

export const TodoDefinition: ModelDefinition = {
  name: TODO_MODEL_TOKEN,
  schema: TodoSchema,
};

小結

今天先將一些基礎設施建立完畢,如:環境變數、MongoDB 的連線、Schema 的配置、統一回傳格式等,如此一來,後面的開發就可以基於這些東西繼續進行。下一篇就會開始設計 API 了,敬請期待!


上一篇
[NestJS 帶你飛!] DAY28 - CORS
下一篇
[NestJS 帶你飛!] DAY30 - 實戰演練 (中)
系列文
NestJS 帶你飛!32

1 則留言

0
json_liang
iT邦新手 4 級 ‧ 2021-10-14 13:48:55

賀!!!即將完賽! 感謝大大分享很實用的 NestJs 讓同為 Nodejs 的開發者學到很多

HAO iT邦新手 4 級 ‧ 2021-10-14 15:53:48 檢舉

謝謝你的支持,能夠幫助到你我很高興/images/emoticon/emoticon07.gif

我要留言

立即登入留言