前言
在本系列文章的第三篇,因为在介绍启动过程的时候,我是没有详细的去分析NestJS是怎么解析依赖的,并且没有对它里面的几个核心类的调度关系,每个核心类的职责进行详细的阐述。
之前本着由简入繁的原则,先让大家知道一些基础的知识便于学习。但是在之前的的文章中有些地方解释的比较模糊,我觉得还不够,因此,在本文将会详细的分析NestJS的启动流程。
本文的内容相当的长,如果你想作为面试八股文学习的话,可以直接看本文最后一节的总结,但是如果你真的想完全搞懂NestJS的话,希望你可以耐心的看完,加油,各位勤劳的打工人,😁。
我是一个实用主义者,为的是向大家传递有用的知识,就不跟大家玩标题党了,如果你觉得我的文章写的好,能够解答你的疑惑,欢迎各位读者点赞评论加关注。
前置知识铺垫
在阅读本文之前,你需要有一些算法方面的基础知识,比如得知道什么是栈(并且对JS的执行栈要有一定的认识),什么是DFS(深度优先搜索),什么是拓扑排序,如果你之前完全没有了解过这方面的知识的话,可以参考我之前的文章。
栈:实力加自信就是一把坚韧不摧的利剑------栈与栈的应用
深度优先搜索:理解深度优先思想和广度优先思想以及一些实际应用
拓扑排序:拓扑排序在前端开发中的应用场景
为什么需要这些前置知识点呢,因为这是NestJS在做依赖分析和依赖注入的核心。
我们先脑补一下,如果我们自己手动使用new来初始化某个类的话,假设类A依赖B,类B依赖C,类C谁都不依赖,那么我们肯定是要先初始化C的实例,然后再初始化B的实例,最后才能初始化A的实例,这样的一个过程其实就是拓扑排序,只不过你可能事先并没有了解过这个概念而已。而刚才,我们从A依赖分析至B,再依赖分析至C,这就是一个深度优先搜索的过程。
NestJS的IoC
容器肯定也是无法逃离这个定式的,否则那就要推翻JS的语法规则了,接下来就进入我们今天的正题吧。
核心类的介绍
以下类的列表是我通过分析NestJS的源码得到部分关键类及其每个类负责的核心逻辑总结。
NestFactoryStatic
,NestJS暴露给用户,用于启动HttpServer或者独立应用的工具类,在源码的packages/core/nest-factory.ts中。NestApplication
,NestJS应用程序的核心类,其承载了IoC
容器和更底层的HttpServer,负责管理整个应用程序,在源码的packages/core/nest-application.ts中DependenciesScanner
,NestJS负责扫描装饰器挂载的元数据的核心类,主要的作用就是分析用户编写的代码的职责(比如是Module,是Provider,是Controller还是Middleware这种),在源码的packages/core/scanner.ts中NestContainer
,NestJS的IoC
容器核心,它的主要作用是管理Module的容器(这是NestJS的Module,不是用户编写的被@Module装饰的那个class),在源码的packages/core/injector/container.ts中Module
,NestJS用于管理模块的核心类,用NestJS官方文档的话来说,它是一个Host,是一个宿主,用于管理imports,controllers,providers,exports的内容,在源码的packages/core/injector/module.ts中Injector
,NestJS完成依赖注入的核心类,其主要职责是完成依赖的分析,和InstanceLoader配合完成类的实例化,依赖内容的注入,在源码的packages/core/injector/injector.ts中InstanceWrapper
,NestJS用于存储依赖关系的核心类,在源码的packages/core/injector/instance-wrapper.ts中InstanceLoader
,NestJS中负责实例的装载器,和Injector相互配合,完成目标类的实例化,在源码的packages/core/injector/instance-loader.ts中
其余类,不会影响我们主线流程的分析,如果您也对其感兴趣的话,可以参考我之前的文章。
为了简化叙述,我把Provider,import导入的Module,Controller和export导出的内容统称为控件,后文会经常出现这个词,大家先有一个印象。
运行流程
启动流程
即我们在代码中使用工厂方法启动主模块:
ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
当我们调用create方法时,NestJS开始初始化。
扫描元数据的过程
NestJS事先做了一个准备工作,它需要把自己的一些核心类初始化,所以它得先做把这个核心Module加入IoC
容器的操作。Module,之前我们在介绍的时候说过的,它负责管理的是那些控件的一个集合。 这个内部的核心模块提供了NestJS的基础能力,以下是这个核心类提供的一些控件。 然后这个核心类的Module被Container管理起来了。
接着开始分析主模块,比如我们正常使用NestJS脚手架创建的项目就是AppModule。 然后在scanForModules这个方法里,开始分析这个模块导入的外部模块,第一步先把自己加到IoC
容器里面。 然后,我们一步一步的把目光切到NestContainer那个类中,看看这个过程发生了什么。 Module的构造函数里面有一些关键的逻辑。 我们在官网上看到的这种例子,ModulRef就是在这个位置加入的。
ts
@Injectable()
export class CatsService {
constructor(private moduleRef: ModuleRef) {}
}
同时,我们还能看到,调用了一个addModuleRef方法,这就是上述代码中ModuleRef被作为Provider加入的。 另外,因为拦截器,过滤器,守卫,管道有提供API放在全局配置类中,NestJS把ApplicationConfig类也作为Provider加进去了,方便后期的使用,这就意味着,我们编写代码里面想拿这些配置,也能够通过依赖注入得到的。 刚才的这个scanForModules方法是一个递归调用的方法,如果一个引入的模块又引入别的模块,那么将会一直向上分析,直到模块没有导入外部模块为止,这就是前文我们提到的深度优先遍历 。 不过,反过来,这也会导致外部导入的Module会先于当前Module创建 ,这确实也符合实际的情况。 这个位置,我就不详细为大家解读导入外部模块的流程了,因为很简单,NestJS扫描到这些模块然后把它们都加入到IoC
容器里面去。 在此刻,NestJS已经把Module都创建好了(我们编写的那个@Module装饰器装饰的类还没有创建,别搞混淆了),暂时还没有创建这个Module要管理的控件。 导入模块处理完成之后,接下来就要开始分析模块的依赖,分析依赖即要找到这个模块所依赖的所有控件。 然后,把Provider跟Module绑定起来,将来Provider在实例化的时候,才能知道对应的Module归属。 这儿,我们仅分析Provider。Controller类似,就不考虑了,读者可以自行查阅NestJS的源码,到后文我们再回过头看一下Import和Export的内容是怎么处理的。
到此,已经把元数据分析完成了,所有的Module已经创建,所有的Module里面依赖的类也已经明确知道,这些类等着实例化,真的是万事俱备只欠东风了。接下来,我们就可以继续看它是怎么做依赖分析和依赖注入的了。
依赖分析的过程
在上一节中,我们已经知道NestJS已经把装饰器挂载的元数据扫描完成了,接着开始做初始化Provider实例的工作。 在InstanceLoader的createInstances方法开始初始化之前扫描到的控件,我们还是拿最有代表性的Provider来分析即可。 然后调用了Injector的loadProvider方法,这个方法,大家要知道一个小的Tips,这就跟Vue的双向绑定里面的递归初始化data函数返回的数据增加getter和setter是一样的原理,它是几个函数形成的递归调用,不仔细的话,不容易看出来 。 刚才的callback被我折起来了,一会儿再看。
然后,在resolveContructorParams这个函数内部,可以看到几个关键信息,第一个是解依赖 然后是逐个处理Provider依赖的参数,首先定义一个resolveParam回调函数 然后依次调用resolveParam回调函数处理每个依赖的参数。 然后这里面关键的方法是resolveSingParam,我们转到它的定义。 在这个方法里面,继续调用resolveComponentInstance方法,我们再次转到它的定义,到这个位置,已经到了这个日志的输出位置了。 然后,看lookupComponent方法里面做了什么,我们继续转到它的定义。 在这个lookupComponent方法里面,打出来了这个日志。 然后把元数据加入到InstanceWrapper里面去记录下来。 如果这儿找不到的话,就到外部导入的模块找依赖的内容。 看一下NestJS是怎么记录依赖关系的,因为我们正常写构造器依赖的类型肯定不Symbol或者String,是对应的类型,所以就在这个位置区分的是构造器注入还是属性注入。 下图是一个构造器注入的一个例子的编译结果。 然后就是建立一来关系。 到这个位置,依赖关系的分析基本上就已经知道个大概了,但是前文我们说过,它是几个函数形成的递归调用,我们得继续看看,这个递归调用是怎么形成的。
回到resolveComponentInstance这个方法 在resolveComponentHost方法内又再次调用了loadProvider方法。 调用链即:loadProvider
->loadInstance->resolveConstructorParams->resolveSingleParam->resolveComponentInstance->resolveComponentHost->loadProvider
,递归调用就是这样形成了。
这个位置,我们就已经把依赖分析的过程了解了,NestJS采用的是深度优先遍历的方式,并没有采取拓扑排序的处理方式。
依赖注入的过程
在完成了依赖的分析过程之后,就可以开始准备实例化了。在之前loadInstance的部分,我们刻意的把一个callback方法折起来了,目的就是为在这个时候进行阐述,callback内部完成的就是类的实例化。
这儿,大家一定要对栈的特性了然于胸,栈的特点就是先入后出 ,那么,回到我最开始提到的那个例子,由类A到类B到类C的解析过程,类A最先被解析到,入栈,然后解析到类B,入栈,然后解析到类C,入栈,此刻已经没有依赖了,最先出栈的肯定是类C,所以最先初始化的肯定是类C,然后再是类B,最后是类A。
以下就是之前我们略过的callback方法完成的任务。 各位还记得我们在前文里面说过吧,NestJS在最前面注册了它的核心类,它的这些核心类是没有依赖的,所以这些核心类是最先初始化的。 好了,回过头来,我们看一下instantiateClass这个方法里面做了什么。 紧接着,我们必须重视callback的调用位置: 在调用它之前还在尝试解依赖,但是假设没有依赖,那这个resolveConstructorParam这个方法前面的那么多代码都可以不用看了,这样是不是就相当于递归进行到了尽头了?
在当前JS的调用栈中,某个类没有依赖,那么它就能够直接初始化不用再继续压栈分析依赖了,当前函数就能直接出栈了,并且把生成好的实例返回传递给 依赖刚才创建的实例 的类,然后有依赖的类就又可以进行实例化,函数又可以退栈,那么重复这个过程一直退栈,就能够把所有的类都实例化完成了。
这个位置,为了方便大家理解,我给贴了一个二叉树作递归遍历的时候调用栈的图放在这儿。
到这个时候,构造器方式注入的过程就已经完成了,还有另外的一种初始化方式,属性注入。
再把目光切回之前我们看过的那个callback函数 再让大家喵一眼在前文我们就已经简单提过的Inject装饰器 反解出@Inject挂载的内容 刚才我们折起来的内容,看起来是不是跟构造器注入非常相似啊,哈哈哈,是的,都在查找依赖的对象实例。 最后,再挨个给这些属性赋值就可以啦。 明显可以看到的就是,只要把构造器注入搞明白了,属性注入简直就是手到擒来。
Exports的处理流程
现在再阐述模块对外导出的流程大家理解起来要稍微舒服一些,因为我们已经明白了依赖注入的过程了。
这个处理流程,仍然发生在扫描元数据的过程中,即: 然后把它添加到Module的exports选项之上,这样就可以供外部依赖读取了。 然后,别的模块如果引用当前模块的时候,直接读取这个模块上暴露的exports选项上的内容,就完成对外导出Provider的能力。
依赖注入时Imports的模块处理流程
在scanForModules方法里面,我们就已经聊过了递归处理了外部模块,这些模块在依赖分析和依赖注入的过程中,我们之前是没有阐述向上层模块查找逻辑的,当时只分析了可以在自己的模块内找到的逻辑场景。
现在,我们可以看一下需要查找外部模块处理的业务场景。 这就是我们在Exports小节所提到过的内容,Module的exports属性上管理着对外暴露Provider的内容。 因为外部模块仍然有可能引入模块(就比如后文要阐述的全局模块的处理就是属于导入的外部模块),因此这儿仍然存在一个递归解析外部模块的逻辑。 至此,我们就把外部模块的处理逻辑理清楚了。
全局模块
之前我们提到过,Module是怎么被加入到IoC
容器的,对于全局模块,NestJS将其加入到了一个单独的集合进行管理。 判断是否是全局模块,就是读取的是@Global装饰挂载的元数据 然后,在解析完成元数据的时候,直接对所有的局部模块进行遍历,把全局模块作为当前模块的依赖。 经过这个操作之后,在做依赖解析的时候,就可以直接到模块的imports选项上去找对应的模块,就跟手动引入一个模块是一样的效果了。
因此,从这个源码,我们可以得出一个结论,因为NestJS是在扫描元数据结束进行的全局模块和局部模块绑定,全局模块在任何位置导入都可以,但是一旦导入了,任何模块都会对其形成引用。
关系总结
本节以图形的方式向大家展示一些关键的逻辑。
NestJs的IoC
容器管理着所有的模块,其中有一个是它自己的核心模块,叫做InternalCoreModule
每个模块内部管理着Controllers,Middlewares,Providers,Exports等一系列控件。 NestJS总体架构就是,HttpServer+IoC
容器,这个HttpServer默认是Express
,也可以替换成Fastify
,还也可以换成MicroService
或者GraphQL
(这就强烈的佐证了NestJS官网所提到的平台无关哲学 ),甚至我们还可以自己编写一套底层Http服务的实现类去实现它提供AbstractHttpAdapter也是可以的,而NestJS官方提供的是Express和Fastify对这个类的实现。如果直接把这个HttpServer去掉,就是Standlone App(比如处理定时任务的Cron),相当于我们仅利用IoC
容器的能力去编写程序。
总结
正如齐天大圣的经典名言:"求仙问卜,不如自己做主。念佛诵经,不如本事在身",听别人说千遍万遍,不如自己亲自去实践一遍。
本文是对本系列文章第三篇文章关于NestJS启动流程的补充,本文没有再次赘述关于路由、中间件、过滤器、拦截器等控件的处理流程,如果大家不清楚的可以查看我之前相应的文章。
- 路由:记录我的NestJS探究历程(四)------路由
- 中间件:记录我的NestJS探究历程(五)------中间件
- 过滤器:记录我的NestJS探究历程(六)------过滤器
- 拦截器:记录我的NestJS探究历程(七)------拦截器
- 守卫:记录我的NestJS探究历程(八)------守卫
- 管道:记录我的NestJS探究历程(九)------管道
最后,以下就是我通过学习源码对NestJS启动流程的全流程总结:
NestJS在启动过程中,首先初始化HttpServer,然后初始化IoC
容器,在IoC
容器初始化完成之后,就通过进行元数据的解析(即扫描我们编写的各种装饰器挂载的元数据),得到所有模块的实例,NestJS会首先把自己框架核心的模块放在最前面去创建,并且这个模块是没有任何依赖的。
在扫描某个模块的时候,这个模块可能会依赖外部模块,然后就递归的去解析外部的模块,当没有额外的外部模块依赖的时候,就开始创建Module,创建这个Module之后,把我们编写的那个类作为核心的Provider加入到自己管理的Provider中,接着加入一些需要暴露给开发者的内置Provider,然后再解析用户编写的Priovider和Controller,Exports选项。
解析Module的流程是一个递归的过程,最终结果就是体现栈的特性之先入后出,也就意味着被依赖的外部模块会先于当前模块完成初始化,这就是模块创建的流程。
Provider的解析也是一个递归的过程,首先是在自己的Module里面找,找的到则终止查找,找不到继续向当前模块导入的模块查找,通过读取的模块的exports解析到Provider。另外,全局模块是任何一个外模的外部导入模块。
当模块创建完成之后,就开始准备进行依赖注入了,即初始化模块中的控件。依赖注入的关键是依赖分析,即得事先知道控件所需的参数。NestJS提供两种注入方式,一种是构造器注入,另外一种是属性注入。NestJS通过解析元数据,初始化IntsanceWrapper类来绑定依赖关系。在依赖解析的过程中,也是一个递归的过程,当解析到头了之后,即某个Provider没有依赖之后,NestJS就开始实例化它,并把它保存起来,届时传递给依赖这个实例的类。这也是一个调用栈的先入后出的顺序,被依赖的控件先初始化,然后再初始化依赖被依赖控件的类,直到实例化完成所有的Provider,经过这一过程之后,就完成了依赖注入。
NestJS会根据控件采用的是属性注入还是构造器注入采取相应的赋值策略。如果是构造器注入,在控件初始化的时候直接把它的依赖参数传递给构造函数,如果是属性注入的话,就记录下哪些属性是需要赋值的,待控件被初始化之后,对这些属性挨个进行赋值。在完成了这一步之后,Provider和Controller其实就已经初始化好了,到这儿,IoC
容器的内容就已经准备好了。
紧接着,NestJS就开始初始化中间件Module,调用我们编写的回调函数以应用到对应的路由,并且把解析到的中间件也让所属的Module(我们编写调用consume的那个类的Host)管理起来了。
再然后,NestJS就开始初始化路由相关的内容,通过读取Module内部的Controller信息,扫描其内部的元信息,进行路由解析和路径拼接,使之能够让路由path和控制器的方法形成绑定,能够和中间件进行绑定。
在路由绑定的过程中,NestJS读取我们配置的过滤器,拦截器、守卫、管道等控件,过滤器包裹着拦截器、守卫、管道的执行作用域,如果当有错误产生会立即被捕获,终止后续的流程进入错误处理流程;
同时在路由绑定的过程中也处理了中间件,中间件的处理流程跟拦截器、守卫、管道控件的处理有些许的差异,没有跟它们在统一的流程内,是被单独处理的。不过也是绑定了过滤器,而且是同一份过滤器(这也是为什么过滤器能够拦截所有控件内错误的原因),然后中间件中执行过程中产生的错误也能被过滤器捕获进入异常处理流程。
经过这个工作流程之后,NestJS相当于就已经把控件到Express(默认)的连接道路打通了,就只需要等待用户的请求发出了。
至此,NestJS的Http服务就已经正式启动起来了,当我们发起一个请求,首先经过NestJS的中间件,在中间件处理完成之后,然后进入守卫处理,如果守卫不放行,则终止后续的流程并返回提示内容给前台;若守卫放行,则进入管道处理,开始进行数据的验证和转换,经过管道之后,最后进入到拦截器,在拦截器内,我们可以对请求进行一些操作,然后请求正式进入到控制器的方法进行处理,处理完成之后,拦截器还能对方法的返回内容进行控制(即我们在前文提到的运行时AOP),完成之后,返回给前端,完成这一次请求。
以上就是我总结的NestJS的全套流程了,仅仅是我的个人分析,可能存在错误,若有遗漏,欢迎大家指出完善。