Nest.js 入门与核心原理解析,从 IoC、DI、装饰器到元编程和元数据

使用 Node.js 开发服务端应用有很多方式,比如最简陋的使用内置 http 模块的 createServer api 即可快速创建一个服务。

如果是偏简单点的服务端应用还可以使用 Express 或 Koa 这种轻量封装的库。想要开发大型的、企业级别的服务端应用,那就得选择比如 Egg、Nest 或 Midway 这类企业级别的框架。

目前企业级框架中 Nest 是 Github 上 star 数量最多的,相当于把其它的按在地上打的差距,实际上手的开发体验也很棒。

上图统计时间截止到 2023.10.29

可以看到,Nest 的维护还是很稳定的,接下来先起个基本的应用,再去探索其写法的背后设计和实现原理。

快速创建一个 Nest 应用

Nest 提供了非常好用的 cli 工具,先安装一下:

bash 复制代码
npm i -g @nestjs/cli

然后使用 nest 命令生成一个本地项目:

bash 复制代码
nest new nest-demo-1

此命令作用很多,开发过程中经常要创建的 controllermoduleservice 等等都可以用该命令快速生成。

打开创建好的项目,可以看到以下目录结构:

基本的项目结构特别简单,对 src/ 目录下文件做一个简单的介绍:

  • app.controller.spec.ts :测试文件,写单测的地方;
  • app.controller.ts :控制器文件,负责处理传入的请求和向客户端返回响应;
  • app.module.ts :根模块文件,所有的子模块都要通过它来引入;
  • app.service.ts :提供功能服务的文件,负责数据的处理;
  • main.ts :入口文件。

打开 main.ts 看下入口文件的代码:

typescript 复制代码
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

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

代码很简单,就是引入根模块 AppModule 然后通过 NestFactory 这个对象创建一个应用程序,启动一个端口为 3000 的服务。

接着打开 app.module.ts 文件,该模块通过装饰器语法以及 controllersprovides 关键字段与 AppControllerAppService 建立了联系:

typescript 复制代码
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

再探 app.controller.ts 文件:

typescript 复制代码
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

该文件定义了一个被 Controller 装饰器装饰的类并在构造函数中接收一个 AppService 服务,以及一个被 Get 装饰器装饰的 getHello 方法,该方法中调用了服务中的 getHello 方法,并将结果返回。

打开 app.service.ts 文件,可以看到就是一个通过 Injectable 装饰器装饰的类:

typescript 复制代码
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

我们先不用具体去理解各个装饰器的作用和代码的组织方式,光从这几个文件来看,他的功能大致是这样的:定义了一个路由,该路由调用了服务里面的 getHello 方法,然后返回结果值 Hello World!

启动服务验证下:

bash 复制代码
yarn start:dev

启动成功后在 Apifox 中创建一个快捷请求,返回的结果与我们预想的一样:

实际开发中,我们只需要保留根模块文件 app.module.ts 文件,控制器文件 app.controller.ts 和服务文件 app.service.ts 都可以删除,因为我们一般会把其作为一个聚合各个子模块的模块,而不会去写业务功能。

提供增删改查接口

接下来我们写下简单的增删改查接口,让大家快速掌握 Nest 的基本写法,新建一个项目:

bash 复制代码
nest new nest-demo-2

假如我们现在要写一个用户模块,要求提供创建用户、删除用户、修改用户资料和查询所有用户的接口,这个用户模块就是我上面所说的子模块。

删掉我上面提到的控制器文件 app.controller.ts 和服务文件 app.service.ts 后,我们使用 nest 命令先创建一个子模块,将其放在 modules/ 目录下:

bash 复制代码
nest generate module modules/user

创建成功后,点开 app.module.ts 你会发现自动引入了一个 UserModule ,并且在 user.module.ts 中已经写入了基本的代码。这就是使用 nest 命令的好处之一,可以少写重复的代码,提高开发的效率和舒适度。

接着创建控制器,同样使用 nest 命令:

bash 复制代码
nest g controller modules/user --no-spec

generate 可以使用缩写 g,而 --no-spec 表示不生成测试文件。

控制器文件一般是用来处理请求和向客户端返回响应的,所以它不应该充斥大量的业务逻辑,一般我们会将业务逻辑写在 service 文件下,继续使用 nest 命令生成:

bash 复制代码
nest g service modules/user --no-spec

来到 user.module.ts 你会发现自动把我们生成的控制器和服务都自动引入了:

typescript 复制代码
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

OK,接下来我们在 main.ts 中定义一个全局变量 UsersStorage ,用来模拟存储工具(非持久化):

typescript 复制代码
globalThis.UsersStorage = [];

后续我们的数据操作就基于该变量。先使用命令启动服务:

bash 复制代码
yarn start:dev

创建

接下来我们实现创建一个 User 的接口,在 user.controller.ts 文件中输入以下代码:

typescript 复制代码
import { Controller, Body, Post } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post('/create')
  async createUser(@Body() userInfo: any) {
    const res = await this.userService.create(userInfo);
    return {
      code: 0,
      msg: '创建成功',
      data: res,
    };
  }
}

可以看到又出现了新的装饰器, @Post 代表请求方式是 post@Body 代表是从请求 body 中解析传入的参数,其背后的实现原理我们先不用管,先知道是这么用的就行。

