记录我的NestJS探究历程(三)

接上文,在上一节中说到了NestJS在编写注入类的一个不足之后,带大家了解一些其他语言的做法,然后本文将开始从源码阐述NestJS的运行原理。

反射

很多语言都有反射这样的语法特性,我曾经广泛使用过C#的反射,所以就以C#的反射来聊一下不同语言之间的反射语法区别

在上一节中,我们有一段这样的代码:

先是模块的实现部分,

ts 复制代码
import { Module } from '@nestjs/common';
import { TopUpHttpService } from './top-up.http.service';
import { HttpModule } from '@nestjs/axios';
import { TOP_UP_TOKEN } from './top-up.constants';

@Module({
  imports: [HttpModule],
  providers: [
    {
      provide: TOP_UP_TOKEN,
      useClass: TopUpHttpService,
    },
  ],
  exports: [TOP_UP_TOKEN],
})
export class TopUpModule {}

业务侧使用:

ts 复制代码
import { ITopUpService, TOP_UP_TOKEN } from '@/modules/top-up';

@Injectable()
export class PeakTokenService extends BaseService {

  // 此处标记的类型使用的是接口,而不是TopUp的实现类
  @Inject(TOP_UP_TOKEN)
  protected readonly payService: ITopUpService;
}

我们的预期其实就是像在代码部分使用接口进行依赖,然后在某个配置文件中配置指定这个接口的实现类,然后程序就自动加载那个实现类作为被IoC容器管理的类。

在NestJS中,我们其实预期使用ITopUpService接口的类型作为ProvidersKeyTopUpHttpServiceITopUpService的实现类作为类注入,但是因为TS语法的限制是无法做到这样的,在之前TS的元数据编程那节中我们可以看到有元数据注入的得是class搭配装饰器,所以因为这个限制,就只好引入一个Token作为DI的依据了。

但是,在C#的ASP.NET框架中是可以这样用的。所以对于C#这样的语言来说,实现这样的技术叫做反射,它的反射技术更像是一个代码解析器,它将 dll(动态链接库)翻译成class(有点儿像Webpack的loader的那种感觉),然后程序就能加载这个解析到的类,实现完全的依赖倒置(直接把某些项目编译的结果生成到C#的bin目录下供其加载),而JS的反射是一种对对象内部的嗅探的API,这是两个语言反射的本质差异,而JSeval才像是C#的反射。

在程杰老师编写的《大话设计模式》的抽象工厂模式那一节中,程杰老师就向大家展示了C#反射的用法.

首先是UML图: 应用反射技术之后,XXXUser类就比较自由了,根据里氏代换原则只要你事先实现使用相应的数据库技术实现这些类的数据访问即可。 比如Windows,你用SQLServerUser去实现IUser接口,Linux,你用MySQL去实现IUser接口。反正程序运行起来的时候会根据平台通过反射自动加载对应平台的实现类,实现完全的解耦。

代码实现图:

初探Nest的运行原理之装饰器

对于NestJS的装饰器,我们主要是先研究几个比较关键的装饰器梳理它大致的运行思路。因此,我们挑选了以下5个比较关键的装饰器来作为研究突破口:

  • Module
  • Controller
  • Get(其它的Http首部一样的,因此只挑选一个研究就好)
  • Inject
  • Injectable

首先映入眼帘的是Module装饰器,代码竟然出奇的简单,核心的代码如下,大致是校验了一下用户输入的参数类型,然后给Module类的依赖和导出绑定关系。

ts 复制代码
export function Module(metadata: ModuleMetadata): ClassDecorator {
  const propsKeys = Object.keys(metadata);
  // 校验参数的合法性
  validateModuleKeys(propsKeys);
  return (target: Function) => {
    for (const property in metadata) {
      if (metadata.hasOwnProperty(property)) {
        // 依次为Module类绑定依赖的关系
        Reflect.defineMetadata(property, (metadata as any)[property], target);
      }
    }
  };
}

接着是Controller装饰器,它主要的目的是2个,将当前的类标记成Controller,另外一个核心目标是标记当前这个Controller需要处理的路由path,其它的作为源码级别的探索的话,我个人觉得并不是特别重要。

ts 复制代码
export function Controller(
  prefixOrOptions?: string | string[] | ControllerOptions,
): ClassDecorator {
  return (target: object) => {
    Reflect.defineMetadata(CONTROLLER_WATERMARK, true, target);
    Reflect.defineMetadata(PATH_METADATA, path, target);
    // 省略部分代码,只保留了关键代码
  };
}

然后是Get装饰器,它主要目的是为了绑定某个方法需要处理的路由及Http首部。

