随着Node.js的出现,JavaScript一举成为了一个前后端通用的语言。不过,与前端领域中借助Node.js出现了一批优秀的工程化框架如Angular、React、Vue等不同,在后端领域出现的Express、Koa等著名工具都没有能够解决一个重要的问题——架构。NestJS正是在这样的背景下出现的,它深受Angular设计思想的启发,而Angular 的很多模式又来自于 Java 中的 Spring 框架,所以我们可以说NestJS就是 Node.js版的 Spring 框架。
一、制造工厂
首先,让我们看看一个最基础的制造工厂类应该是什么样的。
// 工人
class Worker {
manualProduceScrew(){
console.log('A screw is built')
}
}
// 螺丝生产车间
class ScrewWorkshop {
private worker: Worker = new Worker()
produce(){
this.worker.manualProduceScrew()
}
}
// 工厂
class Factory {
start(){
const screwWorkshop = new ScrewWorkshop()
screwWorkshop.produce()
}
}
const factory = new Factory()
// 工厂开工啦!!!
factory.start()
在这个简化版的工厂中,我们仅设计了三个最基础的类来负责螺丝的制造工作。乍看上去这个设计并没有什么问题,当工程希望生产螺丝时,工厂直接向螺丝生产车间下达了生产指令,而螺丝生产车间进一步向工人下达了生产指令,最终螺丝被生产了出来。
然而过了一段时间后,工厂新进了一批自动化螺丝生产设备,厂长希望使用这批设备代替工人的工作从而降低生产成本,于是,我们需要对这个汽车工厂的代码进行改造!
// 机器
class Machine {
autoProduceScrew(){
console.log('A screw is built')
}
}
class ScrewWorkshop {
// 改为一个机器实例
private machine: Machine = new Machine()
produce(){
this.machine.autoProduceScrew()
}
}
class Factory {
start(){
const screwWorkshop = new ScrewWorkshop()
screwWorkshop.produce()
}
}
const factory = new Factory()
// 工厂开工啦!!!
factory.start()
在费了一番力气改造了螺丝生产车间后,螺丝又一次被生产制造了出来。但是,没过多久,厂长就发现并购入了一批价格更低、生产效率更高的机器,于是我们又一次需要改造螺丝生产车间。试想如果这样的情况不断发生,我们就需要不断花费力气改造螺丝生产车间,很快生产车间的主任将会对我们不耐烦起来。那我们有没有可能在不改变螺丝生产车间的情况下就能够替换其底层的生产方式呢?
二、工厂改造
首先,我们分析一下上面这个符合直觉的设计到底存在什么问题。Machine/Worker类是最终执行生产动作的类,他们都归属于螺丝生产车间ScrewWorkshop这个类。从这个方面来讲,Machine/Worker类应该是低层类,而ScrewWorkshop应该为高层类,工厂中的高层类依赖了低层类。因此这个工厂的设计违背了依赖倒置原则。
什么是依赖倒置原则(Dependency Inversion Principle)
High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
高层模块不应该依赖底层模块,二者都应该依赖抽象(例如接口)。
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
抽象不应该依赖细节,细节(具体实现)应该依赖抽象。
因此,我们首先应该让Machine/Worker类以及ScrewWorkshop类解藕,让螺丝生产车间为其所希望使用的低层生产方式类定义一个接口,让Machine/Worker等低层类遵循并实现这个接口。
// 定义一个生产者接口
interface Producer {
produceScrew: () => void
}
// 实现了接口的机器
class Machine implements Producer {
autoProduceScrew(){
console.log('A screw is built')
}
produceScrew(){
this.autoProduceScrew()
}
}
// 实现了接口的工人
class Worker implements Producer {
manualProduceScrew(){
console.log('A screw is built')
}
produceScrew(){
this.manualProduceScrew()
}
}
class ScrewWorkshop {
// 依赖生产者接口,可以随意切换啦!!!
// private producer: Producer = new Machine()
private producer: Producer = new Worker()
produce(){
this.producer.produceScrew()
}
}
class Factory {
start(){
const screwWorkshop = new ScrewWorkshop()
screwWorkshop.produce()
}
}
const factory = new Factory()
// 工厂开工啦!!!
factory.start()
在经过对工厂这样的一番改造后,今后螺丝生产车间改造的工作明显变得更加轻松了,只需要改变其属性中所新建的遵循Producer接口的实例即可。然而,这并没有完全改善我们与车间主任之间的关系,每次厂里在改造生产机器时我们还是需要麻烦车间主任。这是因为我们还是没有能够完全遵守依赖倒置原则,ScrewWorkshop仍然依赖了Worker/Machine的实例,只不过这种依赖相较之前少了一点罢了。
那么如何能够完全遵守这个依赖倒置原则从而摆脱这项任务呢?这时就轮到控制反转与依赖注入来帮忙啦!
什么是控制反转(Inversion Of Control)
控制反转是一种设计原则。顾名思义,它用于在面向对象设计中反转不同种类的控制以实现松耦合。在这里,控制是指一个类中除了完成其主要工作流程之外的其他所有流程,包括对应用程序流程的控制,以及对依赖对象创建和绑定流程的控制。
什么是依赖注入(Dependency Injection)
控制反转只告诉了我们需要怎么去做,但并没有告诉我们应该怎么做。所以实现控制反转的手段多种多样,其中比较流行的也是NestJS、Spring等主流框架所使用的手段就是依赖注入。
依赖注入允许在类之外创建依赖对象,并通过不同的方式将这些对象提供给类。使用依赖注入的手段,我们能够将类所依赖对象的创建和绑定移动到类自身的实现之外。
不同的方式包括:构造函数注入、属性注入、Setter方法注入、接口注入。
我不想看概念了,能简单的说一下它们到底做了什么吗?
通俗的说通过控制反转和依赖注入实现了以下功能:
如果类A需要类B,类A中并不直接控制创建类B的实例。与之相反,我们从类A外部控制类B实例的创建,类A之中只负责使用类B的实例,完全无需关心类B实例是如何创建的。
下面,我们将使用它们来对我们的工厂进行进一步的改造。
// ......Worker/Machine及其所遵循的接口Producer的实现与此前一致,此处省略
class ScrewWorkshop {
private producer: Producer
// 通过构造函数注入
constructor(producer: Producer){
this.producer = producer
}
produce(){
this.producer.produceScrew()
}
}
class Factory {
start(){
// 在Factory类中控制producer的实现,控制反转啦!!!
// const producer: Producer = new Worker()
const producer: Producer = new Machine()
// 通过构造函数注入
const screwWorkshop = new ScrewWorkshop(producer)
screwWorkshop.produce()
}
}
const factory = new Factory()
// 工厂开工啦!!!
factory.start()
三、NestJS中的”工厂“
在对这个车间的改造过程中我们都做了些什么:
- 依赖倒置: 解除ScrewWorkshop与Worker/Machine具体类之间的依赖关系,转为全部依赖Producer接口;
- 控制反转: 在Factory类中实例化ScrewWorkshop中需要使用的producer,ScrewWorkshop的对依赖项Worker/Machine的控制被反转了;
- 依赖注入: ScrewWorkshop中不关注具体producer实例的创建,而是通过构造函数constructor注入;
需要明确的是依赖倒置和控制反转都是设计原则,只是一种思想,而依赖注入DI才是是真正的实现手段。在Nest的设计中遵守了控制反转的思想,使用依赖注入(包括构造函数注入、参数注入、Setter方法注入)解藕了Controller与Provider之间的依赖。
最后,我们将NestJS中的元素与我们自己编写的工厂进行一个类比:
- Provider & Worker/Machine:真正提供具体功能实现的低层类。
- Controller & ScrewWorkshop:调用低层类来为用户提供服务的高层类。
- Nest框架本身 & Factory:控制反转容器,对高层类和低层类统一管理,控制相关类的新建与注入,解藕了类之间的依赖。
四、 NestJS的IOC与DI
在Nest中使用依赖注入一般有以下三步:
声明定义
使用@Injectable装饰器来声明一个类,它表示该类可以由Nest的IOC容器管理
// app.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello nanjiu';
}
}
声明在什么地方使用
这是依赖注入的地方,一般是在类的构造函数constructor中注入,只有完成注入后才可以使用
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/hello')
get(): string {
return this.appService.getHello();
}
}
官方把appService称为token,NestJS会根据这个token在容器中找到第1步中声明的类(这个对应关系将在第三步中进行关联注册),从而提供对应的实例,这里的实例全局唯一,只有1个!在第一次需要该实例的时候,Nest会new一个出来,而后会缓存起来,后序如果其它地方也注入了这个依赖,那Nest会从缓存中拿到之前new出来的实例供大家使用。
建立注入依赖与容器中类的联系
依赖注入后还需要在Module中进行关联
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Nest会根据所有注入的依赖关系生成一个依赖关系图,就有点类似我们使用import引入各个模块时也会生成一个复杂的依赖关系图。这里AppController中依赖了AppService,如果AppService中还依赖其它东西也会一并放到Nest构建的依赖关系图中,Nest会从下到上按照依赖顺序构建出一整张依赖关系图保证所有的依赖关系正常运作。
五、总结
依赖注入和控制反转使用面向对象编程的思维,有助于代码低耦合高内聚。
依赖注入是一种控制反转IOC(inversion of control)技术,可以把对象或依赖的实例化交给IOC容器去处理,在NestJS中这个容器就是NestJS的运行时系统。当需要一个对象实例的时候,我们不需要自己手动new xxxClass(),只需要在合适的地方对类进行注册,在需要用到的地方直接注入,容器将为我们完成new的动作。