上面代码还在构造函数 contructor 中定义了一个私有变量 userService ,但是我们没有初始化它, Nest 就自动给我们注入了 UserService ,并能使用该类中的方法,比如上面的 create 方法,这是怎么做到的呢?后面我们再细说。

user.service.ts 文件中代码如下:

typescript 复制代码
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  async create(userInfo: any) {
    globalThis.UsersStorage.push(userInfo);
    return userInfo;
  }
}

可以看到逻辑也是非常简单,往全局变量 UsersStorage 数组中往后添加了一个用户。

现在打开 Apifox,创建一个快速请求来验证我们上述的创建接口是否好用:

请求正常响应,且返回正确,那么一个简单的创建接口就完成了。

查询

查询接口也很简单,继续在 controller 中增加我们的方法:

typescript 复制代码
import { Controller, Body, Get, Post } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  // ...

  @Get('/queryList')
  async getAllUsers() {
    const res = await this.userService.findAll();
    return {
      code: 0,
      msg: '请求成功',
      data: res,
    };
  }
}

service 中代码如下:

typescript 复制代码
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  // ...

  async findAll() {
    return globalThis.UsersStorage;
  }
}

删除

删除操作一般使用 delete 方式请求,需要用到装饰器 @Delete ,还要传入一个参数 name 字段,使用装饰器 @Param 解析,用于找到该名字后将其对应的用户信息对象删除,继续在 controller 中增加我们的方法:

typescript 复制代码
import { Controller, Body, Param, Get, Post, Delete } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}
  
  // ...

  @Delete('/delete/:name')
  async removeGoods(@Param('name') name: string) {
    const res = await this.userService.delete(name);
    return {
      code: 0,
      msg: '删除成功',
      data: res,
    };
  }
}

service 中代码如下:

typescript 复制代码
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  // ...

  async delete(name: string) {
    const idx = globalThis.UsersStorage.findIndex((item) => item.name === name);
    globalThis.UsersStorage.splice(idx, 1);
    return globalThis.UsersStorage;
  }
}

修改

假设我们现在要修改某个用户的年龄大小,部分修改一般使用装饰器 @Patch ,对应请求方式为 patch ,传入参数 name 字段,以及需要修改为什么年龄的 age 字段,使用装饰器 @Body 解析请求参数,继续在 controller 中增加我们的方法:

typescript 复制代码
import { Controller, Body, Param, Get, Post, Delete, Patch } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  // ...

  @Patch()
  async updateGoods(@Body() userInfo: any) {
    const res = await this.userService.update(userInfo);
    return {
      code: 0,
      msg: '修改成功',
      data: res,
    };
  }
}

service 中代码如下:

typescript 复制代码
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  // ...

  async update(userInfo: any) {
    const idx = globalThis.UsersStorage.findIndex(
      (item) => item.name === userInfo.name,
    );
    globalThis.UsersStorage[idx].age = userInfo.age;
    return globalThis.UsersStorage;
  }
}

OK,就这么多,上手 Nest 我们就先写到这么多,更多的内容我们可以慢慢探索,但是,如果你是一个初学者,其实你在书写上面的代码时,你一定是困惑的,你不明白各个装饰器的背后都做了什么,不明白为什么要使用 Module 来组织各个模块,不明白为什么能直接访问未被实例初始化的 service 等,接下来我们就来探索下其背后的实现原理。

IoC(控制反转)和 DI(依赖注入)

控制反转和依赖注入是 Nest 的核心设计思想,我们先不讲概念,从一个例子逐步理解。

一个公司的例子

假如现在有一家软件公司,简化后的公司架构可以用如下代码表示:

typescript 复制代码
// 产品经理
class ProductManager {
  generateRequirement() {
    console.log("A requirement has been generated.");
  }
}

// 程序员
class Programmer {
  completeRequirement() {
    console.log("A requirement has been completed.");
  }
}

// 主管
class Director {
  private productManager: ProductManager;
  private programmer: Programmer;

  constructor() {
    this.productManager = new ProductManager();
    this.programmer = new Programmer();
  }

  task() {
    this.productManager.generateRequirement();
    this.programmer.completeRequirement();
  }
}

// 公司
class Company {
  run() {
    const screwWorkshop = new Director();
    screwWorkshop.task();
  }
}

在这个简化的公司架构中,定义了两个负责实际生产的产品经理类和程序员类,以及依赖了这两个类的主管类,最后是经营的公司类。

可以看到,想要公司开始正常经营,公司层面让主管负责管理,主管要向程序员和产品经理下达命令,安排需求和完成需求,这样产品逐渐得到迭代,客户也越来越多。

依赖耦合

随着市场的逐渐饱和和竞争者赶上,公司经营出现困难,需要降本增效,这时公司决定开除工资高的程序员,重新招便宜的程序员。此时主管就必须找程序员谈话、开除、重新招人,非常琐碎又消耗心力,代码只体现重新招到的程序员代码变动,如下:

typescript 复制代码
// 便宜程序员
class CheapProgrammer {
  completeRequirement() {
    console.log("A requirement has been completed.");
  }
}

// 主管
class Director {
  private productManager: ProductManager;
  private programmer: CheapProgrammer;

