likes
comments
collection
share

Nest为什么可以这样写?

作者站长头像
站长
· 阅读数 12

Nest为什么可以这样写?

开篇碎碎念: 笔者用Nest有一段时间了,写过中间层也写过后端服务,享受着规范、便利写法的同时又很疑惑,为什么我在写Nest项目的时候可以这样写,为什么我可以@injectable而不需要手动初始化实例,为什么@Controller装饰一下就可以变成接口......本篇将会记录一下我对Nest实现的一些小探索。

Nest到底是什么

Nest到底是什么?它和我们常见的expresskoa是同一个东西么?

首先,我们可以明确的一点,expresskoa是基于NodeJs的开发框架,也就是说,它们其实是对Node的一层封装,并提供API用于完成某些基于Node的复合操作。

举个很简单的栗子🌰

const express = require('express')
const app = express()
const port = 3000
app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

通过上面的代码,我们很容易的就在本地起了一个监听端口为3000的服务,虽然我们调的是expressapplicationlisten方法,但其实底层用的就是我们熟悉的Node Http模块。另外,expresskoa都给我们内置了路由模块,我们不需要再去手动实现路由的分发。然后还有中间件、静态文件处理等模块,都提高了我们开发Node项目的效率。

总体来说,expresskoa与Node的关系就像是jQueryJavaScript间的关系类似,我们总会使用它们来加快我们项目的开发效率,但是我们不一定会用它们来约束我们的开发范式。

Nest则是更上层次的封装,就如它们官网说的

Nest provides a level of abstraction above these common Node.js frameworks (Express/Fastify), but also exposes their APIs directly to the developer.

除此之外,Nest还提供了一套完整的开箱即用的开发范式(也可以理解成应用结构体系),可以让我们创建、维护大型、高拓展性、低耦合性的应用。

关于这一点,笔者在之前的好几篇文章都有提及,这里也就不再赘述了,有兴趣的同学可以点击下面的传送门了解↓↓

那么,有这样一套开箱即用的开发范式有什么好处呢?个人的拙见:

首先它给自由的野马套上了缰绳,然后又为野马圈了一个草场,最后就任由野马在草场里自由奔跑。可能不是那么恰当的比喻,不过我认为在开发中大型项目的时候,必然会有协作的产生,有了协作的产生则需要一定的规范。

如果我们用的是express或者koa,并且我们没有及时的完善我们的开发协作规范,就容易会让项目走向失控,但是Nest帮我们很好的解决了这个问题,只要我们用了这个框架,就必须得看制定的规范进行开发协作。

Nest为什么能这么写

Nest最大的特色就是类Spring框架的开发模式了,我们能看到代码里会出现很多@,它们经常是跟着类或者类的属性一同出现,为什么在Nest项目里我们可以这样写呢?这么写的意义是什么呢?我们先来看一下示例:

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

这里看到的@Controller/@Get是一种叫做装饰器的写法,这种写法也许在其他的编程语言中很常见,不过在JavaScript里面还未能正式纳入标准中(目前仍在stage-3),我们可以通过babel或者typescript来使用装饰器语法。

让我们回到示例中,可以看到在CatsController类上面,有一个@Controller()装饰器,而findAll作为CatsController类的成员,它上面也有一个装饰器Get()。它们是一回事吗?然后他们分别做了些什么呢?让我们用babel的在线解析器来看看吧,先看一下装饰类的情况babel之后的代码是怎么样的。

示例代码如下:

function Controller(route) {
  return function (targetClass){
    // do something...
    targetClass.route = route
  }
}

@test
@Controller('cat')
class CatsController {}

Babel解析后代码如下:

function Controller(route) {
  return function (targetClass) {
    // do something...
    targetClass.route = route;
  };
}
let _CatsController;
_dec = test;
_dec2 = Controller('cat');
class CatsController {}
_class = CatsController;
[_CatsController, _initClass] = _applyDecs(_class, [], [_dec, _dec2]).c;
_initClass();

可以看到我们的装饰器和被装饰的类都被传入到_applyDecs函数中了,顾名思义,这个函数就是用来初始化装饰器的,并且参数是个数组,这就表示我们是可以在同一个类上面挂多个装饰器的。

然后我们再看看_applyDecs里面具体干了啥,对于现阶段来说最核心的代码是这段(便于理解,删减部分代码):

