Open peng-yin opened 1 year ago
市面上 NodeJS 的服务端框架有很多,如Koa、Express、EggJS、Midway等,它们功能都很强大,也有很好的生态,插件非常丰富,为什么还需要Nest呢?
如果是一个简单的应用,其实用什么框架都无所谓,一个框架用 100 行代码实现,另一个用 80 行,区别不大。但涉及到企业级的应用,分分钟有上万行的代码,代码的组织结构就变得很重要了。如果代码拆分不合理,一个 JS 文件就有上千行的代码,后期的维护成本会非常的高。再考虑到复杂项目参与者众多,没有一个规范去约束的话,每个人写出来的代码风格迥异,协作起来会很难受。上文提到的几个框架对项目代码的架构要么是没约束,要么就是约束比较弱或者看起来很别扭。相比之下Nest的实现就很简洁,用起来很顺手。具体细节将在下文进行描述。
Nest还通过依赖注入的形式实现了控制反转,只要声明模块中的依赖,Nest就会在启动的时候去创建依赖,然后自动注入到相应的地方。依赖注入最大的作用是代码解耦,依赖的对象根据不同的情况可以有多种实现,如单元测试的时候可以在不改业务代码的情况下将依赖的对象换成 Mock 数据。
Nest还践行了面向切面编程的思想,除了Middleware外,还有Exception Filter、Pipes、Guards和Interceptors几个预定义的切面,可以集中进行异常处理、数据验证、权限验证和逻辑扩展等功能。Nest自带如数据验证等一些常用的基于切面的功能,也可以通过继承的方式来进行扩展。这些预定义的切面是代码架构的组成部分,按照这些约定来组织代码会大大降低日后的维护成本。
类型系统是后端开发很重要的一环,Nest是使用TypeScript实现的框架,因此原生就支持TypeScript,而且还大量使用了注解,熟悉 Spring 的朋友会感到十分亲切。
另外,Nest是基于Express实现的,需要的话可以取到底层的对象,如request和response
对于接触新的技术,一上来撸文档或看视频,是一种低效并且不持久的学习方式,零碎的知识点就像是每一个神经元,相互没有连接成网络最终会形成一盘散沙,大脑并不擅长处理这种结构。而要多方面去渗透理解,如发展历史,着重解决哪些问题,相比其他类似技术,优势在哪?从这些角度入手,容易形成自己的知识框架。例如,在node生态中,express解决了什么问题?之后为什么又诞生孪生兄弟koa,两者有什么区别?后起之秀nest凭借什么能够脱颖而出,成为目前最流行企业级框架之一?按照这个逻辑链,梳理形成属于自己的知识体系。
egg已经不太满足的开发效率和开发模式,主要有以下几点:
对typescript支持度不够,这是由于egg.js本身就不是typescript开发 egg.js封装web架构,约定大于编码,如:强制将web应用分级为: controller、service、middleware、extend等,自由度相对比较弱,当你需要定制化开发内容,你需要深入了解egg.js的整个运行原理才能实现 虽然部门内部定制化开发 @Controller @Service等注解,减少路由配置,但是这一块插件还存在一些隐藏规则,需要开发注意 当然egg.js运行的web应用还是比较稳定,而且相关插件生态也比较丰富,只是当egg.js迭代更新速度在2020年后就逐步放缓,更不上变化,我们就需要迎接一些新的框架来满足要求。
框架对比
我从近两年听到或者网上收集的,基于Node.js的框架主要有以下几个:
对比一下,我们主要用来开发后端api接口,不需要SSR,不需要过于重或过于轻量的框架,因此最后挑选了nest.js。
nest.js
术语介绍:
class Dog{ say(return 'one one!')}
say('one one!')
a = 5; b=6; c=a+b;
当a
或b
变化的时候,c
会随之变化str='one one~'; say(str)
, 当str
变化,会自动触发say
基础概念
Nest.js的核心是基于IoC控制反转 + DI 依赖注入 去实现类的声明和实例化的。如果你了解过Spring Boot其实很容易上手nest.js。
Module
Module 其实是nest.js用来将一个web应用拆分成各个子模块的分类规定,web应用根模块一般叫
app.module.ts
,官方设计图如下:Module应该由以下几个部分组成:
Controller
Controller就一个作用,分割路由,调用处理方法,返回http请求结果。
支持写法:
@Controller('test')
@Get()
@Post()
@Put()
@Del()
代表各种请求方法(http Method)@Session()
@Body(key?: string)
@Param(key?: string)
Provider
Provider其实就是不仅仅是Service层,还包括:Sql的Dao层、工具方法等提供。它和其他层关系如下图:
写法:
@Injectable()
声明该类是一个Provider,允许其他类实现依赖注入@Optional()
允许构造不传@Inject()
自动依赖注入Middleware
Middleware中间件,其实和egg.js的中间件概念一样,就是当http请求来了之后,被中间件处理一遍之后才会到对应的Controller层。
写法:
implements NestMiddleware
,必须实现NestMiddleware
接口,以及内部方法use(req: Request, res: Response, next: NextFunction)
,同时内部方法必须调用next
Module
层注册中间件,这里需要可以设置MiddlewareConsumer
,中间件消费者工具类,主要把中间件加上一些配置项功能,如:forRoutes
支持路由匹配,exclude
不包含路由ExceptionFilter
Filter过滤器,这个应该是所有web框架都具备的功能,拦截用户请求和web返回数据。在Nest.js中,只实现ExeptionFilter,你也可以基于这个去自定义自己的异常过滤器,具体如下图:
写法:
@Catch(HttpException) class HttpExceptionFilter implements ExceptionFilter
实现自定义异常过滤器@UseFilters(new HttpExceptionFilter())
能给具体接口包裹上一层自定义的异常过滤器Pipe
Pipe 管道流,是指的Http请求里的内容数据流,它支持数据验证、数据转换等功能,有点类似Filter的功能。
写法:
@Param('id', ParseIntPipe) id: number
,将参数id转换为number类型class ValidationPipe implements PipeTransform
自定义 Pipe,同时必须实现方法transform(value: any, metadata: ArgumentMetadata)
@UsePipes(new Pipe())
,支持在controller配置自定义的PipeGuard
Guard 守卫,也是处Http请求中的一层特殊中间件,但是与中间件不同的时候,中间件不知道next()是去哪个执行代码,而Guard则可以获取
ExecutionContext
实例,可以获知整个请求的生命周期和内置内容,通常用来接口登录和权限控制。写法:
class AuthGuard implements CanActivate
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>
@UseGuards
Controller层使用,app.useGlobalGuards(new RolesGuard());
全局注册Interceptor
Interceptor是面向切面编程理念影响的概念,它允许你在方法执行前后扩展原有函数功能,如:改变返回结果,扩展基本功能等,常用的场景:添加常规日志。
写法:
class LoggingInterceptor implements NestInterceptor
,自定义实现intercept(context: ExecutionContext, next: CallHandler): Observable<any>
方法实现,同时需要返回对应结果next()
@UseInterceptors(LoggingInterceptor)
可以在类或方法前进行注册,app.useGlobalInterceptors(new LoggingInterceptor());
全局注册其他
createParamDecorator
,可以从request对象中抽取固定的参数。applyDecorators
可以将多个装饰器 方法合在一起验证,然后形成一个新的注装饰器PS: 装饰器是什么?
生命周期
Nest.js的生命周期分为三个阶段:初始化、运行和终止,下图详细生命周期的各个子阶段:
允许监听的生命周期函数:
onModuleInit()
, 模块初始化时候调用onApplicationBootstrap()
,所有模块都准备好了,但是在web应用正式启用前会被调用onModuleDestroy()
,模块准备被停止beforeApplicationShutdown()
, web应用准备被停止之前onApplicationShutdown()
,web应用被停止之后,在进程退出之前上手实战
第一步安装:
生成项目结构
运行
打开 http://localhost:3000 就可以访问了。
前置知识
在了解实现原理之前有几个知识概念,需要了解一下:
IoC和DI
IoC和DI其实同属于一个技术理念,下面维基百科的介绍:
简单的说IoC是一个开发代码的设计原则,DI则是实现这个设计原则的方案。
IoC
从代码层上来讲解IoC,简单的说就是:
再通俗一点,就是有一个IoC容器管家,负责你开发的代码类的归置,你只管使用代码类,不用管它放在哪里,只需要调用即可。
DI
DI,Dependency Injection,依赖注入
简单的说,就是依赖注入是将需要注入的对象完全交给框架去实现,而依赖查找则是开发者通过框架提供的方法,由自己控制需要注入的时间点。
问题
采用IoC和DI,需要注意的问题是:
JavaScript的Reflect
Reflect在MDN网站是这么解释的:
按照前端开发者理解来说,Reflect能解决开发中遇到很多this的代理问题,虽然大部分方案都可以通过其他方式解决,但是Reflect的定义能帮助我们快速实现这些功能。
Reflect符合ES6标准的提供的API有如下几个:
Reflect.apply(target, thisArgument, argumentsList)
,和Function.prototype.apply(thisArgument, argumentsList)
功能类似,也是调用函数,且允许将函数的this指向thisArgument
Reflect.construct(target, argumentsList[, newTarget])
,new一个target,且可以将target的this的指向新的newTarget对象Reflect.defineProperty(target, propertyKey, attributes)
,拦截target对象的操作,和Object.defineProperty()
类似Reflect.deleteProperty(target, propertyKey)
,作为函数的delete操作符,相当于执行 delete target[name]。Reflect.get(target, propertyKey[, receiver])
,获取target的属性值,和target[name]
的区别在于可以receiver
,可以指定调用属性值的时候this
Reflect.getOwnPropertyDescriptor(target, propertyKey)
,类似于 Object.getOwnPropertyDescriptor()。如果对象中存在该属性,则返回对应的属性描述符,否则返回 undefined。Reflect.getPrototypeOf(target)
,返回指定对象的原型(即内部的 [[Prototype]] 属性的值)Reflect.has(target, propertyKey)
,判断一个对象是否存在某个属性,和in 运算符
的功能完全相同Reflect.ownKeys(target)
,返回一个包含所有自身属性(不包含继承属性)的数组。(类似于 Object.keys(), 但不会受enumerable 影响).Reflect.isExtensible(target)
, 判断一个对象是否可扩展(即是否能够添加新的属性)Reflect.preventExtensions(target)
,阻止新属性添加到对象Reflect.set(target, propertyKey, value[, receiver])
,将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true。Reflect.setPrototypeOf(target, prototype)
,可设置对象的原型,即内部的 [[Prototype]] 属性)为另一个对象或 null,利用原型链用来强制给某个对象增加额外方法当然还有一些没有进入标准,但是在ES7提案的方法
Reflect Metadata
(Typescript已实现),后面Nest.js已采用的方法,主要有以下几个:Reflect.getMetadata(metadataKey, target, propertyKey)
, 用于获取某个类的元数据Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);
, 用于设置某个类的元数据简单理解这个api方法,你可以通过
Reflect.defineMetadata
获取到类或者函数的参数类型,也可以给类或者函数设置元数据再获取,具体代码如下:TypeScript 的优势了,TypeScript 支持编译时自动添加一些 metadata 数据,如下所示:
Reflect.getMetadata("design:type", target, key)
, 获取target函数类型Reflect.getMetadata("design:paramtypes", target, key)
, 获取target函数参数类型Reflect.getMetadata("design:returntype", target, key)
, 获取target函数返回值类型这个
Reflect.getMetadata("design:paramtypes", target, key)
基本上就是Nest.js实现Ioc和DI的核心代码。TypeScript的装饰器
如何实现一个装饰器呢?
如果我们要定制一个修饰器如何应用到一个声明上,我们得写一个装饰器工厂函数。 装饰器工厂就是一个简单的函数,它返回一个表达式,以供装饰器在运行时调用。
IoC和DI实现原理
其实在了解完
Reflect.getMetadata
,我们就大概知道IoC和DI的实现原理,我们以一个@Controller
为例, 具体步骤如下:@Controller
装饰器工厂,标识待注入的类具体代码如下:
所以Nest.js实现IoC和DI的核心实现原理:
分层
nestJS经常被调侃为srpingJS,所以这里参考java项目的阿里分层规范,其架构图如下:
对第三方平台封装的层,预处理返回结果及转化异常信息; 对Service层通用能力的下沉,如缓存方案、中间件通用处理; 与DAO层交互,对多个DAO的组合复用。
不同的业务场景,不同的应用大小,程序复杂度高低,可以灵活的增删上述某些结构。无论是nest还是egg,官方demo里都没有明确提到dao层,直接在service层操作数据库了。这对于简单的业务逻辑没问题,如果业务逻辑变得复杂,service层的维护将会变得非常困难。业务一开始一般都很简单,它一定会向着复杂的方向演化,如果从长远考虑,一开始就应该保留dao层,在nestJS中并未查看到相关规定,可根据开发者场景自行考虑。如下是nestJS的分层架构图:
对于Web层:在nestJS中,如果使用restful风格,就是controller;如果使用graphql规范,就是resolver...对于同一个业务逻辑,我们可以使用不同的接口方式暴露出去。 经常被问到和提起的问题就是为什么需要有service层:
首先service作用就是在里面编写业务逻辑代码,一般来说,都是为了增加代码复用率,实现高内聚,低耦合等... 体现在这里的好处就是上述提到的同一段业务代码可以使用不同的接口方式暴露出去,或者可以在一个service内调用其他service,而非在一个接口函数里面调用另外一个内部接口,这是极其不优雅的。 当然,老生常谈的就是不同功能目的的代码分开写方便维护管理等等