  constructor() {
    this.productManager = new ProductManager();
    this.programmer = new CheapProgrammer();
  }

  task() {
    this.productManager.generateRequirement();
    this.programmer.completeRequirement();
  }
}

虽然新招的便宜程序员也能完成需求,但是因为压力太大很快离职了,主管又要消耗大精力招人,在代码中体现为重新实例化一个例如 OtherProgrammer 类。

产品经理类 ProductManager 和程序员类 Programmer 都是产品迭代的实际执行者,属于底层类,它们都归属于高层类主管类 Director ,在这个公司中,高层类依赖了底层类,这样明显违反了"依赖倒置"原则,最直接的问题就是导致明明主管的工作是安排任务,让产生需求和完成需求顺利进行,但还需要去考虑员工的问题。

解耦

为了让主管类专注做他该做的事(主管类并不关心来的是什么程序员,只要是能完成需求的程序员就行,产品经理也同理),所以我们需要解耦这种关系,可以改造为:

typescript 复制代码
// 定义一个产品经理接口
interface ProductManager {
  generateRequirement: () => void
}

// 定义一个程序员接口
interface Programmer {
  completeRequirement: () => void
}

// 贵产品经理
class ExpProductManager implements ProductManager {
  generateRequirement() {
    console.log("A requirement has been generated.");
  }
}

// 贵程序员
class ExpProgrammer implements Programmer {
  completeRequirement() {
    console.log("A requirement has been completed.");
  }
}

// 主管
class Director {
  private productManager: ProductManager;
  private programmer: Programmer;

  constructor(pm: ProductManager, programmer: Programmer) {
    this.productManager = pm;
    this.programmer = programmer;
  }

  task() {
    this.productManager.generateRequirement();
    this.programmer.completeRequirement();
  }
}

// 公司
class Company {
  run() {
    // 控制反转
    const expProductManager: ExpProductManager = new ExpProductManager()
    // const cheapProductManager: CheapProductManager = new CheapProductManager()
    const expProgrammer: ExpProgrammer = new ExpProgrammer()
    // const cheapProgrammer: CheapProgrammer = new CheapProgrammer()

    const screwWorkshop = new Director(expProductManager, expProgrammer);
    screwWorkshop.task();
  }
}

现在,我们对产品经理类和程序员类这种底层类分别定义了一个接口,每个产品经理类和程序员类都要遵从接口实现,也就是产品经理类必须实现了 generateRequirement 方法,程序员类必须实现了 completeRequirement 方法。

然后对产品经理类和程序员类的实例化操作我们不再在主管类中去做,而是放到了该类的外部(公司层面)去做,构造函数直接接收实例化后的类,这些类都实现了对应的方法。

这样就彻底解放了主管类,他不需要再关心来的是什么人,只要能完成任务,保证需求正常迭代即可。

概念对照

在上面的改造过程中,其实我们已经在接触 IoC 和 DI 了,但可能还不是很清楚怎么将代码与概念结合起来理解。

首先看下 IoC 和 DI 的概念:

  • 控制反转:是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。控制是指在一个类中完成了其依赖的对象的创建和绑定,反转是指将这种控制权移交给了 IoC 容器。
  • 依赖注入:控制反转只是一种思想,而没有具体告诉我们该怎么做。依赖注入就是实现控制反转的一种方式,它允许在类之外创建依赖的对象,并通某些方式将这些对象提供给类。

总结就是,使用"依赖注入"的手段,我们能够将类所依赖对象的创建和绑定移动到类自身的实现之外,实现了"控制反转"的效果,以便符合"依赖倒置"原则。

用一张图表现下上面的改造:

那么改造的代码与概念对比起来理解话,大概如下:

  • 控制反转:在 Director 类中需要使用到的 productManager/programmer 实例,被放到了 Company 类中实例化后传入,Director 对依赖项 ExpProductManager/ExpProgrammer 的控制被反转了;
  • 依赖注入:在 Director 类中不关注具体 productManager/programmer 实例的创建,而是通过构造函数 constructor 注入;

最后,我们将 Nest 中的各个模块与我们的公司进行一个类比:

  • Provider 👉 ExpProductManager/ExpProgrammer:实现具体功能的底层类。
  • Controller 👉 Director:调用底层类的高层类。
  • NestFactory 👉 Company:IoC 容器,控制相关类实例的新建与注入,解藕各个类相互间的依赖。

Decorator(装饰器)

在本文一开始例举的增删改查示例中,我们看到了 ControllerInjectableBodyGet 等装饰器,事实上在 Nest 的应用中,大量用到了各种各样的装饰器,所以我们有必要了解下 TypeScript 中装饰器的语法。

由于经过自己测试,当前最新的 Nest 10.x 版本还是使用 TypeScript 装饰器的旧语法,所以我们下面讲解也是基于旧语法,推荐学习阮一峰的 TypeScript 教程 - 装饰器(旧语法)

装饰器是一种语法结构,写法为 @Decorator,这个结构中有两个部分:

  • @:标识后面的函数作为装饰器使用;
  • Decorator:编译阶段执行的一个函数。

比如下面的简单示例:

typescript 复制代码
function logName(target: any) {
  console.log(`This is class ${target.name}`);
  return target;
}

@logName
class Programmer {} // "This is class Programmer"

