Nest.js入门 —— 模拟实现FakeNest(三)

987 阅读12分钟

Nest.js入门系列文章链接:

Nest.js入门——控制反转与依赖注入(一)

Nest.js入门——TS装饰器与元数据(二)

Nest.js入门——模拟实现FakeNest(三)

随着Node.js的出现,JavaScript一举成为了一个前后端通用的语言。不过,与前端领域中借助Node.js出现了一批优秀的工程化框架如Angular、React、Vue等不同,在后端领域出现的Express、Koa等著名工具都没有能够解决一个重要的问题——架构。Nest正是在这样的背景下出现的,它深受Angular设计思想的启发,而Angular 的很多模式又来自于 Java 中的 Spring 框架,所以我们可以说Nest就是 Node.js版的 Spring 框架。

因此对于很多Java后端同学来说,Nest中的设计与其编写方式都是非常容易理解的,但是对于前端出身的传统JS程序员,仅仅提到Nest中最主要最核心的思想如控制反转、依赖注入等概念就让人望而却步,更别说其原理还涉及到了TypeScript、装饰器、元数据、反射等等相关概念,再加上其官方文档及核心社区都是英文,使得许多同学都被挡在了门外。

Nest.js入门系列文章将从Nest的设计思想出发详细讲解其相关概念及原理,最终模仿实现一个极其简易(也可以说是简陋)的FakeNest框架。一方面让已经使用并希望进一步了解Nest原理的同学能够有所收获,另一方面也力图让从事传统JS前端开发的同学能够入门并了解借鉴到后端开发中的一些优秀思想。

本文为Nest.js入门的第三篇,也是这个系列的最后一篇。通过分析Nest.js的主要设计,结合控制反转、依赖注入的思想及装饰器、元数据的使用,我们模仿编写一个简化版本的FakeNest框架来理解Nest.js是如何实现的。

FakeNest框架的所有代码可以通过 github.com/kerryzhangc… 下载并运行。

一、Nest.js迷你项目

在经历了漫长的设计思想与新兴语法的学习之后,我们终于来到了自己实现一个FakeNest框架(微型Nest.js)这个激动人心的时刻!

不过先别着急,为了让FakeNest能够做到与Nest.js一摸一样的事情(至少在迷你项目设定的特定情形下),首先要做的是模仿Nest.js官网入门示例写一个包含最基础功能的可运行的迷你项目,之后分析迷你项目中所用到的Nest.js装饰器及函数并将它们一一实现,最后将这个迷你项目中所引入的有关Nest.js的部分全部更换为自己实现的FakeNest并再次运行。如果一切顺利的话,那么我们将会得到完全一致的运行结果。

下面列出了这个猫咪迷你项目的全部代码(模仿Nest.js入门教程)。在这个项目中,为了简化代码结构,我们将Service、Controller、Module以及启动函数全部放入同一个文件之中。

import 'reflect-metadata'
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Injectable } from '@nestjs/common'

// Service
@Injectable()
export class CatsService {
  private readonly cats: string[] = ['Lucy', 'Ketty'];

  hello(): string{
    return this.cats.join(',') + ' meow'
  }
}

// Controller
@Controller('/cats')
class CatsController{
  constructor(private catsService: CatsService) {}

  @Get('/hello')
  hello(){
    return this.catsService.hello()
  }
}

// Module
@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

// Start
async function bootstrap() {
  const app = await NestFactory.create(CatsModule);
  await app.listen(3000);
}
bootstrap();

这个迷你项目所实现的功能非常简单,提供了一个Get方法路径为/cats/hello的接口,该接口接收到请求后会返回所有猫咪的名字并和让它们和你亲切的打声招呼。实现这个迷你项目相关的功能已经完全足够让我们了解Nest.js背后的基本设计了。

现在,启动这个迷你项目并在浏览器中输入 http://localhots:3000/cats/hello ,就可以看到如下输出:

截屏2022-04-15 下午7.12.09

Bingo!恭喜你成功启动了这个迷你项目。

最后,让我们来一一分析一下我们都用到了Nest.js所提供的什么功能:

  1. Service:@Injectable类装饰器,标明CatsService类是一个Provider,以便其后续被注入;
  2. Controller: @Controller类装饰器,标明CatsController类一个Controller并且跟路径为/cats;@Get方法装饰器,标明hello方法提供一个Get接口,相对路径为/hello;
  3. Module: @Module类装饰器,接收并将上述Controller和Privider连接起来,标明CatsModule类是一个Module;
  4. Start: 调用NestFactory.create方法,接收CatsModule构造一个项目实例,之后启动项目并监听3000段后。

