走马观Nest.js
2021-07-02
TL;DR
从19年夏天偶然了解 Nest.js 至今,我已经使用它构建了三个应用,不过应用的规模都不大。每个应用之间都相隔半年以上,但每一次它给我的开发体验都是十分舒服的,它已成为目前我构建后端应用的首选框架。最近在开发代码质量检测工具的服务端时我依然选择它开发。这篇文章对 Nest.js 进行了简单的介绍,通过此文能够基本了解 Nest.js 的使用和开发。
初次接触 Nest.js,可能会觉得它的概念特别多,引入了 Providers
、Pipes
等概念。我个人觉得这些概念的出现是很自然的,而且这恰好是它最大的特点,一个后端应用从请求到达至响应的每一个环节都有具体的执行者,这种“专人专事”的方式使得项目非常清晰。
控制器 (Controllers)
在 Nest.js 中,每一个控制器是一个类,我们使用 @Controller
来标识它,配合 @Get/Post 等 method 标识,即可完成一个控制器。
import { Controller, Get } from '@nestjs/common'
@Controller('test')
export class TestController {
@Get('list')
getList (@Query('name') name: string) {}
}
而且框架内置了非常多的 HttpStatus 和 HttpExceptions,极方便的辅助开发。
提供者 (Providers)
Providers 是一个很抽象的概念,其几乎可以是任何一个类,通过依赖注入到不同的类中,让被注入对象的创建工作委托给 Nest.js 运行时,使得不同模块/类之间的使用变得非常简单。使用 @Injectable 修饰一个类,即可将其变成 Providers。
比如我们上面有了控制器,根据 MVC 思想,一般我们的业务处理会放在 M 层去处理。
// testService.ts
import {Injectable} from '@nestjs/common'
@Injectable()
export class TestService {
hanleList () {
}
}
那么在我们的其他模块,便可以通过依赖注入使用这个 Providers,你不需要去做诸如 new TestService()
的事情。
import { Controller, Get } from '@nestjs/common'
import { TestService } from 'testService.ts'
@Controller('test')
export class TestController {
constructor (
private readonly testService: TestService
) {}
@Get('list')
getList () {
testService.hanleList();
}
}
模块
从外部看,模块则是一个具体功能的所有系统的集合。Nest.js 应用是由不同的模块组成,就像是前端开发中的组件,由根组件出发,由各个子组件组成。模块由 @Module 进行修饰,其内部定义模块的各个元素:
// testModule.ts
import { Module } from '@nestjs/common';
import { TestController } from 'testController.ts'
import { TestService } from 'testService.ts'
@Module({
controllers: [TestController],
providers: [TestService]
})
export class TestModule {}
那么在其他模块,如果想使用某一个模块,就像是前端开发中使用某一个组件一样,只需要在父模块中引入即可:
// rootModule.ts
import { Module } from '@nestjs/common'
import { TestModule } from 'testModule.ts'
@Module({
import: [
TestModule
]
})
export class AppModule {}
当了解了这几个概念后,不考虑工程化的情况下,已经足够我们去编写应用。所以其他概念的引入,则是将开发中各个相似的处理抽离出来。
中间件
中间件是在路由处理之前调用的函数,可以使用它在路由请求处理前做一些事情,譬如对请求进行修改。Nest.js 中使用中间件很简单,大致分为两类,应用中间件和全局中间件。
应用中间件
应用中间件即只用在某个模块上,它的使用一般是在模块文件中,比如下面这个中间件 LoggerMiddleware 则提供给 /test/xxx
路由使用
// testModule.ts
import { Module, MiddlewareConsumer } from '@nestjs/common';
import LoggerMiddleware from './LoggerMiddleware.ts';
@Module({
// ...
})
export class TestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('test');
}
}
全局中间件
全局中间件则是对整个应用使用,我们需要在 Nest.js 构建的应用的启动文件里 use
即可:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './appModule.ts';
import { logger } form './loggerMiddleware.ts';
async function bootstrap () {
const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(80);
}
异常过滤器
程序出现异常很正常,但是对于用户侧,我们并不希望用户看到具体的异常报错,而是提供给其清晰地友好的信息。
一般来说,我们常在逻辑中这么抛出异常:
// testService.ts
// do something...
if (xxx) {
throw new HttpException({
status: HttpStatus.FORBIDDEN,
error: '这里是给用户看的错误信息',
}, HttpStatus.FORBIDDEN);
}
于是客户端便收到了如下的响应:
{
"status": 403,
"error": "这里是给用户看的错误信息"
}
上面的
HttpException
和HttpStatus
分别是 Nest.js 内置的类和枚举器。
但是并不是所有的异常我们都希望在每个地方这么处理,有时候我们想统一处理异常,并做一些诸如日志记录的事情,这时候就要自己实现异常过滤器。下面是一个示例:
// allExceptionFilter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
Logger
} from "@nestjs/common";
@Injectable()
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const msg = exception['response'] && exception['response']['msg'] || exception['message'] || '服务端错误';
Logger.error(`捕获异常,${msg}`, 'exception');
response.status(200).json({
statusCode: 200,
success: 0,
msg
});
}
}
可以看到,异常过滤器需要继承 ExceptionFilter
,并重载 catch
方法。异常过滤器也是一个 Provider。
要使用这个异常过滤器,我们只需要在目标 controller 上用 UseFilters
装饰器声明即可:
// testController.ts
import { Controller, UseFilters } from '@nest/common';
import { AllExceptionFilter } from './allExceptionFilter';
@Controller('test')
@UseFilters(new AllExceptionFilter())
export class TestController {}
管道
从客户端传到路由控制器的数据,会经过管道,所以管道是数据传输的管子,在这里我们可以对数据进行:
- 转换
- 验证
如果在管道层抛出异常,则请求并不会到达路由层。
假设我们已经存在一个叫做 testPipe
的管道,我们要使用它对 /test/report
接口的数据进行验证或转换,只需要:
- 使用
UsePipes
声明要使用的管道 - 将参数需满足的形式传给管道 [非必须]
// testController.ts
@Controller('test')
export class TestController {
@Post('report')
@UsePipes(new TestPipe(testPipeSchema))
handleReport (@Body() reportData: reportData) {
// ...
}
}
很显然,管道内部则是对数据进行处理。为了再加深印象理解管道,我们简单实现 testPipe 这个管道。
一个管道需要实现 PipeTransform
, 而 transform 方法中 value 变量为当前处理的参数,即我们路由中收到的参数; metaData 中包含有请求上下文的信息。
// testPipe.ts
import {
Injectable,
PipeTransform,
ArgumentMetadata,
BadRequestException,
HttpStatus
} from "@nestjs/common";
@Injectable()
export class TestPipe implements PipeTransform {
transform (value: reportData, metaData: ArgumentMetadata) {
if (!Array.isarray(value)) {
throw new BadRequestException({
status: HttpStatus.BAD_REQUEST,
message: '参数错误,xxx必须是数组'
});
}
}
return value;
}
Nest.js 中也内置了许多有用的管道,对于许多场景都可以开箱即用。而且我们当然也可以绑定全局的管道,在 main.ts
中 use
即可:
// ...
app.useGlobalPipes(new ValidationPipe());
// ...
守卫
守卫从名字也看得出来,它的工作就是询问“来者何人”——鉴权。当一个操作请求到达,一般来说我们的应用针对它需要做两件事情:
- 我们的对某些操作(代码里的函数)需要定义什么角色或者说具备什么凭证才能执行,即定义条件
- 守卫里要校验当前请求者是否是这个角色或具备凭证,即验证条件
Nest.js 的设计也正是如此,下面我们从这两个角度来看看 Nest.js 里 守卫是怎么样的。先假设我们构建了一个叫做 RolesGuard 的守卫。
定义条件
// testController.ts
import { Controller, UseGuards, Post, SetMetadata } from '@nestjs/common';
import { RolesGuard } from './rolesGuard.ts';
@Controller('test')
@UseGuards(RolesGuard)
export class TestController {
@Post('deleteValue')
@SetMetadata('roles', ['admin', 'admin2'])
async deleteValue (@Body() deleteItem: string) {
// ....
}
}
解释一下这段代码:
- 首先在控制器上通过
UseGuard
使用了这个守卫 - 在 deleteValue 这个操作上,通过
setMetadata
这个装饰器指定了权限,即 roles 为['admin', 'admin2']
中的任意一个才可以
验证条件
下面我们来实现这个守卫,来看看守卫的原理。
// rolesGuard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) {
return false;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
return roles.includes(user.roles);
}
}
- 守卫也是一个 Provider,因此我们在 Controller 中直接使用的 RolesGuard,而没有
new RolesGuard()
- 通过
canActivate
返回的布尔值决定是否通过验证 - 在守卫中,可以通过
context
参数拿到请求的上下文。除了示例中可以拿到 request 外,还可以知道这个请求将会被哪个方法处理(getHandler)等信息 - 我们可以通过
Reflector
这个反射器,拿到 Controller 中通过setMetadata
设置的条件
怎么样,这种依赖注入解耦后,整个结构是不是赏心悦目。
拦截器
当我们想要在函数或整个应用执行前/后做一些事情,但是又不想对其有侵入性时,便可以使用拦截器。比如说:
- 想统计一下函数执行时间,侵入性的做法是不是就需要在函数开始前后获取时间然后运行完后相减
- 想对所有请求的响应结果做一个统一的数据格式转换
这种不入侵原程序来影响原程序的思想便是面向切面编程(AOP)。下面看看 Nest.js 中的拦截器,先假设我们定义了叫 ResponeInterceptor
的拦截器,它将请求的响应转成统一格式。
使用拦截器
只需要在控制器或者方法上使用 UseInterceptors
装饰器即可。
// testController.ts
import { Controller, UseInterceptors, Get } from '@nest/common';
@Controller('test')
@UseInterceptors(ResponeInterceptor)
export class TestController {
@Get('getValue')
async getValue (@Query() id: string) {
return '这是获取的数据';
}
}
拦截器实现
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from 'rxjs/operators';
@Injectable()
export class RequestInterceptor implements NestInterceptor {
intercept (context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(map(data => {
const baseResponse = {
success: 1,
data: null,
msg: ''
};
return Object.assign(baseResponse, data);
}));
}
}
这段拦截器的功能则是将每个路由处理中返回的数据以 data 字段返回给客户端。拦截器的几个要素:
- 实现
NestInterceptor
接口的 intercept 方法 - intercept 方法的参数中,context 参数和守卫中一致,即在拦截器中也可以获得请求、方法执行者等信息
next.handle()
实际上是在执行对应路由的方法,返回值为Observable
流,比如控制器中的 getValue 方法。然后pipe
中订阅了 handle 返回的Observable
,于是可以在 map 做想要对值做的事情。这部分实际上是 Rxjs 的知识。
顺带一提,我们甚至还可以在拦截器里处理异常。
// ...
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
catchError(err => throwError(new BadGatewayException())),
);
}
一切准备就绪后,还需要在 Module 里声明拦截器:
import { APP_INTERCEPTOR } from '@nestjs/core';
import { RequestInterceptor } from './requestInterceptor.ts';
@Module({
// ...
providers: [
{
provide: APP_INTERCEPTOR,
useClass: RequestInterceptor
}
],
// ...
})
其他
使用 Express 生态
Nest.js 底层基于 Express.js,因此可以使用 Express 的周边生态。
静态资源、跨域、body大小限制
// main.ts
import * as serverStatic from 'serve-static';
import { AppModule } from './appModule.ts';
import { json, urlencoded } from 'body-parser';
const bodyLimit = '50mb';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
app.use(json({limit: bodyLimit}));
app.use(urlencoded({limit: bodyLimit, extended: true}));
app.use('/', serverStatic(path.join(__dirname, '../public'), {
maxAge: '1d'
}));
await app.listen(80);
}
bootstrap();
数据库
以 MongoDB 为例,我们数据存储在 test 这个数据库里。
安装依赖
npm i @nestjs/mongoose mongoose
建立数据库连接
在根模块里(app.module.ts):
// app.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
// ...
MongooseModule.forRoot('mongodb://xxx:27017/test')
]
})
export class AppModule {}
创建数据表 schema 和文档接口
下面指定了 People 这个数据表的字段和类型:
// peopleSchema.ts
import * as mongoose from 'mongoose';
export const peopleSchema = new mongoose.Schema({
name: String,
address: String,
telephone: String
// ...
});
// peopleDocument.ts
import { Document } from 'mongoose';
export interface PeopleDocument extent Document {
name: string,
address: string,
telephone: string
// ...
};
在某个业务模块里使用表
// testModule.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { people } from './peopleSchema.ts';
@Module({
imports: [
MongooseModule.forFeature([
{
name: 'People', // 表名
schema: peopleSchema
}
])
]
})
export class BcodeModule {}
业务中注入模型
// testService.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { peopleDocument } from './peopleDocument.ts';
@Injectable()
export class testService {
constructor (
@InjectModel('People') private readonly peopleModel: Model<peopleDocument>
) {}
// 就可以在业务中操作数据表了
async findAll () {
return await this.peopleModel.find({});
}
}
官方文档有很多非常详细的[技术介绍](Database | NestJS - A progressive Node.js framework)
上述基本上简单但是又较全面的涵盖了 Nest.js 的内容,了解这些已经可以开始使用它构建一般性应用了。经过上面的走马观花,对 Nest.js 高度解耦的特点有了很直观的感受,除此之外,良好的开发体验还来自于非常完善的类型提示所带来的 TypeScript 编码舒适度;cli 快速创建控制器、模型等文件。不过也不得不承认,相比于其他 Node.js 框架,Nest.js 比较重,于是有一种论调是:既然都选择了 Nest.js,为何不干脆上 Java?很显然,作为前端开发工程师,选择上自然会更倾向于 JavaScript。
如果你感觉 Nest.js 有让你使用的欲望,那不妨开始构建你的第一个 Nest.js 应用。