我们定义了一个函数 logName,接收一个参数,打印一条信息,将原参数返回。上面作为装饰器使用,本质上类似以下调用过程:

typescript 复制代码
function logName(target: any) {
  console.log(`This is class ${target.name}`);
  return target;
}

class Programmer {} 

logName(Programmer) // "This is class Programmer"

上面我们只是打印了一条信息,但是我们还能做更多,比如给类的每个实例添加一个属性并赋值,另外,我们可以补足下 target 类型,它本质上是类的构造函数:

typescript 复制代码
type Constructor = new (...args: any[]) => any;

function addFate<T extends Constructor>(target: T) {
  return class extends target {
    constructor(...args: any[]) {
      super(...args);
      this.fate = 'chives';
    }
  }
}

@addFate
class Programmer {
  [x: string]: any

  print() {
    console.log(`Most of programmers are ${this.fate}`)
  }
}

const p = new Programmer()
p.print() // Most of programmers are chives

除了类装饰器,还有方法装饰器、属性装饰器、存取器装饰器、和参数装饰器。受篇幅限制,我写了一个例子,用以介绍它们的大概作用。

有一个程序员类 Programmer ,我们要从这个类 new 一些程序员来干活儿,对他的要求有以下这些:

  • 这个人的背景是有房贷;
  • 年龄小于 35 岁;
  • 每天早上工作最低 3 小时,中午后工作最低 7 个小时;
  • 上报每天的工作时长。

首先用一个装饰器来给这个类的原型对象添加背景:

typescript 复制代码
type Constructor = new (...args: any[]) => any;

// 类装饰器
function addBackground<T extends Constructor>(target: T) {
  target.prototype.background = "mortgage slave";
  return target;
}

再用一个装饰器来判断类的属性(这里我们指年龄 age)值是否大于特定的值,如果大于就抛错:

typescript 复制代码
// 属性装饰器
function validateAge(max: number) {
  return (target: Object, propertyKey: string) => {
    Object.defineProperty(target, propertyKey, {
      set: function (v: number) {
        if (v > max) {
          throw new Error(`${v} is too old!`);
        }
      },
    });
  };
}

接下来的装饰器用于打印总工时:

typescript 复制代码
// 方法装饰器
function logWorkTime(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: any[]) {
    const result = original.call(this, ...args);
    console.log(`Already worked ${result} hours`);
    return result;
  };
}

最后我们的类长这样:

typescript 复制代码
@addBackground
class Programmer {
  @validateAge(35)
  age!: number;

  @logWorkTime
  work(morningTime: number, afternoonTime: number) {
    return morningTime + afternoonTime;
  }
}

const programmer = new Programmer();
const a = programmer.work(3, 7); // Already worked 10 hours
programmer.age = 36; // throw error

上面给执行实例方法 programmer.work(3, 7) 时会打印已经工作的时长,给属性赋值 programmer.age = 36 时会抛出错误,因为被 validateAge 装饰后,对该属性的赋值有所限制。

但是我们对照下要实现功能,少了一个校验 "每天早上工作最低 3 小时,中午后工作最低 7 个小时",你可能会想通过参数装饰器去实现,但是很遗憾,参数装饰器功能很有限,其函数定义如下:

typescript 复制代码
type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
  • target:(对于静态方法)类的构造函数,或者(对于类的实例方法)类的原型对象。
  • propertyKey:所装饰的方法的名字,类型为 string | symbol
  • parameterIndex:当前参数在方法的参数序列的位置(从 0 开始)。

从上面的定义来看,我们只能拿到方法名和当前装饰的参数的位置,而对参数本身做不了任何事,更别提校验参数了。

这就要引出我们下一个章节讲讲元编程了,学习完就可以实现我们的参数校验功能!

Metaprogramming (元编程)

按照维基百科的定义

Metaprogramming is a programming technique in which computer programs have the ability to treat other programs as their data. It means that a program can be designed to read, generate, analyze or transform other programs, and even modify itself while running.

元编程是一种编程技术,允许计算机程序将其它程序作为其数据。这意味着一个程序可以读取、生成、分析、转换其它的程序,甚至是在运行时修改自身。

概念是比较抽象的,简短总结:将程序作为数据,然后对其维护。

上面的程序维护 在不同上下文中,有不同的含义。比如我们写的 TypeScript 程序通过编译器转为了 JavaScript,这里的程序 就是指 TypeScript,维护就是指编译过程,这是在编译阶段的元编程。

在运行阶段,程序 就指正在执行的代码逻辑行为,比如创建对象、函数、类等实体,而这些实体可能非常复杂,维护 就是我们对这些实体的读取、分析或修改等行为。而 ES6 的 ProxyReflect 让元编程变得更加方便。

举个简单的例子:

typescript 复制代码
const programmer = {
  name: "vortesnail",
  age: 28,
};

const proxy = new Proxy(programmer, {
  set: function (target, propKey, value, receiver) {
    if (propKey === "age" && value > 28) {
      throw new Error("property name must be less than or equal to 28.");
    }
    return Reflect.set(target, propKey, value, receiver);
  },
});

proxy.age = 30;

这段代码中,程序 就是指 programmer 这个实体本身以及其任何行为,而维护 就是我们代理了这个实体,并将它的 name 属性赋值规定为小于等于 28,不然就会抛错。换句话说,我们扩展了这个实体的能力,对赋值做了校验。

