Como um desenvolvedor de software que começou minha carreira com Java, tive problemas durante minha transição para JavaScript. O ambiente original não tinha um sistema de tipo estático e praticamente não tinha suporte para injeção de dependência em contêiner, fazendo com que eu escrevesse um código que estava sujeito a bugs óbvios e dificilmente testável.

O sistema de tipo de tempo de compilação do TypeScript mudou tudo, permitindo o desenvolvimento contínuo de projetos complexos. Ele possibilitou o ressurgimento de padrões de design como injeção de dependência, digitação e passagem de dependências corretamente durante a construção de objetos, o que promove uma programação mais estruturada e facilita a escrita de testes sem patching de macacos.

Neste artigo, revisaremos cinco contêineres ferramentas de injeção de dependência para escrever sistemas de injeção de dependência em TypeScript. Vamos começar!

Pré-requisitos

Para acompanhar este artigo, você deve estar familiarizado com os seguintes conceitos:

Inversão de controle (IoC): um padrão de design que estipula que os frameworks devem chamar o código do userland, em vez do código do userland chamando o código da biblioteca Injeção de dependência (DI): uma variante do IoC em que os objetos recebem outros objetos como dependências em vez de construtores ou setters Decoradores: funções que permitem a composição e são agrupáveis ​​em classes, funções , métodos, acessores, propriedades e parâmetros Metadados do decorador: uma maneira de armazenar a configuração de estruturas de linguagem em tempo de execução usando decoradores para definir destinos

Explicitamente injetando dependências

As interfaces permitem que os desenvolvedores separem os requisitos de abstração dos reais implementação, o que ajuda tremendamente na escrita de testes. Observe que as interfaces definem apenas funcionalidade, não dependências. Por fim, as interfaces não deixam rastros de tempo de execução, no entanto, as classes sim.

Vamos considerar três interfaces de exemplo:

export interface Logger {log: (s: string)=> void; } interface de exportação FileSystem {createFile (descritor: D, buffer: Buffer): Promise ; readFile (descritor: D): Promise ; updateFile (descritor: D, buffer: Buffer): Promise ; deleteFile (descritor: D): Promessa ; } exportar interface SettingsService {upsertSettings (buffer: Buffer): Promise ; readSettings (): Promessa ; deleteSettings (): Promessa ; }

A interface Logger abstrai o log síncrono, enquanto a interface FileSystem genérica abstrai as operações CRUD de arquivo. Finalmente, a interface SettingsService fornece uma abstração da lógica de negócios sobre o gerenciamento de configurações.

Podemos inferir que qualquer implementação do SettingsService depende de algumas implementações das interfaces Logger e FileSystem. Por exemplo, poderíamos criar uma classe ConsoleLogger para imprimir logs na saída do console, criar um LocalFileSystem para gerenciar os arquivos no disco local ou criar uma classe SettingsTxtService para gravar as configurações do aplicativo em um arquivo settings.txt.

Dependências podem ser passadas explicitamente usando funções especiais:

