[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 了,敬请期待!


<<:  Day 29 使用 docker-compose 来安装 Wordpress

>>:  【Day 4_ Arm Mali GPU家族究竟是何方神圣_上篇】

EP 02 - 文件阅读

Youtube 频道:https://www.youtube.com/c/kaochenlong ...

DAY 24:Composite Pattern,管理有层次的物件们

什麽是 Composite Pattern? 将单一与多个物件的使用方式统一给使用者使用 UML 图...

web C# 找出页面上的control

它可能在任何一个Control.要仔细找. Page.Controls -System.Web.UI...

[Day23] 物件传参考范例

经由昨天我们可以知道,纯值在传递时是透过复制的方式,而物件则是利用传参考的方式,今天就来练习几个关於...

Day 12 JavaScript var vs let (2)

今天介绍 JS 内 var 与 let 的後两点差异。 执行环境 Execution Context...