5大TypeScript依赖性注入容器

1,782 阅读11分钟

作为一个以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 的任何实现都依赖于LoggerFileSystem 接口的一些实现。例如,我们可以创建一个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 类不依赖于像ConsoleLoggerLocalFileSystem 这样的实现。相反,它依赖于前述的接口,LoggerFileSystem<string>

然而,显式管理依赖关系给每个DI容器带来了问题,因为接口在运行时并不存在。

依赖关系图

任何系统的大多数可注入组件都依赖于其他组件。你应该能够在任何时候画出它们的图,而一个深思熟虑的系统的图将是无环的。根据我的经验,循环依赖是一种代码气味,而不是一种模式。

一个项目变得越复杂,依赖关系图就越复杂。换句话说,明确地管理依赖关系并不能很好地扩展。我们可以通过自动化的依赖性管理来弥补这一点,使之成为隐性的。要做到这一点,我们就需要一个DI容器。

依赖性注入容器

一个DI容器需要以下条件。

  • ConsoleLogger 类与Logger 接口的关联
  • LocalFileSystem 类与FileSystem<string> 接口的关联
  • SettingsTxtServiceLoggerFileSystem<string> 接口的依赖性。

类型绑定

在运行时将一个特定的类型或类绑定到一个特定的接口,可以通过两种方式进行。

  • 指定一个名称或标记,将实现与它绑定在一起
  • 将一个接口推广到一个抽象类,并允许后者留下运行时的痕迹

例如,我们可以明确说明ConsoleLogger 类与使用容器的API的logger token有关。另外,我们可以使用一个接受令牌名称作为参数的类级装饰器。然后,装饰器将使用容器的 API 来注册绑定。

如果Logger 接口成为一个抽象类,我们可以对它和它的所有派生类应用一个类级装饰器。这样做时,装饰器将调用容器的API来跟踪运行时的关联。

解决依赖关系

在运行时解决依赖关系有两种方式。

  • 在构建对象时传递所有的依赖关系
  • 在构建对象后使用设置器和获取器传递所有的依赖关系

我们将专注于第一种方式。一个DI容器负责实例化和维护每个组件的生命周期。因此,容器需要知道在哪里注入依赖关系。

我们有两种方法来提供这种信息。

  1. 使用能够调用DI容器的API的构造参数装饰器
  2. 直接使用 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();
    }
}

TypedInjectLoggerTypedInjectFileSystem 类作为所需接口的具体实现。类型绑定是通过使用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 的地图,其中包含了我们以后用于注入的所有令牌。我实现了必要的接口,为每个接口添加了类级装饰器@injectableInversifySettingsTxtService 构造函数的参数使用@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.