在下面的章节中,我们将围绕这些功能展开,一步步手把手的带你实现上述每一个装饰器和函数!

二、FakeNest框架实现

还记得我们前两章中的内容吗?让我们思考一下Nest.js怎样使用了它们:

  1. 控制反转和依赖注入是Nest.js的核心思想,一般而言Controller会依赖Provider,为了将它们解藕,Provider需要被Nest注入到Controller之中,为了明确Provider与Controller之间多对多的关系,Nest.js提供了Module来注册它们的对应关系。
  2. 装饰器和元数据是Nest.js实现的主要手段,为了能够将Provider注入Controller中,需要使用装饰器为它们分别添加一些元数据,之后在NestFactory类中调用create函数时取出这些元数据,通过它们得到Controller所需的Provider类型并实现Provider的新建注入。

希望你在继续向后读这篇文章前能真正看懂并理解这两句话,因为下面我们会围绕着这两句话阐述的思想来对迷你项目中所用到的Injectable、Controller、Get、Module装饰器生成函数以及启动类一一实现。

2.1 Injectable

import { FAKE_INJECTABLE_WATERMARK } from './common/const'

function Injectable() {
  // 返回类装饰器
  return (target: any) => {
    Reflect.defineMetadata(FAKE_INJECTABLE_WATERMARK, true, target);
  };
}

export default Injectable;

Injectable在执行后会返回一个类装饰器,它的主要作用就是将其所装饰的类标记为可注入的!因此我们可以看到,这里调用了Reflect.defineMetadata方法将key为FAKE_INJECTABLE_WATERMARK,value为true的元数据添加到了其所装饰的类target之上,这就是Nest.js对Provider的标记方式。

在我们的迷你项目中,该类装饰器装饰了CatsService类,该类将在未来被注入CatsController之中。

2.2 Controller

import { FAKE_BASE_PATH } from './common/const'

function Controller(path: string){
  // 返回类装饰器
  return function(target: any){
    Reflect.defineMetadata(FAKE_BASE_PATH, path, target)
  }
}

export default Controller

Contoller接受一个字符串path作为其所装饰类中相关Http监听方法的根路径(被Get/Post等修饰的方法),在执行后会返回一个类装饰器。这里调用了Reflect.defineMetadata方法将key为FAKE_BASE_PATH,value为path的元数据添加到了其所装饰的类target之上。

在我们的迷你项目中,该类装饰器装饰了CatsController类,该类的跟路径为/cats。

2.3 Get

import {FAKE_PATH, FAKE_METHOD} from './common/const'

// Get/Post/Put/Delete/Patch/Options/Head等Http监听方法通用
function Request(method: string){
  return function (path: string){
    // 返回方法装饰器
    return function(target: any, propertyKey: string){
      Reflect.defineMetadata(FAKE_METHOD, method, target, propertyKey)
      Reflect.defineMetadata(FAKE_PATH, path, target, propertyKey)
    }
  }
}

export const Get = Request('Get')
export const Post = Request('Post')
// ....

Get与Post/Put/Delete/Patch/Options/Head等都是由Request这个高阶函数执行后返回的函数,该函数接受一个字符串path作为其所装饰方法中相关Http监听所监听的相对路径,在执行后会返回一个方法装饰器。这里调用了Reflect.defineMetadata方法将key为FAKE_METHOD,value为method的元数据以及key为FAKE_PATH,value为path的元数据添加到了其所装饰的方法target.propertyKey之上。

在我们的迷你项目中,该方法装饰器装饰了CatsController类下的hello方法,该方法的相对路径为/hello。

2.4 Module

function Module(metadata: Record<string, any>) {
  return (target: any) => {
      for (const property in metadata) {
        Reflect.defineMetadata(property, metadata[property], target);
      }
  };
}

export default Module

Module接受一个对象metadata(其中存储了相关Provider及Controller)作为注册信息,在执行后会返回一个类装饰器。这里遍历了metadata中的属性property,调用了Reflect.defineMetadata方法将key为property,value为metadata[property] 的元数据依次添加到了其所装饰的类target之上。

在我们的迷你项目中,该类装饰器装饰了CatsModule类,该类在装饰后具有key为controllers,value为[CatsController]数组以及key为providers,value为[CatsService]数组的两个元数据。

2.5 启动类FakeFactory

可以看到在前述的所有装饰器中,我们所做的事情总结起来只有一点,那就是添加元数据!既然如此,我们可定需要在某处取出这些元数据并且使用。你猜的没错,这个地方就是我们的FakeFactory(等价于Nest.js中的NestFactory,不过为了标明这是我们自己写的框架换了名称)。