ts 复制代码
export const RequestMapping = (
  metadata: RequestMappingMetadata = defaultMetadata,
): MethodDecorator => {
  const pathMetadata = metadata[PATH_METADATA];
  const path = pathMetadata && pathMetadata.length ? pathMetadata : '/';
  const requestMethod = metadata[METHOD_METADATA] || RequestMethod.GET;

  return (
    target: object,
    key: string | symbol,
    descriptor: TypedPropertyDescriptor<any>,
  ) => {
    // 标记某个方法需要处理的路由
    Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
    // 标记某个方向需要处理的Http首部
    Reflect.defineMetadata(METHOD_METADATA, requestMethod, descriptor.value);
    return descriptor;
  };
};

接下来是复杂一些的装饰器Inject,因为后续的DI操作全仰仗它记录的依赖关系作为依据,它是一个复合装饰器,既可以用在类的构造函数的参数上装饰,又可以应用在类的属性上装饰。有index参数的时候,它是作为一个参数装饰器使用的,

ts 复制代码
export function Inject<T = any>(
  token?: T,
): PropertyDecorator & ParameterDecorator {
  return (target: object, key: string | symbol | undefined, index?: number) => {
    // 读取TS元编程的信息或者用户手动配置的依赖Key,就比如我们在反射章节的那个用法
    const type = token || Reflect.getMetadata('design:type', target, key);

    if (!isUndefined(index)) {
      let dependencies =
        Reflect.getMetadata(SELF_DECLARED_DEPS_METADATA, target) || [];
      // 绑定某个参数依赖的类
      dependencies = [...dependencies, { index, param: type }];
      Reflect.defineMetadata(SELF_DECLARED_DEPS_METADATA, dependencies, target);
      return;
    }
    let properties =
      Reflect.getMetadata(PROPERTY_DEPS_METADATA, target.constructor) || [];
    // 绑定某个参数依赖的类
    properties = [...properties, { key, type }];
    Reflect.defineMetadata(
      PROPERTY_DEPS_METADATA,
      properties,
      target.constructor,
    );
  };
}

最后是Injectable装饰器,最开始看起来好像是一个很神奇的装饰器,最后看了NestJS的很多源码之后发现,它应该是这几个装饰器里面最废柴的装饰器(不考虑它的额外参数的话,用或不用它,没有太大的实质性影响,😂)。

ts 复制代码
export function Injectable(options?: InjectableOptions): ClassDecorator {
  return (target: object) => {
    // 标记被装饰的类是一个可以被注入的类型
    Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
    // 标记将来运行的时候装入类的生命周期
    Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
  };
}

初探NestJS的运行原理之应用启动过程

我们在启动应用时,使用NestJS提供的NestFactory提供的工厂方法,调用create方法开始启动应用。 create方法首先创建了一个NestContainer类的实例,这个就是NestJS全局的IoC容器,这个类相当重要,后续的很多逻辑我们都是在围绕它分析的,此处我们先知道它重要,后面再来分析。

然后调用了另外一个叫做initialize的方法,这个方法使用NestJS内部实现的一个队列,开始去解析之前我们装饰器挂载的元数据,使用的是DependenciesScanner

scan开始调用scanForModules真正开始解析模块的依赖,这个scanForModules是个比较复杂的方法,也是比较重要的方法。

scanForModules首先创建了一个Module类的实例,Module类位于nest/packages/core/injector/container.ts中,这是NestJS自己的Module,不是我们写的那个class。我们从insertOrOverrideModule这个方法开始跟踪,可以一直跟踪到Module类的被IoC容器初始化,IoC将我们书写的按个class交给Module类关联。

Module把我们书写的那个被@Module装饰器装饰的class,作为了一个Provider加入到了Provider集合里面,可以在日后创建。

于是可以接着向下分析,为了简化分析,我们可以不用考虑DynamicModule,可以看到,它已经开始准备扫描imports导入的模块了。

然后开始递归创建导入的模块。

上述过程只处理完成了我们书写的那个作为Module的class入口文件,紧接着,还要扫描这个class的依赖数据,核心方法是scanModulesForDependencies,依次去解析它的依赖。

获取元数据的过程就不赘述了,我们主要需要看看addImportaddProvideraddControlleraddExportedProvider这些方法干了啥。

这些方法来源于IoC容器,在这之前我们提到过,它已经创建了。这个位置,需要使用断点调试一下,看看这个Provider是不是就是我们业务代码里面写的那个。

断点的实时数据如下:

运气真好,的确就是我们写的那个类,好了,到了这个位置,NestJS已经完成了依赖收集过程了。其它的行为也相似,不过最后我们还需要额外看一下exports的Providers,此处还不需要关心,到时候再回过头看即可,什么时候开始创建类,咱们接着看。

类的创建有个非常取巧的办法,我们首先在我们自己编写的那个Module类和Controller的contructor里面打一个断点,然后使用调用堆栈来查看创建的过程。

首先看Module的创建过程,在这之前,我们已经分析到了,我们编写的这个Module类被当做了一个Provider加到了NestJS自己的Module类的依赖里面。

