1、Nest.js 概述
如果要说近几年NodeJS开发者中最流行和热门的MVC框架,那非NestJS莫属了。下图是2012年至2023年Github上各个知名NodeJS Web框架的Star数趋势:
我们可以直观得看出,从2018年以来,NestJS
就异军突起,说是一骑绝尘也不为过,力压Egg
、midway
、Hapi
和Sails
,仅用两年时间就稳居最受欢迎的 NodeJS
框架至今。
官方文档:
Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素。
在底层,Nest使用强大的 HTTP Server 框架,如 Express(默认)和 Fastify。Nest 在这些框架之上提供了一定程度的抽象,同时也将其 API 直接暴露给开发人员。这样可以轻松使用每个平台的无数第三方模块。
-
NestJS 的一个核心概念是
"依赖注入"(Dependency Injection)
。这一概念背后的理念是:框架或高级的库能够为你的类创建依赖关系,并将它们放置在正确的参数位置,以创建services
、controllers
和其他提供程序的可用实例。 -
依赖注入(
DI
)是一种"控制反转"(Inversion of Control)
的实现,它是委托给库或框架级别的,而不是自己来维护。 -
本质上,装饰器只是更高级的函数,通常只是添加元数据,即提供目标类、函数或参数的额外信息的键值对,以便通过
Reflect API
读取,也就是所谓的元编程 meta
(一种程序将其他程序作为数据来操作的技术)。基于此,Nest 将编写的服务器代码视为自己的数据,这样它就能为你创建express
或fastify
实例(当然,也可以创建微服务、websocket 或 graphql 服务器)。 -
在 Nest 中,使用装饰器来创建元数据入口,以便读取和使用。这些元数据总是通过
Reflect.getMetadata(metadataKey, classInstance, methodName)
来读取。
methodName 只存在于方法装饰器中,如带有 HTTP 动词或 GraphQL 解析器的控制器方法。
Nest 最终使用的最重要的元数据是 design:paramtypes
元数据。这是一个 metadata key
,其中包含了构造函数中什么位置使用什么依赖关系的信息。如果你的应用程序中使用了路由处理程序,还可以通过 ValidationPipe
来了解路由处理程序的每个参数的类型。
需要注意的是,
design:paramtypes
元数据只有在使用tsc
或在tsconfig.json
中设置--emitDecoratorMetadata
标志,这也是某些编译工具(如 esbuild)难以编译 Nest.js 的原因。主要有以下几个原因:
- 动态导入:Nest.js 使用动态导入来处理模块和服务的注入。这意味着在运行时,Nest.js 会动态地查找和导入模块和服务。而 esbuild 是静态打包器,它在构建过程中需要知道所有的导入。这可以使动态导入变得复杂。
- 装饰器和元数据反射:Nest.js 大量使用装饰器和元数据反射,这是 TypeScript 的一部分,但 esbuild 并不完全支持。目前为止,esbuild 还没有实现装饰器和元数据反射的支持。
- Node.js 特定的 API:Nest.js 为 Node.js 开发的,使用了一些特定的 Node.js API,比如
require()
。然而,esbuild 主要是为浏览器环境设计的,可能不能完全支持所有 Node.js 的特性。- TypeScript 的高级特性:Nest.js 使用了一些 TypeScript 的高级特性,如路径别名等。虽然 esbuild 支持 TypeScript,但并不支持所有 TypeScript 的高级特性。
对于 Nest.js 项目,可能需要更多的配置和工作才能使其正常工作。这并不是说 esbuild 不能用于 Nest.js 项目,但可能需要额外的步骤和工具,如
Babel
或TypeScript
编译器,以补充 esbuild 的一些限制。
2、IoC、DI 和 AOP
2.1 IoC(控制反转)
维基百科的介绍:
IoC,控制反转(英语:Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。其中最常见的方式叫做依赖注入(Dependency Injection,简称DI),还有一种方式叫“依赖查找”(Dependency Lookup)。
简单的说IoC是一个开发代码的设计原则,DI则是实现这个设计原则的方案。如果要我用一个词来形容控制反转,那就是:控制权在框架而不是我手中。
从代码层上来讲解 IoC
,简单的说就是:
Class A
中用到了Class B
的对象b,一般情况下,需要在A的代码中显式地用 new 创建 B 的对象。- 使用
IoC
设计原则后,A 的代码只需要定义一个private
的B对象,不需要直接 new 来获得这个对象,而是通过相关的容器控制程序来将B对象在外部new出来并注入到A类里的引用中。 IoC
将采用依赖注入或依赖查找两种方案去实现
再通俗一点,就是有一个 IoC
容器管家,负责你开发的代码类的归置,你只管使用代码类,不用管它放在哪里,只需要调用即可。
2.2 DI(依赖注入)
DI(Dependency Injection)依赖注入:
- 依赖注入是被动的接收对象,在类A的实例创建过程中即创建了依赖的B对象,通过类型或名称来判断将不同的对象注入到不同的属性中
- 依赖查找是主动索取相应类型的对象,获得依赖对象的时间也可以在代码中自由控制
简单的说,就是依赖注入是将需要注入的对象完全交给框架去实现,而依赖查找则是开发者通过框架提供的方法,由自己控制需要注入的时间点。
2.3 AOP(面相切面编程)
AOP
(Aspect Oriented Programming),中文译为:面向切面编程,以下是维基百科 (opens new window)的定义:
面向切面程序设计是计算机科学中的一种程序设计思想,旨在将横切关注点与业务主体进行进一步分离,以提高程序代码的模块化程度。
AOP
主要的思想是将我们对于业务逻辑无关的一些操作,比如日志记录、性能统计、安全控制、异常处理、错误上报等,将这些操作从业务逻辑中剥离出来,将它们放在一些独立的方法中,然后如果我们对这些操作做修改的时候就可以不用影响到业务逻辑相关的代码。它主要体现了我们对代码的低耦合性的追求。
因为 NestJS 使用的 MVC 架构,所以有 AOP 的能力,其中 Nest 的实现主要包括如下五种(按执行顺序排列)
- Middleware
- Guard
- Pipe
- Interceptor
- ExceptionFilte
那我们使用 TypeScript
进行开发时,如何去实践这一种编程思想呢?答案就是装饰器(在 java 中叫做 注解)。
3、TypeScript 实现 DI
IoC 为我们提供了反转控制的方法,而依赖注入(Dependency Injection)就是 Nest 将其作为核心的方法之一。换句话说,依赖注入在 NestJS 中起着至关重要的作用。
DI 允许我们将类的创建抽象化,使其与类的实现分离,从而将领域/业务逻辑与所有其他层分离开来,使代码更易于维护。Node 中没有开箱即用的依赖注入解决方案,但我们可以借助 TypeScript 装饰器实现自己的依赖注入服务。
关于 TypeScript 中的装饰器和 reflect-metadata,前面三篇文章已经讲得比较全面了,这里大致讲下 TypeScript 装饰器怎么实现DI。因为目前这两个特性都还处于提案阶段(推荐阅读),所以需要在tsconfig.json
中开启:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
TypeScript 编译器现在可以为装饰器发出一些序列化的design-time
类型元数据。我们可以使用 reflect-metadata 包来使用这个 reflection API polyfill
:
npm install reflect-metadata
我们还需要在 index.ts 中导入 reflect-metadata:
import 'reflect-metadata';
目前只有三个 reflect-metadata
的 design keys
:
- 类型元数据使用元数据关键字
design:type
。 - 参数类型元数据使用元数据关键字
design:paramtypes
。 - 返回类型元数据使用元数据关键字
design:returntype
。
来看个简单的demo:
import "reflect-metadata";
function Log(target: any, key: string) {
const type = Reflect.getMetadata("design:type", target, key);
console.log(type.name); // Function
const paramtypes = Reflect.getMetadata("design:paramtypes", target, key);
console.log(paramtypes[0].name); // String
const returntype = Reflect.getMetadata("design:returntype", target, key);
console.log(returntype.name); // Boolean
}
class Demo {
@Log
public foo(bar: string): boolean {
return typeof bar === "string";
}
}
new Demo();
// Function
// String
// Boolean
简单来讲,依赖注入是一种由一个对象提供另一个对象的依赖关系的技术。什么意思?我们在程序中构建一个模块负责构建对象(通常称为注入器),而不是手动构建对象:
class Service1 {}
class Service2 {
constructor(service1: Service1) {}
}
class Example {
constructor(service1: Service1, service2: Service2) {}
}
要获得 Example 的实例,你需要按以下方式构建它:
const example = new Example(new Service1(), new Service2(new Service1()));
通过使用负责创建对象的注入器,你只需执行类似操作即可:
const example = Injector.resolve<Example>(Example);
现在,我们要做的就是实现我们自己的注入器,它能够通过注入所有必要的依赖关系来解析实例。但我们如何才能在运行时知道类的依赖关系呢?这就是 reflect-metadata
软件包的作用了。我们可以通过以下方式读取类构造函数的元数据:
Reflect.getMetadata('design:paramtypes', Example);
它将返回 undefined
,因为如果类没有被装饰,TypeScript 不会发出(也就是tsconfig.json中的emit
)关于类的元数据。只有在类上有装饰器的情况下,它才会发出元数据。因此,我们需要一个装饰器,以便我们的类能够发出元数据。来实现它:
import "reflect-metadata";
interface Type<T> {
new (...args: any[]): T;
}
function Injectable() {
return function <T>(target: Type<T>) {
console.log(Reflect.getMetadata("design:paramtypes", target));
};
}
Type<T>
是构造函数的通用类型。类装饰器接收类的构造函数作为目标参数。Injectable
装饰器什么也不做,只是记录它所应用的类构造函数的元数据设计:参数类型
。
回到我们的示例:在 Example 类中应用 Injectable 装饰器后,TypeScript 将发出该类的元数据:
import "reflect-metadata";
interface Type<T> {
new (...args: any[]): T;
}
function Injectable() {
return function <T>(target: Type<T>) {
console.log(Reflect.getMetadata("design:paramtypes", target));
};
}
@Injectable()
class Service1 {}
@Injectable()
class Service2 {
constructor(service1: Service1) {}
}
@Injectable()
class Example {
constructor(service1: Service1, service2: Service2) {}
}
如果我们现在再调用
Reflect.getMetadata('design:paramtypes', Example);
// [ [Function: Service1], [Function: Service2] ]
将返回依赖关系类型数组。再来看编译后的 js 代码:
("use strict");
var __decorate =
(this && this.__decorate) ||
function (decorators, target, key, desc) {
var c = arguments.length,
r =
c < 3
? target
: desc === null
? (desc = Object.getOwnPropertyDescriptor(target, key))
: desc,
d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
r = Reflect.decorate(decorators, target, key, desc);
else
for (var i = decorators.length - 1; i >= 0; i--)
if ((d = decorators[i]))
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata =
(this && this.__metadata) ||
function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function")
return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
require("reflect-metadata");
function Injectable() {
return function (target) {
console.log(Reflect.getMetadata("design:paramtypes", target));
};
}
class Service1 {}
class Service2 {
constructor(service1) {}
}
let Example = class Example {
constructor(service1, service2) {}
};
Example = __decorate(
[Injectable(), __metadata("design:paramtypes", [Service1, Service2])],
Example
);
在倒数第三行,我们可以看到在运行时,它向 Example 添加了 design:paramtypes
元数据,其值为 [Service1, Service2]
。它将此元数据存储在 WeakMap
中,键值为 target,值为数组。这样我们就能知道 Example 类构造函数接收了哪些参数。
现在,让我们来实现注入器,解决类的依赖关系:
class Injector {
private static container = new Map<string, any>();
static resolve<T>(target: Type<T>): T {
if (Injector.container.has(target.name)) {
return Injector.container.get(target.name);
}
const tokens = Reflect.getMetadata("design:paramtypes", target) || [];
const injections = tokens.map((token: Type<any>): any =>
Injector.resolve(token)
);
const instance = new target(...injections);
Injector.container.set(target.name, instance);
return instance;
}
}
我们有一个 DI 容器,其中将存储依赖关系的实例。我们的解析方法将接收类构造函数作为参数,读取依赖关系的类型并递归解析所有依赖关系。让我们回到开头的示例(现在稍作扩展),通过注入器进行解析:
import "reflect-metadata";
interface Type<T> {
new (...args: any[]): T;
}
function Injectable() {
return function <T>(target: Type<T>) {
console.log(Reflect.getMetadata("design:paramtypes", target));
};
}
@Injectable()
class Service1 {
doService1Staff() {
console.log("Service1");
}
}
@Injectable()
class Service2 {
doService2Staff() {
console.log("Service2");
}
constructor(public service1: Service1) {}
}
@Injectable()
class Example {
constructor(public service1: Service1, public service2: Service2) {}
}
class Injector {
private static container = new Map<string, any>();
static resolve<T>(target: Type<T>): T {
if (Injector.container.has(target.name)) {
return Injector.container.get(target.name);
}
const tokens = Reflect.getMetadata("design:paramtypes", target) || [];
const injections = tokens.map((token: Type<any>): any =>
Injector.resolve(token)
);
const instance = new target(...injections);
Injector.container.set(target.name, instance);
return instance;
}
}
const example = Injector.resolve(Example);
example.service1.doService1Staff();
example.service2.doService2Staff();
运行代码,控制台会输出:
[ [Function: Service1] ] [ [Function: Service1], [Function: Service2] ] Service1 Service2
可以看到,注入器成功注入了所有依赖项。Nest.js
的核心实现原理:通过装饰器给 class 或者对象添加 metadata
,并且开启 ts 的 emitDecoratorMetadata
来自动添加类型相关的 metadata
,然后运行的时候通过这些元数据来实现依赖的扫描,对象的创建等等功能。
实际上,要成为一个成熟的实现,还有很多事情要做:
- 错误处理
- 处理循环依赖关系
- 注入多个构造器标记的能力
- ...
这里只是为了搞懂DI的基本实现,所以就不扯远了。
4、Nest.js 中的 DI
DI 是使软件松散耦合的设计模式之一。它是一种隐藏对象内部结构,通过从外部而不是对象本身提供对象所需的对象来增强可重用性的设计模式。依赖注入允许你分离创建和使用对象的责任。
在 Nest.js
中,通过将 Service
DI 到 Controller
中,Controller
不需要知道 Service 的内部工作原理。我们可以从最基本的 Nest.js
初始化项目结构来理解什么叫 DI
:
# src
# ├── app.controller.spec.ts // app.controller.ts 的测试文件
# ├── app.controller.ts // 描述路由过程的文件
# ├── app.module.ts // 通过导入要在项目中使用的模块来构建应用程序
# ├── app.service.ts // app.controller.ts 中使用的业务逻辑
# └── main.ts // 首次执行代码的入口
# nestjs 以模块为单位管理每个功能。
从 app.controller.ts
看起:
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
// 接收在 AppController 的构造函数中已经实例化的应用程序。
// 现在,AppController 的 getHello() 方法可以调用 AppService 的 getHello() 方法。
// nestjs 使用 IoC 容器将实例化版本传递给 AppController。
// 实例会被缓存,因此同一个实例不会被多次实例化(=singleton)
@Get()
getHello(): string {
return this.appService.getHello();
}
}
在 app.service.ts
中:
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
在 app.module.ts
中:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [],
controllers: [AppController], // 在此写入以使用 AppController
providers: [AppService], // 在此处写入控制器将使用的服务
})
export class AppModule {}
providers 是可以注入的对象,我们可以把带有 @Injectable()
的 class 放到 Module 的 providers 里声明,因为 Nest 实现了 IOC,这样就会被它给识别到,从而实现依赖注入。
当然这是一种简写,原本是这样:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [],
controllers: [AppController],
providers: [
{
provide: AppService,
useClass: AppService,
},
],
})
export class AppModule {}
这样就实现了依赖注入,我们就可以通过 @Inject()
在其他地方使用了,比如在 Controller 里使用:
import { Controller, Get, Inject } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(@Inject(AppService) private readonly appService: AppService) {}
//或者直接写,不用构造器
// @Inject(AppService) private readonly appService: AppService
@Get()
getHello(): string {
return this.appService.getHello();
}
}
在 main.ts 中:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule); // AppModuleをinstance化して、appに代入
await app.listen(3005); // 3000はReactのデフォルトポートなので変更
}
bootstrap();
现在我们执行 npm start
启动服务,访问 localhost:3000
就会执行这个 AppController
类中的 getHello
方法了。控制器可以在不知道服务的内部实现的情况下使用 getHello
方法。而这个 getHello
方法实际上是定义在 app.service.ts
中。回到 app.controller.ts
文件,可以看到构造函数的参数签名中第一个参数 appService
是 AppService
的一个实例:
constructor(private readonly appService: AppService){}
但是在代码里并有没有看到实例化这个 AppService
的地方。这里其实是把创建这个实例对象的工作交给了 nest 框架,而不是 AppController
自己来创建这个对象,这就是所谓的控制反转
。而把创建好的 AppService
实例对象作为 AppController
实例化时的参数传给构造器就是依赖注入
了。
@Injectible
从上面的例子可以发现,在 Nest.js
中,依赖注入由 Nest.js
运行时系统处理。通过使用 @Injectible
装饰器,一个类可以成为一个提供者(可以注入到其他类中)。例如,你可以这样创建一个服务:
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
private getAllUsers() {
return this.userRepository.getAllUsers();
}
}
在这里,UserService
依赖于 UserRepository
,为了完成 getAllUsers
方法,它需要 UserRepository
的实例。在 Nest 中,这些被称为 Providers
,是可以作为依赖注入的类。如上所述,这些提供程序被添加到提供程序数组中的模块中。
NestJs 中的对象在运行时创建,并由其 DI 容器处理。一般来说,独立类(例如资源库)首先被实例化,然后是依赖于它们的类。例如,如果 A 依赖于 B,那么 B 将首先被实例化,因为 A 在创建对象时需要 B 的实例。但也可能存在循环依赖的情况,即 A 依赖于 B,而 B 也依赖于 B。NestJs 提供了两种解决此类情况的方法 —— forwardRef
和 ModuleRef
。有关它们的更多信息,可查看官方文档。
推荐阅读
- 从nest.js中了解IoC和DI的实现
- 学完这篇Nest.js 实战,还没入门的来锤我!(长文预警)
- 干货!一篇能带你搞懂前端Nest.js核心原理的文章
- 有趣的装饰器:使用 Reflect Metadata 实践依赖注入
- 控制反转和依赖注入的四个问题
- NESTJS源码精读(1): 启动与依赖注入
- Top 5 TypeScript dependency injection containers
- TypeScript Decorators: Dependency Injection
- Nest核心概念
- Nest.js 之依赖注入