作为一个以Java开始职业生涯的软件开发者,我在向JavaScript过渡时遇到了一些问题。原来的环境缺乏静态类型系统,而且几乎不支持容器化依赖注入,导致我写的代码容易出现明显的错误,而且几乎无法测试。
TypeScript的编译时类型系统改变了这一切,允许复杂项目的连续开发。它使设计模式重新出现,如依赖注入、类型和在对象构建过程中正确传递依赖关系,这促进了更多的结构化编程,便于编写测试,而不需要猴子打补丁。
在这篇文章中,我们将回顾五个容器化的依赖注入工具,用于在TypeScript中编写依赖注入系统。让我们开始吧!
前提条件
要跟上这篇文章,你应该熟悉以下概念。
- 反转控制(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>
接口的依赖性。
类型绑定
在运行时将一个特定的类型或类绑定到一个特定的接口,可以通过两种方式进行。
- 指定一个名称或标记,将实现与它绑定在一起
- 将一个接口推广到一个抽象类,并允许后者留下运行时的痕迹
例如,我们可以明确说明ConsoleLogger
类与使用容器的API的logger
token有关。另外,我们可以使用一个接受令牌名称作为参数的类级装饰器。然后,装饰器将使用容器的 API 来注册绑定。
如果Logger
接口成为一个抽象类,我们可以对它和它的所有派生类应用一个类级装饰器。这样做时,装饰器将调用容器的API来跟踪运行时的关联。
解决依赖关系
在运行时解决依赖关系有两种方式。
- 在构建对象时传递所有的依赖关系
- 在构建对象后使用设置器和获取器传递所有的依赖关系
我们将专注于第一种方式。一个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。
- 基于类的注入
- 基于token的注入
基于类的注入
基于类的注入允许通过传递接口-类的关系来插入类。
@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是一种主流的编程语言,使用依赖注入等既定的设计模式可以帮助开发者从其他语言过渡到TypeScript。
The postTop 5 TypeScript dependency injection containersappeared first onLogRocket Blog.