JavaScript原始环境缺乏静态类型系统,几乎不支持容器化依赖注入,导致我编写的代码容易出现明显错误且几乎无法测试。
TypeScript 的编译时类型系统改变了这一切,允许复杂项目的持续开发。它使依赖注入、在对象构建期间正确键入和传递依赖项等设计模式重新出现,这促进了更加结构化的编程,并有助于编写测试而无需打补丁。
在本文中,我们将回顾五种用于在 TypeScript 中编写依赖注入系统的容器化依赖注入工具!
先决条件
要学习本文,您应该熟悉以下概念:
- Inversion of Control (IoC):一种设计模式,规定框架应该调用用户态代码,而不是用户态代码调用库代码
- 依赖注入 (DI):IoC 的一种变体,其中对象接收其他对象作为依赖项而不是构造函数或设置器
- 装饰器:支持组合并可围绕类、函数、方法、访问器、属性和参数包装的函数
- 装饰器元数据:一种在运行时通过使用装饰器定义目标来存储语言结构配置的方法
显式注入依赖
接口允许开发人员将抽象需求与实际实现分离,这对编写测试非常有帮助。请注意,接口仅定义功能,而不定义依赖项。最后,接口不会留下运行时痕迹,但是类会。
让我们考虑三个示例接口:
export interface Logger {
log: (s: string) => void;
}
export interface FileSystem<D> {
createFile(descriptor: D, buffer: Buffer): Promise<void>;
readFile(descriptor: D): Promise<Buffer>;
updateFile(descriptor: D, buffer: Buffer): Promise<void>;
deleteFile(descriptor: D): Promise<void>;
}
export interface SettingsService {
upsertSettings(buffer: Buffer): Promise<void>;
readSettings(): Promise<Buffer>;
deleteSettings(): Promise<void>;
}
该Logger
接口抽象了同步日志记录,而通用FileSystem
接口抽象了文件 CRUD 操作。最后,该SettingsService
接口提供了对设置管理的业务逻辑抽象。
我们可以推断出SettingsService
的任何实现依赖于Logger
和FileSystem
接口的一些实现。例如,我们可以创建一个ConsoleLogger
类来将日志打印到控制台输出,创建一个LocalFileSystem
来管理本地磁盘上的文件,或者创建一个SettingsTxtService
将应用程序设置写入文件的类。settings.txt
可以使用特殊函数显式传递依赖项:
export class ConsoleLogger implements Logger {
// ...
}
export class LocalFileSystem implements FileSystem<string> {
// ...
}
export class SettingsTxtService implements SettingsService {
protected logger!: Logger;
protected fileSystem!: FileSystem<string>;
public setLogger(logger: SettingsTxtService["logger"]): void {
this.logger = logger;
}
public setFileSystem(fileSystem: SettingsTxtService["fileSystem"]): void {
this.fileSystem = fileSystem;
}
// ...
}
const logger = new ConsoleLogger();
const fileSystem = new LocalFileSystem();
const settingsService = new SettingsTxtService();
settingsService.setLogger(logger);
settingsService.setFileSystem(fileSystem);
SettingsTxtService
该类不依赖于ConsoleLogger
或之类的实现LocalFileSystem
。相反,它取决于上述接口Logger
和.FileSystem<string>
但是,显式管理依赖项对每个 DI 容器都会造成问题,因为运行时不存在接口。
依赖图
任何系统的大多数可注射组件都依赖于其他组件。您应该可以随时绘制它们的图形,而经过深思熟虑的系统的图形将是无环的。根据我的经验,循环依赖是一种代码错误,而不是一种模式。
项目越复杂,依赖关系图就越复杂。换句话说,显式管理依赖项不能很好地扩展。我们可以通过自动化依赖管理来解决这个问题,这使得它是隐式的。为此,我们需要一个 DI 容器。
依赖注入容器
DI 容器需要以下内容:
ConsoleLogger
类与Logger
接口的关联LocalFileSystem
类与接口的关联FileSystem<string>
- 的相关性
SettingsTxtService
上都Logger
和接口FileSystem<string>
类型绑定
在运行时将特定类型或类绑定到特定接口可以通过两种方式发生:
- 指定将实现绑定到它的名称或标记
- 将接口提升为抽象类并允许后者留下运行时跟踪
例如,我们可以使用容器的 API显式声明ConsoleLogger
该类与logger
令牌相关联。或者,我们可以使用接受令牌名称作为其参数的类级装饰器。然后装饰器将使用容器的 API 来注册绑定。
如果Logger
接口变成抽象类,我们可以对它及其所有派生类应用类级装饰器。这样做时,装饰器将调用容器的 API 来跟踪运行时的关联。
解决依赖关系
可以通过两种方式在运行时解析依赖项:
- 在对象构建期间传递所有依赖项
- 对象构造后使用 setter 和 getter 传递所有依赖项
我们将专注于第一个选项。DI 容器负责实例化和维护每个组件的生命周期。因此,容器需要知道在哪里注入依赖项。
我们有两种方式提供这些信息:
- 使用能够调用 DI 容器 API 的构造函数参数装饰器
- 直接使用 DI 容器的 API 来通知它依赖关系
尽管装饰器和元数据(如Reflect API)是实验性功能,但它们在使用 DI 容器时减少了开销。
依赖注入容器概述
现在,让我们看看五个流行的依赖注入容器。请注意,本教程中使用的顺序反映了 DI 在被应用到 TypeScript 社区时如何演变为一种模式。
Typed Inject
Typed Inject项目的重点是类型安全和明确性。它既不使用装饰器也不使用装饰器元数据,而是选择手动声明依赖项。它允许存在多个 DI 容器,并且依赖项被限定为单例或瞬态对象。
下面的代码片段概述了从上下文 DI(在之前的代码片段中显示)到类型化注入 DI 的转换:
export class TypedInjectLogger implements Logger {
// ...
}
export class TypedInjectFileSystem implements FileSystem<string> {
// ...
}
export class TypedInjectSettingsTxtService extends SettingsTxtService {
public static inject = ["logger", "fileSystem"] as const;
constructor(
protected logger: Logger,
protected fileSystem: FileSystem<string>,
) {
super();
}
}
在TypedInjectLogger
和TypedInjectFileSystem
类作为必需的接口的具体实现。类型绑定是在类级别通过使用inject
静态变量列出对象依赖关系来定义的。
以下代码片段演示了 Typed Inject 环境中的所有主要容器操作:
const appInjector = createInjector()
.provideClass("logger", TypedInjectLogger, Scope.Singleton)
.provideClass("fileSystem", TypedInjectFileSystem, Scope.Singleton);
const logger = appInjector.resolve("logger");
const fileSystem = appInjector.resolve("fileSystem");
const settingsService = appInjector.injectClass(TypedInjectSettingsTxtService);
使用createInjector
函数实例化容器,并显式声明令牌到类的绑定。开发人员可以使用该resolve
函数访问所提供类的实例。可以使用该injectClass
方法获得可注入的类。
InversifyJS
InversifyJS项目提供了一个轻量级的DI容器,通过符号化创建应用接口。它使用装饰器和装饰器的元数据进行注入。但是,将实现绑定到接口仍然需要一些手动工作。
支持依赖范围。对象的范围可以是单例对象或瞬态对象,也可以绑定到请求。如有必要,开发人员可以使用单独的 DI 容器。
下面的代码片段演示了如何将上下文 DI 接口转换为使用 InversifyJS:
export const TYPES = {
Logger: Symbol.for("Logger"),
FileSystem: Symbol.for("FileSystem"),
SettingsService: Symbol.for("SettingsService"),
};
@injectable()
export class InversifyLogger implements Logger {
// ...
}
@injectable()
export class InversifyFileSystem implements FileSystem<string> {
// ...
}
@injectable()
export class InversifySettingsTxtService implements SettingsService {
constructor(
@inject(TYPES.Logger) protected readonly logger: Logger,
@inject(TYPES.FileSystem) protected readonly fileSystem: FileSystem<string>,
) {
// ...
}
}
按照官方文档,我创建了一个名为的映射TYPES
,其中包含我们稍后将用于注入的所有令牌。我实现了必要的接口,@injectable
为每个接口添加了类级装饰器。InversifySettingsTxtService
构造函数的参数使用@inject
装饰器,帮助DI容器在运行时解析依赖。
DI 容器的代码见下面的代码片段:
const container = new Container();
container.bind<Logger>(TYPES.Logger).to(InversifyLogger).inSingletonScope();
container.bind<FileSystem<string>>(TYPES.FileSystem).to(InversifyFileSystem).inSingletonScope();
container.bind<SettingsService>(TYPES.SettingsService).to(InversifySettingsTxtService).inSingletonScope();
const logger = container.get<InversifyLogger>(TYPES.Logger);
const fileSystem = container.get<InversifyFileSystem>(TYPES.FileSystem);
const settingsService = container.get<SettingsTxtService>(TYPES.SettingsService);
InversifyJS 使用流畅的接口模式。IoC 容器通过在代码中显式声明来实现令牌和类之间的类型绑定。获取托管类的实例只需要一次正确转换的调用。
TypeDI
TypeDI项目旨在为简单起见,通过利用装饰和装饰的元数据。它支持单例和瞬态对象的依赖范围,并允许存在多个 DI 容器。您有两种使用 TypeDI 的选项:
- 基于类的注入
- 基于令牌的注入
基于类的注入
基于类的注入允许通过传递接口类关系来插入类:
@Service({ global: true })
export class TypeDiLogger implements Logger {}
@Service({ global: true })
export class TypeDiFileSystem implements FileSystem<string> {}
@Service({ global: true })
export class TypeDiSettingsTxtService extends SettingsTxtService {
constructor(
protected logger: TypeDiLogger,
protected fileSystem: TypeDiFileSystem,
) {
super();
}
}
每个类都使用类级@Service
装饰器。该global
选项意味着所有类都将在全局范围内实例化为单例。类的构造函数参数TypeDiSettingsTxtService
明确声明它需要一个TypeDiLogger
类的实例和一个TypeDiFileSystem
类。
一旦我们声明了所有依赖项,我们就可以使用 TypeDI 容器,如下所示:
const container = Container.of();
const logger = container.get(TypeDiLogger);
const fileSystem = container.get(TypeDiFileSystem);
const settingsService = container.get(TypeDiSettingsTxtService);
TypeDI 中基于令牌的注入
基于令牌的注入使用令牌作为中介将接口绑定到它们的实现。相比于基于类的注射剂的唯一变化是声明使用每个结构参数适当令牌 的@Inject
装饰:
@Service({ global: true })
export class TypeDiLogger extends FakeLogger {}
@Service({ global: true })
export class TypeDiFileSystem extends FakeFileSystem {}
@Service({ global: true })
export class ServiceNamedTypeDiSettingsTxtService extends SettingsTxtService {
constructor(
@Inject("logger") protected logger: Logger,
@Inject("fileSystem") protected fileSystem: FileSystem<string>,
) {
super();
}
}
我们必须构造我们需要的类的实例并将它们连接到容器:
const container = Container.of();
const logger = new TypeDiLogger();
const fileSystem = new TypeDiFileSystem();
container.set("logger", logger);
container.set("fileSystem", fileSystem);
const settingsService = container.get(ServiceNamedTypeDiSettingsTxtService);
TSyringe
TSyringe项目是由微软维护的DI容器。它是一个多功能容器,几乎支持所有标准 DI 容器功能,包括解决循环依赖。与 TypeDI 类似,TSyringe 支持基于类和基于令牌的注入。
Tsyringe 中基于类的注入
开发人员必须使用 Tsyringe 的类级装饰器标记目标类。在下面的代码片段中,我们使用了@singleton
装饰器:
@singleton()
export class TsyringeLogger implements Logger {
// ...
}
@singleton()
export class TsyringeFileSystem implements FileSystem {
// ...
}
@singleton()
export class TsyringeSettingsTxtService extends SettingsTxtService {
constructor(
protected logger: TsyringeLogger,
protected fileSystem: TsyringeFileSystem,
) {
super();
}
}
然后 Tsyringe 容器可以自动解析依赖项:
const childContainer = container.createChildContainer();
const logger = childContainer.resolve(TsyringeLogger);
const fileSystem = childContainer.resolve(TsyringeFileSystem);
const settingsService = childContainer.resolve(TsyringeSettingsTxtService);
Tsyringe 中基于令牌的注入
与其他库类似,TSyringe 要求程序员使用构造函数参数装饰器进行基于令牌的注入:
@singleton()
export class TsyringeLogger implements Logger {
// ...
}
@singleton()
export class TsyringeFileSystem implements FileSystem {
// ...
}
@singleton()
export class TokenedTsyringeSettingsTxtService extends SettingsTxtService {
constructor(
@inject("logger") protected logger: Logger,
@inject("fileSystem") protected fileSystem: FileSystem<string>,
) {
super();
}
}
声明目标类后,我们可以注册具有相关生命周期的令牌类元组。在下面的代码片段中,我使用的是单例:
const childContainer = container.createChildContainer();
childContainer.register("logger", TsyringeLogger, { lifecycle: Lifecycle.Singleton });
childContainer.register("fileSystem", TsyringeFileSystem, { lifecycle: Lifecycle.Singleton });
const logger = childContainer.resolve<FakeLogger>("logger");
const fileSystem = childContainer.resolve<FakeFileSystem>("fileSystem");
const settingsService = childContainer.resolve(TokenedTsyringeSettingsTxtService);
NestJS
NestJS是一个在底层使用自定义 DI 容器的框架。可以将 NestJS 作为独立应用程序作为其 DI 容器的包装器运行。它使用装饰器及其元数据进行注入。范围是允许的,您可以从单例、瞬态对象或请求绑定对象中进行选择。
下面的代码片段包括 NestJS 功能的演示,从声明核心类开始:
@Injectable()
export class NestLogger implements Logger {
// ...
}
@Injectable()
export class NestFileSystem extends FileSystem<string> {
// ...
}
@Injectable()
export class NestSettingsTxtService extends SettingsTxtService {
constructor(
protected logger: NestLogger,
protected fileSystem: NestFileSystem,
) {
super();
}
}
在上面的代码块中,所有目标类都标有@Injectable
装饰器。接下来,我们定义了AppModule
应用程序的核心类,并指定了它的依赖项providers
:
@Module({
providers: [NestLogger, NestFileSystem, NestSettingsTxtService],
})
export class AppModule {}
最后,我们可以创建应用程序上下文并获取上述类的实例:
const applicationContext = await NestFactory.createApplicationContext(
AppModule,
{ logger: false },
);
const logger = applicationContext.get(NestLogger);
const fileSystem = applicationContext.get(NestFileSystem);
const settingsService = applicationContext.get(NestSettingsTxtService);
概括
在本教程中,我们介绍了什么是依赖注入容器,以及为什么要使用它。然后,我们探索了五种不同的 TypeScript 依赖注入容器,并通过示例了解如何使用每种容器。
现在 TypeScript 是一种主流编程语言,使用依赖注入等既定的设计模式可以帮助开发人员从其他语言过渡。