在堆栈里面,有一个重要的方法调用,是createInstancesOfDependencies。这个方法的来源是另外一个核心类InstanceLoader,这个类里面关联着一个更加重要的类Injector,同时也关联着NestJS的IoC容器。

剩下的过程,就是分析Provider怎么创建的了。

这儿,有一个非常费解的逻辑,为什么要先调用createPrototypes,再调用createInstances,我是没有太想明白这个问题的,问了一下Chatgpt,它给我的答案非常满意。

在 NestJS 中,InstanceLoader 类负责实例化依赖项。在 createInstancesOfDependencies 方法内部,首先调用 createPrototypes 方法,然后再调用 createInstances 方法。理解这两个步骤的执行顺序需要考虑 TypeScript 的装饰器和类构造函数的执行顺序。

装饰器和类构造函数的执行顺序

在 TypeScript 中,装饰器是在类构造函数执行之前执行的。这就意味着,当类被实例化时,类的装饰器和类构造函数内部的装饰器都已经执行完毕。而在装饰器中,有时候我们会对类的原型进行一些操作,这些操作可能会影响实例的行为。

createPrototypes 方法的作用

在 NestJS 的 InstanceLoader 中,createPrototypes 方法的主要作用是为每个装饰器创建一个原型实例。这是因为一些装饰器可能会在原型上进行操作,而不是实例。例如,使用 @Injectable 装饰器时,它会为类创建一个原型,并在原型上存储一些元数据信息。

createInstances 方法的作用

createInstances 方法的目标是为每个原型创建实例。实例化发生在原型上,因为一些元数据信息可能存储在原型上,并在实例化时被使用。通过首先调用 createPrototypes,确保了装饰器中可能对原型的修改已经生效。

为何要按照这个顺序执行?

如果不按照这个顺序执行,可能会导致一些装饰器中的逻辑不生效,因为装饰器中的逻辑通常期望类的原型已经被修改。如果先实例化再调用 createPrototypes,那些装饰器可能错过对原型的修改的机会,从而影响实例的行为。

总的来说,NestJS 设计这样的执行顺序是为了确保装饰器中的逻辑能够正确地应用到实例上,同时考虑到 TypeScript 装饰器和类构造函数执行顺序的特点。

最后,可以进入到依赖项的初始化了,还是像上文描述的那样,我们在Provider里面打一个断点看看堆栈的情况就能够大致明白了。

在这个位置,已经事先找到了Provider依赖的参数列表 那么,依赖的参数列表又从哪儿来的呢?

篇幅太长,大家可能都已经忘了第一节的时候我们阐述元编程的时候TS的注入的信息了,内容如下。

我们此刻算是搞明白了构造器注入了,但是还差一个属性注入,就是我如下这样的写法:

ts 复制代码
@Injectable()
export class PeakTokenService extends BaseService {
  @Inject(RISK_CTRL_TOKEN)
  protected readonly riskCtrlService: IRiskCtrlService;

  @Inject(TOP_UP_TOKEN)
  protected readonly payService: ITopUpService;
}

在TS的装饰器里面,属性或方法装饰器是不允许直接给被装饰的字段赋值的,你只能通过PropertyDescriptor去修改value字段,实现增强或削弱。

但是为何我们这种用法却能够达到给字段赋值的效果呢,其实还是NestJS自行实现的黑魔法,接下来就一起看一下它是怎么实现属性注入的。

这个位置,我们就需要看Inject装饰器做了些什么了,Inject装饰器在工作的时候做了一个元数据的添加。 我们在NestJS的源码里面搜一下PROPERTY_DEPS_METADATA这个字段出现的位置,它一定应该出现在Injector这个类里面才是。

我们可以猜测大概这两个方法完成了属性注入:

为了证明猜测,可以在NestJS的源码里面打个断点看看,然后再查看堆栈信息来辅助我们推导。

最后,不要忘了一个之前留下来的TODO,@Module的exports选项是怎么样处理的,现在可以看一看了。

通过以上的操作,把exports选项标记的Providers添加到了NestJS内部的Module的一个集合里面。

最后看一下,这个集合是在什么时候被用到的呢,还是需要回到Injector类中。我们先搜一下

最终可以在之前我们已经分析过的查找依赖的过程中找到它的起始调用 其实它的目的就是相当于从另外一份已经创建好了的对象集合里面找可以作为DI的数据源。

我们目前已经大致搞清楚了NestJS的DI的全部流程,但是目前我们还没有总结它几个核心类的关系,每个类的职责,也没有对其启动流程总结,后续的章节我们将会做这件事儿。

在下一篇文章中,我们会开始解析NestJS的路由原理,未完待续。

相关推荐
一颗花生米。1 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
勿语&3 小时前
Element-UI Plus 暗黑主题切换及自定义主题色
开发语言·javascript·ui
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch7 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光7 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   7 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发