在我看来,我们在上一节举的例子中,实现的那些装饰器都在元编程的范围。

现在再看我们未实现的"每天早上工作最低 3 小时,中午后工作最低 7 个小时"参数校验功能,我们可以按照以下代码进行实现:

typescript 复制代码
// 其它代码不变

// 方法装饰器
function logWorkTime(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: any[]) {
    // 在方法装饰器中对参数做校验 - START
    const proptype = target as any;
    proptype.paramsCollector = Reflect.get(proptype, "paramsCollector") || {};
    for (let parameterIndex = 0; parameterIndex < args.length; parameterIndex++) {
      let minNum: number[] = Reflect.get(proptype.paramsCollector, `${propertyKey}_${parameterIndex}`);
      if (args[parameterIndex] < minNum) {
        throw new Error(`Working hours are less than ${minNum} hours`);
      }
    }
    // 在方法装饰器中对参数做校验 - END

    const result = original.call(this, ...args);
    console.log(`Already worked ${result} hours`);
    return result;
  };
}

// 参数装饰器
function minimum(num: number) {
  return (target: Object, propertyKey: string, parameterIndex: number) => {
    const proptype = target as any;
    proptype.paramsCollector = Reflect.get(proptype, "paramsCollector") || {};
    Reflect.set(proptype.paramsCollector, `${propertyKey}_${parameterIndex}`, num);
  };
}

@addBackground
class Programmer {
  // ...

  @logWorkTime
  work(@minimum(3) morningTime: number, @minimum(7) afternoonTime: number) {
    return morningTime + afternoonTime;
  }
}

const programmer = new Programmer();

上面代码中,我们定义了一个参数装饰器 minimum 用于在原型对象上新增一个属性 paramsCollector,它的作用为存储传入的值,也就是我们要求的最低工时,结构如下:

typescript 复制代码
{
  work_0: 3,
  work_1: 7
}

接着还改造了方法装饰器 logWorkTime,它主要是从原型对象上把 paramsCollector 对象下的参数校验最小值取出来,接着遍历我们的参数,就可以对当前传入的参数值进行比较了,不符合要求的直接抛错。

现在如果我们执行 work 方法时,传入的参数不符合要求就会报错:

typescript 复制代码
programmer.work(2, 6); // throw error

上面代码虽然实现了我们想要的参数校验功能,但是有一个问题就是,我们"污染"了原型对象,因为我们给它新增了一个属性 paramsCollector,这是不利于维护的,也不优雅,那么我们该怎么办呢?那就要讲讲接下来的元数据了。

关于元编程,推荐再仔细阅读下这两篇文章,帮助理解:
JavaScript 元编程简介
JavaScript 元编程

Metadata(元数据)

元数据表示的含义为数据的额外数据,比如下面的一个简单对象:

typescript 复制代码
const p = {
  name: 'vortesnail'
};

这个对象本身是一个数据,对象中 name 字段的值是否可写、是否可遍历都是该字段的元数据,本质就是对数据的一种描述,而这种描述能够被读写的。我们要理解这句话,可以将上述对象的 name 字段设置为不可修改:

typescript 复制代码
const p = {
  name: "vortesnail",
};

let descriptor1 = Object.getOwnPropertyDescriptor(p, "name");

console.log(descriptor1); // { value: 'vortesnail', writable: true, enumerable: true, configurable: true }

Object.defineProperty(p, "name", {
  writable: false,
});

let descriptor2 = Object.getOwnPropertyDescriptor(p, "name");

console.log(descriptor2); // { value: 'vortesnail', writable: false, enumerable: true, configurable: true }

descriptor1 会打印出 writable: true,表示我们可写;接着通过 Object.definePropertyname 字段修改为不可写,descriptor2 打印出 writable: false,这时候 p.name = "other" 将会报错。

于是,我们就可以将属性描述符作为这个对象数据的元数据来理解。

但是现在有个问题,我们目前依靠 JavsScript 的语法无法给数据添加自定义的元数据,幸运的是,现在有一个提案,通过扩展 Reflect 的功能,允许向对象和对象属性添加自定义元数据。

reflect-metadata

这是一个 npm 包,相当于一个 Reflect 的附加 polyfill 方案,扩展了 Reflect 的能力,要使用的话需要先安装下:

bash 复制代码
yarn add reflect-metadata

还需要把 tsconfig.json 中的以下配置项打开:

json 复制代码
{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
  },
}

以下是扩展后的读写元数据方法的使用方式:

typescript 复制代码
// 给对象定义一个元数据
Reflect.defineMetadata(metadataKey, metadataValue, target);

// 给对象属性定义一个元数据
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);

// 获取对象上的元数据
const result = Reflect.getMetadata(metadataKey, target);
// 获取对象属性上的元数据
const result = Reflect.getMetadata(metadataKey, target, propertyKey);

可以看到,和使用 JavaScript 对象一样,元数据也就是一个键值对而已。以下是个简单例子:

typescript 复制代码
import "reflect-metadata";

const p = {
  name: "vortesnail",
};

Reflect.defineMetadata("age", 18, p);
Reflect.defineMetadata("is", "string", p, "name");

