这个系列文即将进入尾声,是时候来验收一下前面所学到的东西了,虽然不会所有的功能都在此次实战演练中使用到,但我会尽量把一些我觉得很实用且常用的功能都纳入考量,那就废话不多说赶快开始吧!
这次的实战演练会做一个简单的「TodoList」,这个 TodoList 拥有基本的角色权限管理,并会有两大资源,分别是:使用者 (user) 与 待办事项 (todo)。而角色共会分成 系统管理者(admin)、管理员 (manager) 以及 成员 (member),他们各自拥有的操作权限如下:
专案架构预计会采用下方的分类方式来进行,这里仅列出重点项目:
.
├─ .env
├─ src
| ├─ common/
| ├─ configs/
| ├─ core/
| ├─ features/
| ├─ app.module.ts
| └─ main.ts
└─ rbac
├─ model.conf
└─ policy.csv
.env
:环境变数配置档。src/common
:放一些共用的项目,如:constants
、enums
、models
等。src/configs
:放环境变数相关的工厂函式。src/core
:放一些与应用程序本身较有直接关联的元件,如:guards
、interceptors
、pipes
等。src/features
:主要功能放在这里,像是这次会用到的 user
、todo
、auth
等。src/app.module.ts
:根模组。src/main.ts
:载入点。rbac
:放置 Casbin 使用到的 model
与 policy
。首先,透过 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。
我们可以先将 MongooseModule
在 AppModule
做配置,运用工厂函式配置环境变数命名空间的技巧,将 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 帮助 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 (下)。
我会希望我们的 API 回传格式式统一的,这对使用 API 的人来说是很重要的,而统一回传格式这件事情最适合用 Interceptor 来实作了,直接将其配置在全域就可以套用到所有 API 上,十分方便!而我预期的格式如下,statusCode
即 HttpCode,oData
即回传的资料:
{
"statusCode": 200,
"oData": {}
}
透过 CLI 快速产生一个 ResponseInterceptor
在 core/interceptors
资料夹底下:
$ nest generate interceptor core/interceptors/response
接着,运用 RxJS 的 pipe
与 map
来达到格式统一的效果:
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();
在设计 API 前,我们先把要存入 MongoDB 的资料设计好 Schema,好让我们之後可以使用 Model 来操作资料库,以这次要设计的系统来说,共需要设计两个 Schema,分别为:user
与 todo
。
提醒:Schema 的设计方法可以参考 DAY22 - MongoDB。
这个专案所需的使用者资讯不必太多,只需要下方几项即可:
username
:使用者名称,必填栏位,最小长度 6
、最大长度 16
。email
:电子信箱,必填栏位。password
:密码,必填栏位,最小长度 8
、最大长度 20
。role
:角色,必填栏位,接受的值为:admin
、manager
以及 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
并没有用到我们定义好的限制条件,原因是存入资料库的是 hash
与 salt
,这个限制条件会放在 DTO 来做资料检验。
提醒:盐加密的技巧可以参考 DAY23 - Authentication (上)。
以下为待办事项所需的栏位:
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家族究竟是何方神圣_上篇】
Youtube 频道:https://www.youtube.com/c/kaochenlong ...
什麽是 Composite Pattern? 将单一与多个物件的使用方式统一给使用者使用 UML 图...
它可能在任何一个Control.要仔细找. Page.Controls -System.Web.UI...
经由昨天我们可以知道,纯值在传递时是透过复制的方式,而物件则是利用传参考的方式,今天就来练习几个关於...
今天介绍 JS 内 var 与 let 的後两点差异。 执行环境 Execution Context...