classe de exportação ConsoleLogger implementa Logger {//…} classe de exportação LocalFileSystem implementa FileSystem {//…} classe de exportação SettingsTxtService implementa SettingsService {logger protegido !: Logger; protegido fileSystem !: FileSystem ; 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);

A classe SettingsTxtService não depende de implementações como ConsoleLogger ou LocalFileSystem. Em vez disso, depende das interfaces mencionadas, Logger e FileSystem .

No entanto, o gerenciamento explícito de dependências representa um problema para cada contêiner de DI porque não existem interfaces no tempo de execução.

Gráficos de dependência

A maioria dos componentes injetáveis ​​de qualquer sistema depende de outros componentes. Você deve ser capaz de desenhar um gráfico deles a qualquer momento, e o gráfico de um sistema bem pensado será acíclico. Com base na minha experiência, as dependências cíclicas são um cheiro de código, não um padrão.

Quanto mais complexo um projeto se torna, mais complexos se tornam os gráficos de dependência. Em outras palavras, o gerenciamento explícito de dependências não tem um bom escalonamento. Podemos remediar isso automatizando o gerenciamento de dependências, o que o torna implícito. Para fazer isso, precisaremos de um contêiner DI.

Contêineres de injeção de dependência

Um contêiner DI requer o seguinte:

a associação da classe ConsoleLogger com a interface do Logger a associação da classe LocalFileSystem com a interface FileSystem a dependência de SettingsTxtService nas interfaces Logger e FileSystem

Type bindings

Vinculando um tipo específico ou classe a um específico interface em tempo de execução pode ocorrer de duas maneiras:

especificando um nome ou token que liga a implementação a ele, promovendo uma interface para uma classe abstrata e permitindo que esta deixe um rastro de tempo de execução

Por exemplo, poderíamos declarar explicitamente que a classe ConsoleLogger está associada ao token do registrador usando a API do contêiner. Como alternativa, poderíamos usar um decorador de nível de classe que aceita o nome do token como seu parâmetro. O decorador, então, usaria a API do contêiner para registrar a ligação.

Se a interface do Logger se tornasse uma classe abstrata, poderíamos aplicar um decorador de nível de classe a ela e a todas as suas classes derivadas. Ao fazer isso, os decoradores chamariam a API do contêiner para rastrear as associações em tempo de execução.

Resolvendo dependências

Resolver dependências em tempo de execução é possível de duas maneiras:

passando todos dependências durante a construção do objeto passando todas as dependências usando setters e getters após a construção do objeto

Vamos nos concentrar na primeira opção. Um contêiner DI é responsável por instanciar e manter o ciclo de vida de cada componente. Portanto, o contêiner precisa saber onde injetar dependências.

Temos duas maneiras de fornecer essas informações:

usando decoradores de parâmetro do construtor que são capazes de chamar a API do contêiner DI usando a API do contêiner DI diretamente para informá-lo sobre as dependências

Embora decoradores e metadados, como o Reflect API , são recursos experimentais, eles reduzem a sobrecarga ao usar contêineres DI.

Visão geral do contêiner de injeção de dependência

Agora, vamos examinar cinco contêineres populares para injeção de dependência. Observe que a ordem usada neste tutorial reflete como o DI evoluiu como um padrão enquanto era aplicado na comunidade TypeScript.

Typed Inject

O Typed Inject foca na segurança e clareza de tipos. Ele não usa decoradores nem metadados de decoradores, optando por declarar manualmente as dependências. Ele permite a existência de vários contêineres de DI, e as dependências são definidas como singletons ou como objetos transitórios.

O trecho de código abaixo descreve a transição do DI contextual, que foi mostrado nos trechos de código anteriores, para o Typed Inject DI:

export class TypedInjectLogger implementa Logger {//…} export class TypedInjectFileSystem implementa FileSystem {//…} export class TypedInjectSettingsTxtService extends SettingsTxtService {public static inject=[“logger”,”fileSystem”] como const; construtor (logger protegido: Logger, fileSystem protegido: FileSystem ,) {super (); }}

As classes TypedInjectLogger e TypedInjectFileSystem servem como implementações concretas das interfaces necessárias. As associações de tipo são definidas no nível da classe listando as dependências do objeto usando injetar, uma variável estática.

O fragmento de código a seguir demonstra todas as principais operações de contêiner dentro do ambiente 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);

O contêiner é instanciado usando as funções createInjector, com ligações token para classe declaradas explicitamente. Os desenvolvedores podem acessar instâncias de classes fornecidas usando a função de resolução. Classes injetáveis ​​podem ser obtidas usando o método injectClass.

InversifyJS

O projeto InversifyJS fornece um contêiner de DI leve que usa interfaces criadas por meio de tokenização. Ele usa decoradores e metadados de decoradores para injeções. No entanto, algum trabalho manual ainda é necessário para vincular implementações a interfaces.

O escopo de dependência é suportado. Os objetos podem ter o escopo como singletons ou objetos transitórios ou vinculados a uma solicitação. Os desenvolvedores podem usar contêineres DI separados, se necessário.

O trecho de código abaixo demonstra como transformar a interface DI contextual para usar InversifyJS:

export const TYPES={Logger: Symbol.for (“Logger”), FileSystem: Symbol.for (“FileSystem”), SettingsService: Symbol.for (“SettingsService”),}; @injectable () export class InversifyLogger implementa Logger {//…} @injectable () export class InversifyFileSystem implementa FileSystem {//…} @injectable () export class InversifySettingsTxtService implementa SettingsService {construtor (@inject ( TYPES.Logger) protected readonly logger: Logger, @inject (TYPES.FileSystem) protected readonly fileSystem: FileSystem ,) {//…}}

Seguindo o documentação oficial , criei um mapa chamado TYPES que contém todos os tokens que usaremos posteriormente para injeção. Implementei as interfaces necessárias, adicionando o decorador de nível de classe @injectable a cada uma. Os parâmetros do construtor InversifySettingsTxtService usam o decorador @inject, ajudando o contêiner DI a resolver dependências em tempo de execução.

O código para o contêiner DI é visto no trecho de código abaixo:

const container=novo Container (); container.bind (TYPES.Logger).to (InversifyLogger).inSingletonScope (); container.bind > (TYPES.FileSystem).to (InversifyFileSystem).inSingletonScope (); container.bind (TYPES.SettingsService).to (InversifySettingsTxtService).inSingletonScope (); const logger=container.get (TYPES.Logger); const fileSystem=container.get (TYPES.FileSystem); const settingsService=container.get (TYPES.SettingsService);

InversifyJS usa o padrão de interface fluente. O contêiner IoC atinge a vinculação de tipo entre tokens e classes, declarando-o explicitamente no código. Obter instâncias de classes gerenciadas requer apenas uma chamada com elenco adequado.

TypeDI

O TypeDI visa a simplicidade, aproveitando decoradores e metadados de decoradores. Ele suporta escopo de dependência com singletons e objetos transitórios e permite a existência de vários containers DI. Você tem duas opções para trabalhar com TypeDI:

injeções baseadas em classe injeções baseadas em token

injeções baseadas em classe

As injeções baseadas em classe permitem a inserção de classes passando classe de interface relacionamentos:

@Service ({global: true}) classe de exportação TypeDiLogger implementa Logger {} @Service ({global: true}) classe de exportação TypeDiFileSystem implementa FileSystem {} @Service ({global: true}) classe de exportação TypeDiSettingsTxtService extends SettingsTxtService {construtor (logger protegido: TypeDiLogger, protected fileSystem: TypeDiFileSystem,) {super (); }}

Cada classe usa o decorador @Service em nível de classe. A opção global significa que todas as classes serão instanciadas como singletons no escopo global. Os parâmetros do construtor da classe TypeDiSettingsTxtService declaram explicitamente que requer uma instância da classe TypeDiLogger e uma da classe TypeDiFileSystem.

Depois de declarar todas as dependências, podemos usar os contêineres TypeDI da seguinte maneira: > const container=Container.of (); const logger=container.get (TypeDiLogger); const fileSystem=container.get (TypeDiFileSystem); const settingsService=container.get (TypeDiSettingsTxtService);

Injeções baseadas em token em TypeDI

As injeções baseadas em token ligam interfaces às suas implementações usando um token como intermediário. A única mudança em comparação com as injeções baseadas em classe é declarar o token apropriado para cada parâmetro de construção usando o decorador @Inject:

@Service ({global: true}) export class TypeDiLogger estende FakeLogger {} @Service ({global: true}) export class TypeDiFileSystem estende FakeFileSystem {} @Service ({global: true}) export class ServiceNamedTypeDiSettingsTxtService estende SettingsTxtService {construtor (@Inject (“logger”) logger protegido: Logger, @Inject (“fileSystem”) protected fileSystem: FileSystem ,) {super (); }}

Temos que construir as instâncias das classes de que precisamos e conectá-las ao contêiner:

const container=Container.of (); registrador const=novo TypeDiLogger (); const fileSystem=new TypeDiFileSystem (); container.set (“logger”, logger); container.set (“fileSystem”, fileSystem); const settingsService=container.get (ServiceNamedTypeDiSettingsTxtService);

TSyringe

O projeto TSyringe é um contêiner de DI mantido pela Microsoft. É um contêiner versátil que oferece suporte a praticamente todos os recursos de contêiner DI padrão, incluindo a resolução de dependências circulares. Semelhante ao TypeDI, o TSyringe oferece suporte a injeções baseadas em classe e em token.

Injeções baseadas em classe em TSyringe

Os desenvolvedores devem marcar as classes de destino com decoradores de nível de classe de TSyringe. No snippet de código abaixo, usamos o decorador @singleton:

@singleton () classe de exportação TsyringeLogger implementa Logger {//…} @singleton () classe de exportação TsyringeFileSystem implementa FileSystem {//…} @ singleton () exportar classe TsyringeSettingsTxtService estende SettingsTxtService {construtor (logger protegido: TsyringeLogger, protegido fileSystem: TsyringeFileSystem,) {super (); }}

Os contêineres TSyringe podem então resolver dependências automaticamente:

const childContainer=container.createChildContainer (); const logger=childContainer.resolve (TsyringeLogger); const fileSystem=childContainer.resolve (TsyringeFileSystem); const settingsService=childContainer.resolve (TsyringeSettingsTxtService);

Injeções baseadas em token no TSyringe

Semelhante a outras bibliotecas, o TSyringe requer que os programadores usem decoradores de parâmetro de construtor para injeções baseadas em token:

@singleton () export class TsyringeLogger implementa Logger {//…} @singleton () exportar classe TsyringeFileSystem implementa FileSystem {//…} @singleton () exportar classe TokenedTsyringeSettingsTxtService estende SettingsTxtService {construtor (@inject (“logger”) logger protegido: Logger, @inject (“fileSystem”) fileSystem protegido: FileSystem ,) {super (); }}

Depois de declarar as classes de destino, podemos registrar tuplas de classe de token com os ciclos de vida associados. No trecho de código abaixo, estou usando um singleton:

const childContainer=container.createChildContainer (); childContainer.register (“logger”, TsyringeLogger, {lifecycle: Lifecycle.Singleton}); childContainer.register (“fileSystem”, TsyringeFileSystem, {lifecycle: Lifecycle.Singleton}); const logger=childContainer.resolve (“logger”); const fileSystem=childContainer.resolve (“fileSystem”); const settingsService=childContainer.resolve (TokenedTsyringeSettingsTxtService);

Nest.js

Nest.js é uma estrutura que usa um contêiner DI personalizado sob o capô. É possível executar Nest.js como um aplicativo independente como um wrapper sobre seu contêiner de DI. Ele usa decoradores e seus metadados para injeções. O escopo é permitido e você pode escolher entre singletons, objetos transitórios ou objetos vinculados à solicitação.

O snippet de código abaixo inclui uma demonstração dos recursos do Nest.js, começando com a declaração das classes principais:

@Injectable () export class NestLogger implementa Logger {//…} @Injectable () export class NestFileSystem estende FileSystem {//…} @Injectable () export class NestSettingsTxtService estende SettingsTxtService {construtor (registrador protegido: NestLogger, fileSystem protegido: NestFileSystem,) {super (); }}

No bloco de código acima, todas as classes de destino são marcadas com o decorador @Injectable. Em seguida, definimos o AppModule, a classe principal do aplicativo, e especificamos suas dependências, provedores:

@Module ({provedores: [NestLogger, NestFileSystem, NestSettingsTxtService],}) export class AppModule {}

Finalmente , podemos criar o contexto do aplicativo e obter as instâncias das classes mencionadas:

const applicationContext=await NestFactory.createApplicationContext (AppModule, {logger: false},); const logger=applicationContext.get (NestLogger); const fileSystem=applicationContext.get (NestFileSystem); const settingsService=applicationContext.get (NestSettingsTxtService);

Resumo

Neste tutorial, cobrimos o que é um contêiner de injeção de dependência e por que você o usaria. Em seguida, exploramos cinco contêineres de injeção de dependência diferentes para TypeScript, aprendendo a usar cada um com um exemplo.

Agora que o TypeScript é uma linguagem de programação dominante, usar padrões de design estabelecidos como injeção de dependência pode ajudar a transição de desenvolvedores de outras linguagens.