// 对象 p 打印结果
console.log("p ->", p); // p -> { name: 'vortesnail' }

// 对象 p 上的元数据
console.log("p(age) ->", Reflect.getMetadata("age", p)); // p(age) -> 18
// 对象 p 的 name 属性上的元数据
console.log("p.name(is) ->", Reflect.getMetadata("is", p, "name")); // p.name(is) -> string

从打印日志可以看出来,向对象 p 或其属性注册元数据并不会改变这个对象的原始结构。

事实上,元数据应该被保存在 [[Metadata]] 内部插槽。

现在我们可以重新优化上面参数校验的功能了,只需要将 Reflect.getReflect.set 的用法替换为 Reflect.getMetadataReflect.defineMetadata 用法即可:

typescript 复制代码
// 其它代码不变

// 方法装饰器
function logWorkTime(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args: any[]) {
    const paramsCollector = Reflect.getOwnMetadata("paramsCollector", target) || {};
    for (let parameterIndex = 0; parameterIndex < args.length; parameterIndex++) {
      let minNum: number[] = paramsCollector[`${propertyKey}_${parameterIndex}`];
      if (args[parameterIndex] < minNum) {
        throw new Error(`Working hours are less than ${minNum} hours`);
      }
    }

    const result = original.call(this, ...args);
    console.log(`Already worked ${result} hours`);
    return result;
  };
}

// 参数装饰器
function minimum(num: number) {
  return (target: Object, propertyKey: string, parameterIndex: number) => {
    let paramsCollector = Reflect.getOwnMetadata("paramsCollector", target) || {};
    paramsCollector = { ...paramsCollector, [`${propertyKey}_${parameterIndex}`]: num };
    Reflect.defineMetadata("paramsCollector", paramsCollector, target);
  };
}

// 其它代码不变

上述代码使用的是 Reflect.getOwnMetadata 而不是 Reflect.getMetadata,他可以避免从原型链上去查找元数据。

现在我们就真正做到了对方法参数的校验,同时还不污染类的原型。

写一个 Toy Nest

经过前面设计思想和相关语法的学习后,是时候写一个 Toy Nest 来练练手了。

Nest 初始项目

在本文一开始,我们通过官方脚手架 @nestjs/cli 创建了一个名字为 nest-demo-1 的初始化项目,大家可以翻到前面这一小节再看看,初始化的项目特别简单。

为了更便于大家阅读,也为了更好理解 Nest 的实现,我对初始化项目做些改造,第一是将所有模块都放到 main.ts 文件中;第二是给 ControllerGet 装饰器都传入了参数。

使用以下命令创建项目:

bash 复制代码
nest new nest-demo-3

改造后文件结构如下:

main.ts 代码如下:

typescript 复制代码
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Injectable } from '@nestjs/common';

// 原来的 .service.ts
@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

// 原来的 .controller.ts
@Controller('/app')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('/hello')
  getHello(): string {
    return this.appService.getHello();
  }
}

// 原来的 .module.ts
@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

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

在控制台执行命令启动服务:

bash 复制代码
yarn start:dev

接着使用 Apifox 测试下该路由,正常返回了结果。

我们实现的 Toy Nest 起码要先保证和这个初始化项目能做到的事情一致,所以我们的思路是,先将这个初始化项目中使用到的装饰器和方法一一实现,最后将 Nest 相关的实现替换为我们自己的实现,正常情况下,会得到一致的运行结果。

代码功能分析

在上面的初始项目中,我们看看都用到了 Nest 中什么功能:

  1. 底层服务类 AppService 要在 AppController 的构造函数中被注入,使用装饰器 @Injectable() 标记为一个 Provider
  2. 调用底层服务的 AppController 被装饰器 @Controller('/app') 标记为一个 Controller,并且路由访问的根路径为 /app,装饰器 @Get('/hello') 标记 getHello 方法的访问方式是 get,并且相对路径为 /hello
  3. 装饰器 @Module 标记 AppModule 是一个 Module,作用为将上述 ControllerProvider 进行关联。
  4. 调用 NestFactory.create 方法,参数传入 AppModule 构造一个服务实例,启动并监听 3000 端口。

接下来,我们围绕上面用到的所有装饰器和方法,一步步实现它们。

实现各个装饰器

前面我们提到过,IoC(控制反转)和 DI(依赖注入)是 Nest 设计的核心思想,而实现的主要手段就是通过装饰器元数据,大家在接下来的实现过程中,跟着我逐步理解到底其中的奥秘。

篇幅限制,我不会把所有代码都放到本文中,若需要参考,可点击该项目地址进行查看:toy-nest

Injectable

新建 src/common/injectable.ts 文件,写入以下代码:

typescript 复制代码
import { INJECTABLE_WATERMARK } from '../const';
import type { ClassDecorator } from '../types';

function Injectable(): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
  };
}

export default Injectable;

Injectable 方法的代码特别简单,它的作用是返回一个装饰器,将装饰的类标记为可注入的,回顾示例项目中,appService 没有在构造函数中初始化,但是我们却可以直接使用,就是因为我们标记了该类,后续会在 IoC 容器中进行注入。

类装饰器可以没有返回值,如果有返回值,就会替代所装饰的类的构造函数。

Controller

新建 src/common/controller.ts 文件,写入以下代码:

