1. 前言
在《NestJS 快速入门》和《NestJS HTTP 请求与响应 》两篇文章中,主要使用了 NestJS 控制器处理简单的逻辑,对于复杂的业务逻辑,通常使用单独的 Service 服务进行处理,然后可以把 Service 作为依赖注入到 Controller 中。
2. 概念
2.1 依赖注入、控制反转、容器
何为容器?容器是可以装一些资源的。举个例子,普通的容器,生活中的容器比如保温杯,只能用来存储东西,没有更多的功能。而程序中的容器则包括数组、集合Set、Map 等。
而复杂的容器,生活中比如政府,政府管理我们的一生,生老病死和政府息息相关。
程序中复杂的容器比如NestJS 容器,它能够管理 Controller、Service 等组件,负责创建组件的对象、存储 组件的对象,还要负责 调用 组件的方法让其工作,并在一定的情况下 销毁 组件。
依赖注入(Dependency Injection)是实现控制反转的一种方式 。控制反转又是什么呢?控制反转(Inversion of Control)是指从容器中获取资源的方式跟以往有所不同。
2.2 为什么需要控制反转
2.2.1 依赖关系复杂、依赖顺序约束
后端系统中有多个对象:
- Controller 对象: 处理 HTTP 请求,调用 Service,返回响应。
- Service 对象: 实现业务逻辑。
- Repository 对象: 实现对数据库的增删改查。
此外,还包括数据库链接对象 DataSource、配置对象 Config 等等。这些对象之间存在复杂的关系:
- Controller 依赖 Service 实现业务逻辑。
- Service 依赖 Repository 进行数据库操作。
- Repository 依赖 DataSource 建立连接,而 DataSource 则需要从 Config 对象获取用户名密码等信息。
这导致对象的创建变得复杂,需要理清它们之间的依赖关系,确保正确的创建顺序。例如:
js
const config = new Config({ username: 'xxx', password: 'xxx'});
const dataSource = new DataSource(config);
const repository = new Repository(dataSource);
const service = new Service(repository);
const controller = new Controller(service);
这些对象需要一系列初始化步骤后才能使用。此外,像 config、dataSource、repository、service、controller 这些对象不需要每次都新建一个,可以保持单例。在应用初始化时,需要明确依赖关系,创建对象组合,并确保单例模式,这是后端系统常见的挑战。
2.3.2 高层逻辑直接依赖低层逻辑,违反依赖倒置规范
依赖倒置: 什么是依赖倒置原则(Dependency Inversion Principle)高层模块不应该依赖底层模块,二者都应该依赖抽象(例如接口)。 抽象不应该依赖细节,细节(具体实现)应该依赖抽象。
1.举一个工厂例子,初始化时有工人、车间、工厂。
1.工厂是容器,车间是消费者,依赖工人和工人的服务,工人是依赖,是生产者。
js
// 工人
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()
2.现在要把工人制造改为机器制造,需要直接在车间把工人制造改为机器制造,麻烦。
js
// 机器
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()
3.此时考虑依赖倒置原则,通过实现生产者接口来处理(PS:这也是为什么像 Java 语言中,实现业务服务时需要定义接口类和实现类,遵循依赖倒置,方便切换不同的业务逻辑)
js
// 定义一个生产者接口
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()
4.工厂改造后,螺丝生产车间的改造变得更容易了,只需要改变其属性中所新建的遵循Producer接口的实例 即可。然而,这并没有完全改善我们与车间主任之间的关系,每次厂里在改造生产机器时我们还是需要麻烦车间主任。这是因为我们还是没有完全遵守依赖倒置原则,ScrewWorkshop 仍然依赖了 Worker/Machine的实例,只不过这种依赖相较之前少了一点罢了。
要完全遵守依赖倒置原则,需要使用控制反转 和依赖注入。
2.3 控制反转思想
2.3.1 获取资源的传统方式
- 自己做饭:买菜、洗菜、择菜、切菜、炒菜,全过程参与,费时费力,必须了解资源创建整个过程中的全部细节并熟练掌握。
- 在应用程序中,组件需要获取资源,传统的方式是主动从容器中获取所需资源,这样开发人员需要知道具体容器中特定资源的获取方式,增加了学习成本,也降低了开发效率。
2.3.2 获取资源的控制反转方式
- 点外卖:下单、等待、吃外卖,省时省力,不必关心资源创建过程的全部细节。
- 控制反转的思想改变了应用程序组件获取资源的方式,容器会主动将资源推送给需要的组件,开发人员只需要提供接收资源的方式即可,这样可以降低学习成本,提高开发效率。这种方式被称为查找的被动方式。
2.4 如何实现控制反转
起源:许多应用程序的业务逻辑实现需要两个或多个类之间的协作,这种协作使得每个对象都需要获取与其合作的对象(即其所依赖的对象的引用)。如果这种获取过程由对象自身实现,那么将导致代码高度耦合,难以维护和调试。
技术描述:
在 Class A 中,我们使用了 Class B 的对象 b。通常情况下,我们需要在 A 的代码中显式地使用 new 来创建 B 的对象。但是,如果采用依赖注入技术,A 的代码只需要定义一个 private 的 B 对象,而不需要直接 new 来获取这个对象。相反,我们可以通过相关的容器控制程序来在外部创建 B 对象,并将其注入到 A 类中的引用中。具体获取的方法以及对象被获取时的状态由配置文件(如 XML)来指定。这种方法可以使代码更加清晰和正式。
loc
也可以理解为把流程的控制从应用程序转移到框架之中 。以前,应用程序掌握整个处理流程;现在,控制权 转移到了框架,框架利用一个引擎驱动整个流程的执行,框架会以相应的形式提供一系列的扩展点,应用程序则通过定义扩展的方式实现对流程某个环节的定制。"框架Call 应用"。基于 MVC 的 web 应用程序就是如此。
实现方法:
实现控制反转主要有两种方式:依赖注入和依赖查找。两者的区别在于,前者是被动的接收对象,在类 A 的实例创建过程中即创建了依赖的 B对象,通过类型或名称来判断将不同的对象注入到不同的属性中,而后者是主动索取相应类型的对象,获得依赖对象的事件也可以在代码中自由控制。
细说:
1.依赖注入:
- 基于接口。实现特定接口以供外部容器注入所依赖类型的对象
- 基于set方法。实现特定属性的publicSet方法,来让外部容器调用传入所依赖类型的对象
- 基于构造函数。实现特定参数的构造函数,在新建对象时传入所依赖类型的对象
- 基于注解。基于Java的注解功能,在私有变量前加"@Autowired"等注解,不需要显式的定义以上三种代码,便可以让外部容器传入对应的对象。该方案相当于定义了public的set方法,但是因为没有真正的set方法,从而不会为了实现依赖注入导致暴露了不该暴露的接口(因为set方法只想让容器访问来注入而并不希望其他依赖此类的对象访问)。
2.依赖查找
依赖查找更加主动,在需要的时候通过调用框架提供的方法来获取对象,获取时需要提供相关的配置文件路径、key等信息来确定获取对象的状态。
2.4.1 工厂例子依赖注入改造
通过以上学习,现在把工厂例子代码进一步改造,将底层类的依赖,由从中间类直接引用,变为高层类在构造时的依赖注入
js
// ......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()
至此,回顾对这个车间的改造三步
- 依赖倒置: 解除ScrewWorkshop与Worker/Machine具体类之间的依赖关系,转为全部依赖Producer接口;
- 控制反转: 在Factory类中实例化ScrewWorkshop中需要使用的producer,ScrewWorkshop的对依赖项Worker/Machine的控制被反转了;
- 依赖注入: ScrewWorkshop中不关注具体producer实例的创建,而是通过构造函数constructor注入;
3. NestJS 依赖注入
在Nest的设计中遵守了控制反转的思想,使用依赖注入(包括构造函数注入、参数注入、Setter方法注入)解藕了Controller 与 Provider之间的依赖。
我们将Nest中的元素与我们自己编写的工厂进行一个类比:
- Provider & Worker/Machine:真正提供具体功能实现的低层类。
- Controller & ScrewWorkshop:调用低层类来为用户提供服务的高层类。
- Nest框架本身 & Factory:控制反转容器,对高层类和低层类统一管理,控制相关类的新建与注入,解藕了类之间的依赖。
IOC 机制是在 class 上标识哪些是可以被注入的,它的依赖是什么,然后从入口开始扫描这些对象和依赖,自动创建和组装对象。
Nest 实现了 IOC 容器,会从入口模块开始扫描,分析 Module 之间的引用关系,对象之间的依赖关系,自动把 provider 注入到目标对象。
Nest 里通过 @Controller
声明可以被注入的 controller,通过 @Injectable
声明可以被注入也可以注入别的对象的 provider,然后在 @Module
声明的模块里引入。并且Nest 还提供了 Module 和 Module 之间的 import,可以引入别的模块的 provider 来注入。
provider 一般都是用 @Injectable 修饰的 class:
在 Module 的 providers 里声明:
上面是一种简写,完整的写法是这样的
构造函数或者属性注入
异步的注入对象
通常情况下,提供者通过使用 @Injectable
声明,然后在 @Module
的 providers
数组中注册类来实现。默认的 token 是类本身,因此不需要使用 @Inject 来指定注入的 token。
但是,也可以使用字符串类型的 token,但在注入时需要单独指定 @Inject。除了可以使用 useClass 指定注入的类,还可以使用 useValue 直接指定注入的对象。如果想要动态生成对象,则可以使用 useFactory,它的参数也注入到 IOC 容器中的对象中,然后动态返回提供者的对象。如果想要为已有的 token 指定一个新的 token,可以使用 useExisting 来创建别名。通过灵活运用这些提供者类型,可以在 Nest 的 IOC 容器中注入任何对象。
4.实践
之前部门逻辑都是放在 controller 中的,现在可以把逻辑放 dept.service.ts 上来,添加 @Injectable
装饰器。
在 DeptModule 模块中的 propviders 中引入 DeptService
最后在 dep.controller 使用部门服务,通过 @Inject()
装饰器注入。
小结
本文我们学习了什么是容器、依赖注入与控制反转,掌握了 Nest 依赖注入使用,并通过部门服务例子进行演示。