在详细给出FakeFactory的实现代码之前,我们需要先了解Nest.js是一个用于构建高效、可扩展的Node.js 服务器端应用程序的开发框架,是为了工程化架构而存在的框架。它是在Express/Fastify这种Http Server框架之上再次封装的,并且拥有替换使用不同Http Server框架的能力。为了简化代码,我们的FakeNest底层固定依赖Express提供Http基础服务(与Nest.js默认的一样)并且忽略了Http Server框架的替换功能。

为了让之前没有使用过Express的同学能够明白FakeFactory的代码,我们首先来看一下Express框架是如何提供Http服务的:

import express from 'express'

const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

可以看到使用Express启动一个基础的Http服务是非常简单的:

  1. 创建一个express实例app;
  2. 调用实例app上所提供的http相关方法,注册监听路径及其所调用的函数。
  3. 调用实例app上的listen方法,启动服务,监听指定端口;

明白了Express的基础使用方式后,让我们来看一下FakeFactory的总体结构:

import {FAKE_BASE_PATH, FAKE_METHOD, FAKE_PATH, FAKE_INJECTABLE_WATERMARK} from './common/const'
import express from 'express'

class FakeFactory {
  private app: Express
  private types: Record<string, any>

  constructor(){
    // 实例化Express
    this.app = express()
    // types将缓存所有Provider,保证其只被实例化一次
    this.types = {}
  }

  // 调用该方法以注册所需信息至Express实例并返回
  create(module: any): Express{
    // ...
    // 返回Express实例
    return this.app
  }

  // 初始化所有Controllers
  initControllers(Controllers: any){
    // ...
  }

  // 初始化一个controller实例上所有的监听方法
  initRoute(controller: any, basePath: string) {
    // ...
  }
	
  // 将Http监听方法注册至Express实例之中
  registerRoute(route: {path: string, method: string, fn: Function}){
    // ...
  }
}

export default new FakeFactory()

首先看一下FakeFactory上的两个私有属性,app中存储了Express实例,types用来缓存所有Provider,保证其只被实例化一次(这也是Nest.js的做法)。这两个属性在构造函数中初始化,其中app中存储的Express实例将会在create方法被调用后返回。

接下来我们一一深入其中的每一个方法,看看它们的细节实现。

create

create(module: any): Express{
  // 获取Module中注册的Controllers
  const Controllers = Reflect.getMetadata('controllers', module)
  this.initControllers(Controllers)
  // 返回Express实例
  return this.app
}

create方法主要通过Reflect.getMetadata获取由**@Module装饰器所添加到入参module上的key为controllers元数据**,取出其上所注册的所有Controllers类数组。接着我们调用initControllers方法并将该数组作为入参,进行其相关的所有初始化工作并将相关信息添加至app这个Express实例上。最后返回Express实例,提供给调用者做后续操作。

initControllers

initControllers(Controllers: any[]): void{
  Controllers.forEach((Controller: any) => {
    // 获取constructor所需provider
    const paramtypes = Reflect.getMetadata('design:paramtypes', Controller)
    // 不考虑provider需要注入的情况
    const args = paramtypes.map((Type: any) => {
      // 若未被Injectable装饰则报错
      if(!Reflect.getMetadata(FAKE_INJECTABLE_WATERMARK, Type)){
        throw new Error(`${Type.name} is not injectable!`)
      }
      // 返回缓存的type或新建type(只初始化一个Type实例)
      return this.types[Type.name] ?
        this.types[Type.name] :
        this.types[Type.name] = new Type()
    })
    const controller = new Controller(...args)
    // 获取该Controller根路径
    const basePath = Reflect.getMetadata(FAKE_BASE_PATH, Controller)
    // 初始化路由
    this.initRoute(controller, basePath)
  });
}

initControllers接受一个Controllers数组作为入参,遍历它们并将其一一实例化,这里有几点值得注意:

  1. 获取Controller构造函数上key为design:paramtypes的元数据(TS类装饰器自动添加的元数据),这个元数据的value中存储了实例化该Controller所需的所有入参类型。
  2. 遍历构造函数所需入参类型,获取Type上是否存在**@Injectable装饰器所添加到Provider类上的key为FAKE_INJECTABLE_WATERMARK, value为true的元数据**,从而判断该入参是否被@Injectable装饰器所修饰过。若是则说明该类不可被注入,抛出异常。若否,则判断是否存在缓存并返回,最后如果没有缓存,则将它实例化存储在缓存中并返回(这里为了代码简洁,忽略了这些Provider实例化时需要的其它Provider,默认所有Provider都可以通过New Provider()直接实例化)。
  3. 将构造好的入参注入Controller之中,实例化。
  4. 获取Controller上由 @Controller装饰器所添加到Controller类上的key为FAKE_BASE_PATH, value为path的元数据。该path就是Controller中Http方法监听的根路径。
  5. 调用initRoute方法,进行路由的初始化。

