# 一、前言
在对 Nest.js 的介绍中提到了其以控制反转(IOC)和依赖注入(DI)为其设计理念的核心思想,因此我们有必要好好讨论一下 IOC 和 DI 到底是什么东西,尤其是 它们在 TypeScript 当中到底是怎么体现的?
# 二、控制反转(IOC)
控制反转(IoC, Inversion of Control)是一种设计原则,在软件工程中用于减少代码之间的耦合度。这个原则的核心在于将传统程序中的流程控制权从程序本身转移给被调用的框架或库。
# IoC 的核心概念
- 传统的控制流:在传统的程序设计中,程序中的每一部分负责控制自己的行为。比如,一个主函数可能会依次调用不同的模块来执行任务。
- 控制反转:在控制反转中,这种流程被反转。** 不是程序代码控制每一步的流程,而是框架或库来控制它。** 程序只是提供一系列的操作或响应,而具体何时调用这些操作或响应则由框架或库来决定。
# IoC 的好处
- 降低耦合度:由于控制流程被反转,程序的各个部分不再直接相互调用,这样降低了耦合度,使得代码更加模块化。
- 增强模块的可重用性:模块不再对特定的流程或其他模块有强依赖,因此它们更容易在不同的环境或框架中重用。
- 提高可测试性:由于耦合度降低,单独测试各个模块变得更加容易。
- 更灵活的代码结构:控制反转允许开发者更灵活地组织代码结构,更容易适应变化的需求。
# IoC 在现代框架中的应用
在许多现代框架中,特别是在像 Spring、Angular、Nest.js 这样的框架中,IoC 是核心设计原则之一。这些框架通常通过依赖注入(DI)来实现 IoC。依赖注入允许框架动态地为组件提供所需的依赖,而不是由组件自己创建依赖。这种方式实际上把控制权从组件转移到了框架,体现了 IoC 的精髓。
# 三、依赖注入(DI)
依赖注入(DI, Dependency Injection)是一种软件设计模式,用于实现控制反转(IoC, Inversion of Control)。也就是说依赖注入是用于实现控制反转的一种手段。它允许将组件的依赖项(即它们所需的其他对象或服务)从外部动态地提供,而不是在组件内部创建这些依赖项。
# 依赖注入的核心概念
- 组件和依赖:在软件中,组件(如类或函数)经常依赖于其他组件来执行其功能。在没有依赖注入的情况下,组件通常自己创建或查找所需的依赖。
- 分离依赖的创建和使用:依赖注入将依赖的创建和使用分离开来。组件不再自己创建依赖项,而是从外部接收它们。这通常通过构造函数、方法参数或属性设置来实现。
# 依赖注入的好处
- 降低耦合度:由于组件不再负责创建自己的依赖,因此它们之间的耦合度降低了。这使得组件更加独立,易于管理和维护。
- 提高代码的可测试性:由于依赖从外部注入,测试时可以很容易地将真实的依赖替换为模拟对象,这大大提高了代码的可测试性。
- 提高代码的灵活性和可重用性:由于依赖是从外部传入的,所以在不同的环境中重用同一个组件变得更加容易。
- 简化了组件间的关系:依赖注入使得组件之间的关系变得更加清晰,依赖关系通常在应用程序启动时就配置好了,这使得管理大型应用程序变得更加容易。
# 依赖注入的实现方式
在许多现代编程框架中(如 Spring、Angular、Nest.js 等),依赖注入通常是核心功能之一。这些框架提供了专门的工具和机制来定义和注入依赖,比如使用装饰器、专门的配置文件或 API 来声明依赖项,以及创建和管理这些依赖项的生命周期。
# 四、TS 中使用 DI 以实现 IoC 的应用示例
首先,假定我们的程序中定义了:
- 服务接口(Service Interface):定义服务的接口。
- 具体服务(Concrete Service):实现服务接口的具体类。
- IoC 容器(IoC Container):负责创建对象实例并管理依赖注入。
假设我们有一个 Logger
服务接口和一个实现了这个接口的 ConsoleLogger
类。然后,我们创建一个 IoC 容器,用于管理这些服务的实例。
// 服务接口,定义服务的行为 | |
interface Logger { | |
log(message: string): void; | |
} | |
// 具体服务实现。类继承接口,实现接口中定义的方法 | |
class ConsoleLogger implements Logger { | |
log(message: string) { | |
console.log("Log:", message); | |
} | |
} | |
// IoC 容器 | |
class IoCContainer { | |
private static instance: IoCContainer = new IoCContainer(); // 单例模式,保证全局只有一个 IoC 实例 | |
private services: { [key: string]: any } = {}; // 存储服务的对象,可以存储多个服务 | |
private constructor() {} | |
// 获取唯一的 IoC 实例 | |
static getInstance(): IoCContainer { | |
return IoCContainer.instance; | |
} | |
// 注册服务,具体就是将服务实例存储到 services 对象中 | |
registerService<T>(name: string, instance: T) { | |
this.services[name] = instance; | |
} | |
// 根据 name 获取服务 | |
getService<T>(name: string): T { | |
const service = this.services[name]; | |
if (service) { | |
return service as T; | |
} else { | |
throw new Error(`Service ${name} not found`); | |
} | |
} | |
} | |
// 使用 IoC 容器 | |
const container = IoCContainer.getInstance(); | |
container.registerService<Logger>("logger", new ConsoleLogger()); | |
const logger = container.getService<Logger>("logger"); | |
logger.log("This is an IoC example"); |
那么上述的代码是怎么进行工作的呢?
- 定义服务:首先,定义了一个
Logger
接口和一个实现该接口的ConsoleLogger
类。 - 创建 IoC 容器:接着,创建了一个
IoCContainer
类。它是一个单例,负责注册和检索服务。通过registerService
方法,你可以注册一个服务的实例。使用getService
方法,你可以获取服务的实例。 - 使用容器:在使用 IoC 容器时,首先要获取它的实例,然后注册
ConsoleLogger
服务。之后,通过容器获取Logger
服务的实例,并使用它。
这个例子展示了 IoC 的基本原理:** 服务的创建和管理不是由服务消费者(即代码中需要服务的部分)来完成的,而是由 IoC 容器来管理。** 这样的设计降低了代码间的耦合度,并增加了灵活性。在实际的框架中,IoC 容器会更加复杂和功能丰富,但基本原理是相同的。
如果上述的代码不适用 IoC 进行统一管理,采用传统模式进行编写,将会是下面这种形式:
// 服务接口 | |
interface Logger { | |
log(message: string): void; | |
} | |
// 具体服务实现 | |
class ConsoleLogger implements Logger { | |
log(message: string) { | |
console.log("Log:", message); | |
} | |
} | |
// 直接使用具体服务 | |
class Application { | |
private logger: Logger; | |
constructor() { | |
// 直接创建 ConsoleLogger 实例,而不是通过 IoC 容器 | |
this.logger = new ConsoleLogger(); | |
} | |
run() { | |
this.logger.log("Application is running"); | |
} | |
} | |
// 创建 Application 实例并运行 | |
const app = new Application(); | |
app.run(); |
自然我们可以看出很多的端倪:
- 依赖的创建:在传统模式中,
Application
类直接负责创建它所需的ConsoleLogger
实例。这与 IoC 版本不同,在 IoC 版本中,Logger
服务是由外部(IoC 容器)注入的。 - 耦合度:在传统模式中,
Application
类与ConsoleLogger
类之间的耦合度更高。如果需要更换日志记录的实现方式,你必须修改Application
类的代码。 - 可测试性:在传统模式中,测试
Application
类时,更难将ConsoleLogger
替换为模拟对象。而在 IoC 版本中,通过容器可以很容易地注入不同的Logger
实现,例如一个用于测试的模拟对象。 - 灵活性:IoC 版本提供了更高的灵活性,因为它允许在应用程序的不同部分或不同环境中重用相同的组件,而不需要更改组件内部的实现。