- 背景介绍
由于我们要新建一个中台 BFF 层,所以在研究企业级微服务框架,找到几个名字很相近的 node.js 企业级框架,Nuxtjs, Nextjs, Nestjs,前两个专注于服务端渲染 ssr,和 react 配合的相对比较好,后者对于微服务,Typescript,OOP等支持的较好,最后我们选择了 Nestjs。
Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素。底层默认使用 Express(可以通过 adapter 转换到 fastify),可以使用 Express 或者 Fastify 所有中间件,完美支持 TypeScript, 与数据库关系映射 typeorm 配合使用可以快速的编写一个接口网关。
熟悉 Spring 和 Angular 的同学可以很快上手 nestjs,它大量借鉴了 Spring 和 Angular 中的设计思想。本文会介绍一下作为一款 Node.js 企业级的框架的特点,以及它的优缺点。
2. 从依赖注入(DI)谈起
2.1 什么是依赖注入(DI)
DI - Dependency Injection,即"依赖注入":组件之间的依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。
首先我们用一个例子来通俗的讲讲什么是依赖注入:
class Cat{ } class Tiger{ } class Zoo{ constructor(){ this.tiger = new Tiger(); this.cat = new Cat(); } }
上述的例子中,我们定义 Zoo,在其 constructor 构造函数的方法中进行对于 Cat 和 Tiger 的实例化,此时如果我们要为 Zoo 增加一个实例变量,比如去修改 Zoo 类本身,比如我们现在想为 Zoo 类增加一个 Fish 类的实例变量:
class Fish{} class Zoo{ constructor(){ this.tiger = new Tiger(); this.cat = new Cat(); this.fish = new Fish(); } }
此外如果我们要修改在 Zoo 中实例化时,传入 Tiger 和 Cat 类的变量,也必须在 Zoo 类上修改。这种反反复复的修改会使得 Zoo 类并没有通用性,使得 Zoo 类的功能需要反复测试。
我们设想将实例化的过程以参数的形式传递给 Zoo 类:
class Zoo{ constructor(options){ this.options = options; } } const zoo = new Zoo({ tiger: new Tiger(), cat: new Cat(), fish: new Fish() })
我们将实例化的过程放入参数中,传入给 Zoo 的构造函数,这样我们就不用在 Zoo 类中反复的去修改代码。
下面是一个简单的介绍依赖注入的例子,可以更为完全使用依赖注入的可以为 Zoo 类增加静态方法和静态属性:
class Zoo{ static animals = []; constructor(options){ this.options = options; this.init(); } init(){ let _this = this; animals.forEach(function(item){ item.call(_this, options); }) } static use(module){ animals.push([...module]) } } Zoo.use[Cat, Tiger, Fish]; const zoo = new Zoo(options);
上述我们用 Zoo 的静态方法 use 往 Zoo 类中注入 Cat、Tiger、Fish 模块,将 Zoo 的具体实现移交给了 Cat 和 Tiger 和 Fish 模块,以及构造函数中传入的 options 参数。
2.2 Angular 中的依赖注入
从 angular1.x 开始,实现了依赖注入或者说控制反转的模式,angular1.x 中就有 controller(控制器)、service(服务)、module(模块)。结合我们当前前端项目中使用到的 angular.js 1.8.9 举例来说明:
const dashboardModule = angular.module('dashboardModule',['ui.router']); dashboardModule.controller('sectionOne', function($scope, $timeout){}) dashboardModule.controller('sectionTwo', function($scope, $state){})
上面这个就是 angular1.x 中的一个依赖注入的例子,首先定义了模块名为“dashboardModule”的 module, 接着在 dashboardModule 这个模块中定义 controller 控制器。将 dashboardModule 模块的控制权交给了 dashboardModule.controller 函数。具体的依赖注入的流程图如下所示:
dashboardModule 这个模块如何定义,由于它的两个控制器决定,此外在控制器中又依赖于 scope、timeout 等服务。这样就实现了依赖注入(DI),或者说控制反转(IoC)。
2.3 Nestjs 中的依赖注入
在 nestjs 中也参考了 angular 中的依赖注入的思想,也是用 module、controller 和 service。
// 模块注入 @Module({ imports: [ UserModule ], providers: [ MainService ], controllers: [ MainController, MainExtroController ] }) export class MainModule {} // 通过类的构造函数注入 @Injectable() export class CategoryService { constructor(private readonly productService: ProductService) { } } // 通过类的属性注入 @Injectable() export class CategoryService { // 如果资源的注入令牌不是class类型的,则需要显式的使用 @Inject 装饰器来指定 @Inject('myProductService') private readonly productService: ProductService; }
- 装饰器和注解
在 nestjs 中,完美的拥抱了 typescript, 特别是大量的使用装饰器和注解。我们来看使用了装饰器和注解后,在 nestjs 中编写业务代码有多么的简洁:
import { Controller, Get, Req, Res } from '@nestjs/common'; @Controller('cats') export class CatsController { @Get() findAll(@Req() req,@Res() res) { return 'This action returns all cats'; } }
上述定义两个一个处理路由前缀为 "cats" 的控制器,对于这个路由的 Get 方法,定义了 findAll 函数。当以 get 方法,请求/cats 的时候,就会主动的触发 findAll 函数。
此外在 findAll 函数中,通过 req 和 res 参数,在这个装饰器内可以直接去使用请求 request 以及对于请求的响应 response。比如我们通过 req 来获取请求的参数,以及通过 res.status().json() 来处理返回请求结果。
- Nestjs 的“洋葱模型”
这里简单讲讲在 nestjs 中 是如何分层的,也就是说请求到达服务端后如何层层处理,直到响应请求并将结果返回客户端。
以上介绍的各种Nest
组件被注册之后由Nest
框架管理,它们在一个请求的某个声明周期阶段被执行。具体执行顺序如下:
- 请求输入
- 全局
middleware
- 根模块(
root module
)middleware
- 全局
Guards
Controller Guards
route
对应处理函数上使用@UseGuards()
注册的Guard
- 全局
interceptors
(controller 前置处理) controller
的interceptors
(controller 前置处理)route
对应处理函数上使用@UseInterceptors()
注册的interceptors
(controller 前置处理)- 全局
pipes
controller
的pipes
route
对应处理函数上使用@UsePipes()
注册的Pipes
route
对应处理函数参数注册的Pipes
(如:@Body(new ValidationPipe())route
对应处理函数route
对应处理函数依赖的Serivce
route
对应处理函数上使用@UseInterceptors()
注册的interceptors
(controller 后置处理)controller
的interceptors
(controller 后置处理)- 全局
interceptors
(controller 后置处理) route
对应处理函数注册的Exception filters
controller
注册的Exception filters
- 全局注册的
Exception filters
- 响应输出
上图中的逻辑就是分层处理的过程,经过分层的处理请求才能到达服务端处理函数,下面我们来介绍 nestjs 中的层层模型的具体作用。
3.1. Middleware 中间件
在 nestjs 中的 middleware 完全跟 express 的中间件一摸一样。不仅如此,我们还可以直接使用 express 中的中间件,比如在我的应用中需要处理 core 跨域:
import * as cors from 'cors'; async function bootstrap() { onst app = await NestFactory.create({}); app.use(cors({ origin:'http://localhost:8888', credentials:true })); await app.listen(8888); } bootstrap();
在上述的代码中我们可以直接通过 app.use 来使用 cors 这个 express 中的中间件。
除此之外,nestjs 的中间件也完全保留了 express 中的中间件的特点:
- 在中间件中可以接受 response 和 request 作为参数,并且可以修改请求对象 request 的结果。
- 可以结束对于请求的处理,直接将请求的结果返回,也就是说可以在中间件中直接 res.send 等。
- 在该中间件处理完毕后,如果没有将请求结果返回,那么可以通过 next 方法,将中间件传递给下一个中间件处理。
在 nestjs 中,中间件跟 express 中完全一样,除了可以复用 express 中间件外,在 nestjs 中针对某一个特定的路由来使用中间件也十分的方便:
class ApplicationModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply(LoggerMiddleware) .forRoutes('cats'); } }
上面就是对于特定的路由 url 为/cats 的时候,使用 LoggerMiddleware 中间件。
3.2. Exception filters 异常过滤器
Exception filters 异常过滤器可以捕获在后端接受处理任何阶段所跑出的异常,捕获到异常后,然后返回处理过的异常结果给客户端(比如返回错误码,错误提示信息等等)。
我们可以自定义一个异常过滤器,并且在这个异常过滤器中可以指定需要捕获哪些异常,并且对于这些异常应该返回什么结果等,举例一个自定义过滤器用于捕获 HttpException 异常的例子。
@Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception.getStatus(); response .status(status) .json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, }); } }
我们可以看到 host 是实现了 ArgumentsHost 接口的,在 host 中可以获取运行环境中的信息,如果在 http 请求中那么可以获取 request 和 response,如果在 socket 中也可以获取 client 和 data 信息。
同样的,对于异常过滤器,我们可以指定在某一个模块中使用,或者指定其在 APP 内使用,以及全局使用等等方式。
3.3. Pipes 管道
Pipes 一般用户验证请求中参数是否符合要求,起到一个校验参数的功能。
比如我们对于一个请求中的某些参数,需要校验或者转化参数的类型:
@Injectable() export class ParseIntPipe implements PipeTransform<string, number> { transform(value: string, metadata: ArgumentMetadata): number { const val = parseInt(value, 10); if (isNaN(val)) { throw new BadRequestException('Validation failed'); } return val; } }
上述的 ParseIntPipe 就可以把参数转化成十进制的整型数字。我们可以这样使用:
@Get(':id') @UsePipes(ParseIntPipe) // 方式 1 @UsePipes(new ParseIntPipe()) // 方式 2 async findOne(@Param('id', new ParseIntPipe()), id) { // 方式 3 return await this.catsService.findOne(id); }
如方式三所示:对于 get 请求中的参数 id,调用 new ParseIntPipe 方法来将 id 参数转化成十进制的整数,如果参数不合法,则抛出错误。
3.4. Guards 守卫
Guards 守卫,其作用就是决定一个请求是否应该被处理函数接受并处理,当然我们也可以在 middleware 中间件中来做请求的接受与否的处理,与 middleware 相比,Guards 可以获得更加详细的关于请求的执行上下文信息。
通常 Guards 守卫层,位于 middleware 之后,请求正式被处理函数处理之前。
下面是一个 Guards 的例子:
@Injectable() export class AuthGuard implements CanActivate { canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const request = context.switchToHttp().getRequest(); return validateRequest(request); } }
这里的 context 实现了一个 ExecutionContext 接口,该接口中具有丰富的执行上下文信息。
export interface ArgumentsHost { getArgs<T extends Array<any> = any[]>(): T; getArgByIndex<T = any>(index: number): T; switchToRpc(): RpcArgumentsHost; switchToHttp(): HttpArgumentsHost; switchToWs(): WsArgumentsHost; } export interface ExecutionContext extends ArgumentsHost { getClass<T = any>(): Type<T>; getHandler(): Function; }
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Observable } from 'rxjs'; import { UnauthorizedException } from '../exception/unauthorized.exception'; @Injectable() export class AuthGuard implements CanActivate { canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const request = context.switchToHttp().getRequest(); return this.validateRequest(request); } validateRequest(req) { if (!req.as || !req.as.login_info) { throw new UnauthorizedException('UNAUTHORIZE'); } return true; } }
除了 ArgumentsHost 中的信息外,ExecutionContext 还包含了 getClass 用户获取对于某一个路由处理的,控制器。而 getClass 用于获取返回对于指定路由后台处理时的处理函数。
对于 Guards 处理函数,如果返回 true,那么请求会被正常的处理,如果返回 false 那么请求会抛出异常。
3.5. Interceptors 拦截器
拦截器可以给每一个需要执行的函数绑定,拦截器将在该函数执行前或者执行后运行。可以转换函数执行后返回的结果等。
概括来说:
interceptors 拦截器在函数执行前或者执行后可以运行,如果在执行后运行,可以拦截函数执行的返回结果,修改参数等。
再来举一个超时处理的例子:
@Injectable() export class TimeoutInterceptor implements NestInterceptor{ intercept( context:ExecutionContext, call$:Observable<any> ):Observable<any>{ return call$.pipe(timeout(5000)); } }
该拦截器可以定义在控制器上,可以处理超时请求。
5. 总结
最后总结一下 nestjs 的优缺点:
5.1 优点
- 完美的支持 typescript, 因此可以使用日益繁荣的 ts 生态工具;
- 可以说是真正意义上的实现了请求作用域;
- 兼容 express 中间件,因为 express 是最早出现的轻量级的 Node.js 服务端框架,nestjs 能够利用所有 express 的中间件,使其生态完善;
- 层层处理,一定程度上可以约束代码,比如何时使用中间件、何时需要使用 guards 守卫等;
- 依赖注入以及模块化的思想,提供了完整的 mvc 的链路,使得代码结构清晰,便于维护,这里的 m 是数据层可以通过 modules 的形式注入,比如通过 typeorm 的 entity 就可以在模块中注入 modules;
- 完美支持 rxjs;
5.2 缺点
- 对于不熟悉 angular 和 spring boot 的人,有一定的上手成本,因为需要掌握 OOP、FP、IoC、Decorator 等等思想;
- 参考