可以看到在这个函数中,Nest.js设计中的核心思想依赖注入通过装饰器及元数据实现了

initRoute

initRoute(controller: any, basePath: string): void{
  // 获取Controller上的所有方法名
  const proto = Reflect.getPrototypeOf(controller) 
  if(!proto){
    return
  }
  const methodsNames = Object.getOwnPropertyNames(proto)
    .filter(item => item !== 'constructor' && typeof proto[item] === 'function')

  methodsNames.forEach(methodName => {
    const fn = proto[methodName]
    // 取出定义的 metadata
    const method = Reflect.getMetadata(FAKE_METHOD, controller, methodName)
    const path = Reflect.getMetadata(FAKE_PATH, controller, methodName)
    // 忽略未装饰方法
    if(!method || !path){
      return
    }
    // 构造并注册路由
    const route = {
      path: basePath + path,
      method: method.toLowerCase(),
      fn: fn.bind(controller)
    }
    this.registerRoute(route)
  })
}

initRoute接受一个controller实例以及其上Http方法监听的根路径basepath字符串作为入参。

首先,我们获取controller实例原型上的所有属性,并且过滤出除构造函数外的所有方法名称(原型上的方法就是定义Controller时类上的方法)。

接下来,我们遍历这些方法名称,分别拿到由 @Get/@Post等方法装饰器添加到对应方法上的key为FAKE_METHOD,value为method的元数据以及key为FAKE_PATH,value为path的元数据。method为该方法所支持的Http请求方式,path为该方法所监听的Http请求相对路径。

最后,我们为所有@Get/@Post等方法装饰过的方法构造一个route对象,其中存储了path(Http请求的绝对路径)、method(Http请求方式)、fn(需要执行的回调函数),并且调用registerRoute方法将其作为参数传入。

registerRoute

registerRoute(route: {path: string, method: string, fn: Function}): void{
  const {path, method, fn} = route
  // Express实例上注册路由
  this.app[method](path, (req: any, res: any) => { 
    res.send(fn(req))
  })
}

registerRoute非常简单,接受一个route对象获取其中信息并将其注册在Express实例之中。

三、FakeNest框架测试

至此为止,我们完成了FakeNest框架的所有代码编写工作。但是我们是否能够和使用Nest.js一样使用它呢?让我们回到文章开头第一节中的迷你项目,对它进行一些改造,将所有Nest.js的引用替换为我们FakeNest框架的引用。

import 'reflect-metadata'

// import { NestFactory } from '@nestjs/core';
// import { Module, Controller, Get, Injectable } from '@nestjs/common'
// 替换为FakeNest!!!
import { FakeFactory, Module, Controller, Get, Injectable } from './fake'

// Service
@Injectable()
export class CatsService {
  private readonly cats: string[] = ['Lucy', 'Ketty'];

  hello(): string{
    return this.cats.join(',') + ' meow'
  }
}

// Controller
@Controller('/cats')
class CatsController{
  constructor(private catsService: CatsService) {}

  @Get('/hello')
  hello(){
    return this.catsService.hello()
  }
}


// Module
@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

// Start
async function bootstrap() {
  // const app = await NestFactory.create(CatsModule);
  // 替换为FakeNest上的FakeFactory!!!
  const app = await FakeFactory.create(CatsModule);
  await app.listen(3000);
}
bootstrap();

运行一下,可以看到我们成功的在3000端口上启动了Http服务。试着访问/cats/hello路径,Lucy和Ketty又再次和你亲切的打招呼啦!

四、总结

FakeNest框架借鉴并简化了Nest.js框架,只完成了其中的部分核心功能,并且剔除了一些边界条件和异常处理逻辑。但是其上的控制反转、依赖注入的思想、装饰器与元数据的使用与Nest.js是一致的!相信你在读懂这个简单的FakeNest框架后就能够对Nest.js的工作原理有一个较为深入的理解。

整个Nest.js入门系列就到此结束了,希望这个系列能够帮助你对Nest.js中所用到的思想技术及背后的原理有一个基础的认识。如果有任何的想法和疑问,也欢迎在留言区留言评论。