typescript 复制代码
import { PATH_METADATA } from '../const';
import type { ClassDecorator } from '../types';

function Controller(path?: string): ClassDecorator {
  const defaultPath = '/';

  return (target) => {
    Reflect.defineMetadata(PATH_METADATA, path || defaultPath, target);
  };
}

export default Controller;

Controller 方法接收参数 path,并将其值存入元数据,该值的含义是请求的根路径,在示例项目中,该值指 /app

Get

新建 src/common/request.ts 文件,写入以下代码:

typescript 复制代码
import { PATH_METADATA, METHOD_METADATA } from '../const';
import type { MethodDecorator } from '../types';

function RequestMapping(method?: string) {
  return (path?: string): MethodDecorator => {
    const reqPath = path || '/';
    const reqMethod = method || 'Get';

    return (target, propertyKey, descriptor) => {
      Reflect.defineMetadata(METHOD_METADATA, reqMethod, descriptor.value);
      Reflect.defineMetadata(PATH_METADATA, reqPath, descriptor.value);
    };
  };
}

export const Get = RequestMapping('Get');
export const Post = RequestMapping('Post');

Get 还是 PostPatchDelete 等 http 方法都是由 RequestMapping 这个高阶方法执行得来的,返回后的方法接收一个 path 参数,表示请求的相对路径。

最内层返回的方法即是我们的装饰器方法,该装饰器主要是给当前装饰的方法添加了两个元数据,分别表示请求方式和相对路径。在示例项目中,请求方式表示 Get,相对路径表示 /hello

Module

新建 src/common/module.ts 文件,写入以下代码:

typescript 复制代码
import type { ClassDecorator } from '../types';

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

Module 方法接收参数 metadata,该参数是一个对象,方法执行后返回一个类装饰器方法,将对象中的键值对作为元数据存到了所装饰的类上。

在示例项目中即把 controllers: [AppController]providers: [AppService] 存起来,其中 controllers 后续启动应用时会用到。

实现 ToyNestFactory

上面实现的所有装饰器从代码上来看都只做了一件事情,就是保存元数据,既然保存了,肯定就有要用到的地方。在上面我们一直在说 IoC 容器注入依赖,这个容器就是我们的 ToyNestFactory 类(在 Nest 中名字叫 NestFactory)。

另外,在示例项目中,是这样启动服务的:

typescript 复制代码
const app = await NestFactory.create(AppModule);
await app.listen(3000);

这意味着 NestFactory.create 方法返回了一个能够监听端口的 http 服务,而 Nest 官方底层使用的提供 http 服务的框架是 express,我们有必要先了解下怎么利用 express 启动一个最基本的 http 服务。

首先安装 express:

bash 复制代码
yarn add express

随便建个测试文件,比如我的 src/test-express.ts,写入以下代码:

typescript 复制代码
import express from 'express';

const app = express();

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

app.listen(3000);

使用 ts-node 执行该文件后,服务被启动,使用 Apifox 请求 http://localhost:3000/hello 会看到返回了 Hello World!,这就是最基本的用法。

知道了上面的用法,我们可以先初步写个 ToyNestFactoryStatic 类的雏形,新建 src/core/index.ts 文件,写入以下代码:

typescript 复制代码
import 'reflect-metadata';
import express from 'express';
import type { Express } from 'express';

class ToyNestFactoryStatic {
  private readonly app: Express;

  constructor() {
    this.app = express();
  }

  create(): Express {
    return this.app;
  }
}

export const ToyNestFactory = new ToyNestFactoryStatic();

导出的 ToyNestFactory 是经过 ToyNestFactoryStatic 实例化后得到的一个实例,所以我们可以直接调用 ToyNestFactory.create 方法。

create

之前我们存的元数据信息里,有标记为需要被注入的类的信息(布尔值)、请求的路径信息和请求方法,我们在 create 方法中要做的事由一张图说明就是:

根据上图思路,完善我们的 create 方法:

typescript 复制代码
class ToyNestFactoryStatic {
  // 其他代码...

  create(module: any): Express {
    const Controllers = Reflect.getMetadata('controllers', module);
    this.initialize(Controllers);

    return this.app;
  }

  initialize(Controllers: any[]) {}
}

因为 @Module 装饰器已经往 AppModule 类上注入了控制器的元数据,所以现在我们可以直接从它上面取出来所有 Controller,示例中其实就只有一个 AppController

然后新建了一个 initialize 方法,用于遍历 Controllers 并做上图的后续处理。

initialize

我们前面分析到,需要拿到每个 controller 的构造函数中参数,即 service 类,进行实例化后再进行注入。

比如示例项目的 AppController 依赖了 AppService,而这部分并没有存储到元数据中。但是 TypeScript 有一个优势,可以在编译时自动添加一些元数据。举个例子:

typescript 复制代码
import 'reflect-metadata';

function addBackground(target: any) {
  target.prototype.background = 'mortgage slave';
  return target;
}

class Computer {}

@addBackground
class Programmer {
  constructor(private readonly computer: Computer) {
    console.log(this.computer);
  }
}

上面这段代码,在 tsconfig.json 把编译选项 emitDecoratorMetadata 设置为 true 后,使用 tsc 编译后的代码,可以看到将构造函数中的参数类以 design:paramtypes 为键的元数据注入到了 Programmer 类上。