// ...
if (classDecs.length > 0) {
  for (
    var initializers = [],
    newClass = targetClass,
    name = targetClass.name,
    i = classDecs.length - 1;
    i >= 0;
    i--
  ) {
    var decoratorFinishedRef = { v: !1 };
    try {
      var nextNewClass = classDecs[i](newClass, {
        kind: "class",
        name: name,
        addInitializer: createAddInitializerMethod(
          initializers,
          decoratorFinishedRef
        )
      });
    } finally {
      decoratorFinishedRef.v = !0;
    }
    void 0 !== nextNewClass &&
      (assertValidReturnValue(10, nextNewClass),
        (newClass = nextNewClass));
  }
  return [
    newClass,
    function () {
      for (var i = 0; i < initializers.length; i++)
        initializers[i].call(newClass);
    }
  ];
}
// ...

从代码很容易可以看出,类装饰器的实装方式其实就是获取外部传入的类装饰器数组并按后进先出的顺序执行(距离被装饰者越近越先执行),这里也顺便解释了多装饰器共同出现时的作用顺序。

了解类装饰器的大致运行原理之后,我们来看看NestController装饰器做了哪些事情(便于理解,删减部分代码)。

export function Controller(
  prefixOrOptions?: string | string[] | ControllerOptions,
): ClassDecorator {
  const defaultPath = '/';

  const [path, host, scopeOptions, versionOptions] = // 处理外部传入的参数...

  return (target: object) => {
    Reflect.defineMetadata(CONTROLLER_WATERMARK, true, target);
    Reflect.defineMetadata(PATH_METADATA, path, target);
    Reflect.defineMetadata(HOST_METADATA, host, target);
    Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, scopeOptions, target);
    Reflect.defineMetadata(VERSION_METADATA, versionOptions, target);
  };
}

接收path/host参数,并将处理好的数据通过Reflect.defineMetadata的方式,声明到对应controllermetadata(元数据)中,后续在初始化路由时会由routes-resolver来完成每条路由的注册,其中路由的注册信息就是从metadata中获取的。

这里说到了一个新的东西reflect-metadata,这个也是让我疑惑很久的设定,到底为什么需要用到这个东西呢?

在介绍先我们需要了解reflect是什么,在ES6中已经出现的API,它也是我们常说的vue3 proxy劫持数据的幕后支持者之一。

MDN - reflect

reflect有一定概念之后,我们就可以来看看reflect-metadata干了什么。reflect-metadata相当于是丰富了原有Reflect对象的功能。个人理解reflect-metadata是一种增强描述,我们可以在不侵入原类、原函数或者原属性的前提下,对其进行额外的描述,例如这样:

class A {
  b: {}
}

Reflect.defineMetadata('test', '1', A)
Reflect.defineMetadata('testb', '2', A, 'b')

console.log(Reflect.getMetadata('test', A)) // '1'
console.log(Reflect.getMetadata('testb', A, 'b')) // '2'

Controller的处理为例,有了这些额外的描述之后,我们就可以直接得到一份完整的路由表,而不再需要我们手动的去维护。

例如我们有一个TestController

@Controller(‘test’)
export class TestController {
    @Get('/home')
    home(req, res) {}
    
    @Post('/detail')
    detail(req, res) {}
}

经过装饰器对类、函数的处理,然后nest在应用初始化时就可以根据已有的元数据生成这样一张路由表:

[
    {
        route: '/test/home',
        method: 'GET',
        fn: function(req, res) {},
        fnName: 'home',
    },
    {
        route: '/test/detail',
        method: 'POST',
        fn: function(req, res) {},
        fnName: 'detail',
    },
]

这样我们在编码时只需要聚焦于自己的业务即可,其他的事情会被框架自动处理。特别是当路由规则复制之后,比如涉及到了鉴权、请求头设定、响应拦截等操作,装饰器和reflect-metadata的优势会更加凸显。

其实到这里,关于标题的内容已经基本介绍完毕了,主要就是装饰器的引入以及reflect-metadata的使用,然后框架会进行全局的扫描,统一的处理这些元数据并且进行实例初始化,最终才我们得以用这种写法进行开发。

接下来,有了对装饰器和reflect-metadata的理解,我们得以更加深入nest的核心 -- 依赖注入以及控制反转。

依赖注入&控制反转

本来也想详细的介绍一下依赖注入&控制反转,但是随着资料检索的深入,发现关于依赖注入&控制反转在各个平台上都有很多很多优质的文章了,在这里就不再赘述了(如果要写,可能又得花多一倍的篇幅)。就留一张最简易的图来帮助 大家了解一下,如果希望更深入的了解,可以到底下的文章推荐查看。

Nest为什么可以这样写?

正文到此已经结束,感谢阅读。

笔者刚接触这个框架的时候,是满头问号的,但是按照文档上面的规范写又能把业务搞出来...但是每次写的时候有觉得很神奇,终于找到个时间能好好的把框架的实现原理浅看一下,也顺便把多年的疑惑给解决了。