接下来不就好办了,直接取出来参数组成的数组,遍历分别实例化:

typescript 复制代码
initialize(Controllers: any[]) {
  Controllers.forEach((Controller) => {
    // guide 1
    const Services: any[] = Reflect.getMetadata('design:paramtypes', Controller);

    const services = Services.map((Service) => {
      // guide 2
      if (!Reflect.getMetadata(INJECTABLE_WATERMARK, Service)) {
        throw new Error(
          `${Service.name} is not injectable, check if it is decorated with @Injectable.`
        );
      }

      // guide 3
      const instance = new Service();
      return instance;
    });

    // guide 4
    const controller = new Controller(...services);

    // guide 5
    const rootPath = Reflect.getMetadata(PATH_METADATA, Controller);

    // guide 6
    this.createRoute(controller, rootPath);
  });
}

对上面代码解释下:

  1. 得益于 TypeScript 编译时会自动给类添加构造函数中的参数类型元数据,我们可以轻易取到服务类,示例中是 [AppService]
  2. 如果存在未被装饰器 @Injectable 装饰的服务类被控制器使用,直接抛错;
  3. 实例化服务类;
  4. 将服务类的实例注入给我们的控制器,从而实现了依赖注入
  5. 获取装饰器 @Controller 传入的根路径,示例项目中即 /app
  6. 注册路由信息。

这个方法的最后,我们拿到了每个控制器的实例,以及根路由信息,接下来就要在 createRoute 方法中注册路由信息,包括请求方式(比如 Get),完整路由(比如 /app/hello)和回调方法(比如 getHello)。

createRoute

typescript 复制代码
createRoute(controller: any, rootPath: string) {
  // guide 1
  const prototype = Reflect.getPrototypeOf(controller) as any;
  const allMethodNames = Reflect.ownKeys(prototype).filter((name) => name !== 'constructor');

  allMethodNames.forEach((methodName) => {
    const fn = prototype[methodName];

    // guide 2
    const method = Reflect.getMetadata(METHOD_METADATA, fn);
    const path = Reflect.getMetadata(PATH_METADATA, fn);

    // guide 3
    if (!method || !path) {
      return;
    }

    const completePath = rootPath + path;
    const lowerMethod = method.toLowerCase() as 'get' | 'post';
    const bindFn = fn.bind(controller);

    // guide 4
    this.app[lowerMethod](completePath, (req: any, res: any) => {
      res.send(bindFn(req));
    });
  });
}

对上面代码解释下:

  1. 在 JavaScript 中类中定义的方法可在实例的原型上找到,通过 Reflect 的 APIs 即可获取到所有方法名;
  2. 我们通过 @Get 装饰器已经往被装饰的方法上添加了请求方法和相对路径元数据,现在可以从它上面取出;
  3. 如果没被装饰器装饰的方法忽略;
  4. 往 express 上注册路由及执行回调方法,发送我们在控制器中实际执行的方法。

测试 Toy Nest

测试我们写完的代码很简单,把 Nest 相关方法替换掉就行,其他代码不用变:

原来的代码:

typescript 复制代码
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Injectable } from '@nestjs/common';

替换后的代码:

typescript 复制代码
import { ToyNestFactory } from './core';
import { Module, Controller,  Get, Injectable } from './common';

// 其他代码不变

启动服务后,通过 ApiFox 访问 http://localhost:3000/app/hello 返回了和使用 Nest 一样的结果。

如何启动一个 TypeScript 的 node 项目,大家可以参考我的源码看看:toy-nest

结语

希望读者在阅读完本文后,对 Nest 背后的设计原理和大致的实现方式有所认识,这对于我们开发 Nest 项目过程中是非常有利的,避免出现做了但是又不知道为什么这么做的尴尬。

如果本文对你有所帮助,不要吝啬你的 star🌟 哦,这是我的博客地址:vortesnail/blog

拜~

相关推荐
四千岁5 小时前
2026 最新版:WSL + Ubuntu 全栈开发环境,一篇搞定!
javascript·node.js
平凡之辈7 小时前
四轮分析法:Nodejs Heap Snapshot 深度分析方法论
node.js
光影少年7 小时前
如何开发一个CLI工具?
javascript·测试工具·前端框架·node.js
带娃的IT创业者9 小时前
AI 时代产品经理能取代程序员吗?一人全栈背后的残酷真相
人工智能·ai·程序员·产品经理·全栈·职业焦虑
踩着两条虫10 小时前
VTJ.PRO 在线应用开发平台的后端模块系统
后端·架构·nestjs
踩着两条虫10 小时前
VTJ.PRO 在线应用开发平台的业务模块(应用、DSL、模板、订单、智能体、技能)
后端·agent·nestjs
踩着两条虫10 小时前
VTJ.PRO 在线应用开发平台的核心模块(用户、认证、RBAC、缓存、设置)
后端·低代码·nestjs
晴天1613 小时前
Neutralinojs 核心原理解析
javascript·electron·node.js
晴天1614 小时前
【跨桌面应用开发】Neutralinojs快速入门指南
前端·javascript·electron·node.js
ybwycx14 小时前
Node.js卸载超详细步骤(附图文讲解)
node.js