Vistas tradicionais sobre inversão de controle (IoC) parecem traçar uma linha dura entre duas abordagens diferentes: o localizador de serviço e os padrões de injeção de dependência (DI).
Praticamente todos os projetos que conheço incluem uma estrutura de DI. As pessoas são atraídas por eles porque promovem um acoplamento fraco entre clientes e suas dependências (geralmente por meio de injeção de construtor) com o mínimo ou nenhum código clichê. Embora seja ótimo para um desenvolvimento rápido, algumas pessoas acham que pode dificultar o rastreamento e depuração do código. A “magia dos bastidores” geralmente é alcançada por meio da reflexão, que pode trazer todo um conjunto de novos problemas.
Neste artigo, exploraremos um padrão alternativo que é adequado para bases de código Java 8+ e Kotlin. Ele retém a maioria dos benefícios de uma estrutura de DI ao mesmo tempo em que é tão direto quanto um localizador de serviço, sem exigir ferramentas externas.
No exemplo a seguir, vamos modelar uma implementação de TV, onde diferentes fontes podem ser usadas para obter conteúdo. Precisamos construir um dispositivo que pode receber sinais de várias fontes (por exemplo, terrestre, cabo, satélite, etc.). Vamos construir a seguinte hierarquia de classes:
Agora vamos começar com uma implementação tradicional de DI, onde uma estrutura como o Spring está conectando tudo para nós:
public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println('Turning on the TV'); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf('Adjusting dish frequency to channel %d
', channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf('Changing digital signal to channel %d
', channel); } }
Notamos algumas coisas:
Começamos bem, mas percebemos que trazer uma estrutura de DI para isso pode ser um pouco exagerado. Alguns desenvolvedores relataram problemas de depuração de problemas de construção (rastreamentos de pilha longos, dependências não rastreáveis). Nosso cliente também expressou que os tempos de fabricação são um pouco mais longos do que o esperado e nosso perfilador mostra lentidão nas chamadas reflexivas.
Uma alternativa seria aplicar o padrão Service Locator. É simples, não usa reflexão e pode ser suficiente para nossa pequena base de código. Outra alternativa é deixar as classes sozinhas e escrever o código de localização da dependência em torno delas.
Depois de avaliar muitas alternativas, escolhemos implementá-lo como uma hierarquia de interfaces de provedor. Cada dependência terá um provedor associado que terá a responsabilidade exclusiva de localizar as dependências de uma classe e construir uma instância injetada. Também faremos do provedor uma interface interna para facilidade de uso. Vamos chamá-lo de injeção de Mixin porque cada provedor se mistura com outros provedores para localizar suas dependências.
Os detalhes de porque eu me conformei com essa estrutura são elaborados em Detalhes e Justificativa, mas aqui está a versão resumida:
O diagrama a seguir mostra como as dependências e os provedores interagem, e a implementação é ilustrada abaixo. Também adicionamos um método principal para demonstrar como podemos compor nossas dependências e construir um objeto de TV. Uma versão mais longa deste exemplo também pode ser encontrada neste GitHub .
public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println('Turning on the TV'); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf('Adjusting dish frequency to channel %d
', channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf('Changing digital signal to channel %d
', channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }
Algumas notas sobre este exemplo:
Nós vimos o padrão em ação e alguns dos motivos por trás dele. Você pode não estar convencido de que deve usá-lo agora, e você estaria certo; não é exatamente uma bala de prata. Pessoalmente, acredito que é superior ao padrão de localizador de serviço na maioria dos aspectos. No entanto, quando comparado aos frameworks de DI, é preciso avaliar se as vantagens superam a sobrecarga de adicionar código clichê.
Quando um provedor estende outro, as dependências são vinculadas. Isso fornece a base básica para validação estática que evita a criação de contextos inválidos.
Um dos principais pontos fracos do padrão do localizador de serviço é que você precisa chamar um GetService()
genérico | método que de alguma forma resolverá sua dependência. Em tempo de compilação, você não tem garantias de que a dependência será registrada no localizador e seu programa pode falhar em tempo de execução.
O padrão DI também não aborda isso. A resolução de dependência geralmente é feita por meio de reflexão por uma ferramenta externa que fica quase totalmente oculta do usuário, que também falha no tempo de execução se as dependências não forem atendidas. Ferramentas como CDI do IntelliJ (disponível apenas na versão paga) fornecem algum nível de verificação estática, mas apenas Punhal com seu pré-processador de anotação parece resolver esse problema por design.
Isso não é obrigatório, mas definitivamente desejado pela comunidade de desenvolvedores. Por um lado, você pode apenas olhar para o construtor e ver imediatamente as dependências da classe. Por outro lado, permite o tipo de teste de unidade que muitas pessoas aderem, que é construindo o assunto em teste com simulações de suas dependências.
Isso não quer dizer que outros padrões não sejam suportados. Na verdade, pode-se até descobrir que Mixin Injection simplifica a construção de gráficos de dependência complexos para teste porque você só precisa implementar uma classe de contexto que estenda o provedor do seu assunto. O MainContext
acima é um exemplo perfeito onde todas as interfaces têm implementações padrão, portanto, pode ter uma implementação vazia. Substituir uma dependência requer apenas a substituição de seu método de provedor.
Vejamos o seguinte teste para a aula de TV. Ele precisa instanciar uma TV, mas em vez de chamar o construtor da classe, está usando a interface TV.Provider. O TvSource.Provider não tem implementação padrão, portanto, precisamos escrevê-lo nós mesmos.
public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }
Agora vamos adicionar outra dependência à aula de TV. A dependência CathodeRayTube faz a mágica para fazer uma imagem aparecer na tela da TV. Ele é desacoplado da implementação da TV porque podemos mudar para LCD ou LED no futuro.
public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println('Beaming electrons to produce the TV image'); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
Se você fizer isso, notará que o teste que acabamos de escrever ainda compila e passa conforme o esperado. Adicionamos uma nova dependência à TV, mas também fornecemos uma implementação padrão. Isso significa que não precisamos simular se quisermos apenas usar a implementação real, e nossos testes podem criar objetos complexos com qualquer nível de granularidade simulada que desejarmos.
Isso é útil quando você deseja simular algo específico em uma hierarquia de classes complexa (por exemplo, apenas a camada de acesso ao banco de dados). O padrão permite configurar facilmente o tipo de testes sociáveis que às vezes são preferidos a testes solitários.
Independentemente de sua preferência, você pode ter certeza de que pode recorrer a qualquer forma de teste que melhor se adapte às suas necessidades em cada situação.
Como você pode ver, não há referências ou menções a componentes externos. Isso é fundamental para muitos projetos que têm tamanho ou mesmo restrições de segurança. Também ajuda com a interoperabilidade porque os frameworks não precisam se comprometer com um framework DI específico. Em Java, tem havido esforços como JSR-330 Dependency Injection para Java Standard que atenuam os problemas de compatibilidade.
As implementações do localizador de serviço geralmente não dependem de reflexão, mas as implementações de DI sim (com a notável exceção do Dagger 2). Isso tem as principais desvantagens de desacelerar a inicialização do aplicativo porque o framework precisa verificar seus módulos, resolver o gráfico de dependência, construir reflexivamente seus objetos, etc.
O Mixin Injection exige que você escreva o código para instanciar seus serviços, semelhante à etapa de registro no padrão de localizador de serviço. Esse pequeno trabalho extra remove completamente as chamadas reflexivas, tornando seu código mais rápido e direto.
Dois projetos que recentemente me chamaram a atenção e se beneficiam de evitar a reflexão são Graal’s Substrate VM e Kotlin / Native . Ambos compilam para bytecode nativo, e isso requer que o compilador saiba com antecedência sobre quaisquer chamadas reflexivas que você fará. No caso do Graal, é especificado em um Arquivo JSON que é difícil de escrever , não pode ser verificado estaticamente, não pode ser facilmente refatorado usando suas ferramentas favoritas. Usar o Mixin Injection para evitar reflexos em primeiro lugar é uma ótima maneira de obter os benefícios da compilação nativa.
Ao implementar e estender as interfaces necessárias, você constrói o gráfico de dependência uma parte de cada vez. Cada provedor fica próximo à implementação concreta, o que traz ordem e lógica ao seu programa. Este tipo de camadas será familiar se você já usou o padrão Mixin ou o padrão Bolo antes.
Neste ponto, pode valer a pena falar sobre a classe MainContext. É a raiz do gráfico de dependência e conhece o quadro geral. Esta classe inclui todas as interfaces de provedor e é a chave para habilitar verificações estáticas. Se voltarmos ao exemplo e removermos Cable.Provider de sua lista de implementos, veremos isso claramente:
o que é power pivot no excel
static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider
O que aconteceu aqui é que o aplicativo não especificou o TvSource concreto a ser usado e o compilador detectou o erro. Com localizador de serviço e DI baseado em reflexão, esse erro poderia ter passado despercebido até que o programa travasse em tempo de execução - mesmo se todos os testes de unidade fossem aprovados! Acredito que esses e outros benefícios que mostramos superam a desvantagem de escrever o clichê necessário para fazer o padrão funcionar.
Vamos voltar ao exemplo CathodeRayTube e adicionar uma dependência circular. Digamos que queremos que seja injetada uma instância de TV, então estendemos TV.Provedor:
public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
O compilador não permite herança cíclica e não podemos definir esse tipo de relacionamento. A maioria dos frameworks falham em tempo de execução quando isso acontece, e os desenvolvedores tendem a contornar isso apenas para fazer o programa funcionar. Mesmo que esse antipadrão possa ser encontrado no mundo real, geralmente é um sinal de design ruim. Quando o código falha na compilação, devemos ser encorajados a procurar soluções melhores antes que seja tarde demais para mudar.
Um dos argumentos a favor do SL em vez do DI é que ele é direto e fácil de depurar. É claro a partir dos exemplos que instanciar uma dependência será apenas uma cadeia de chamadas de método do provedor. Rastrear a origem de uma dependência é tão simples quanto entrar na chamada do método e ver onde você termina. A depuração é mais simples do que ambas as alternativas, porque você pode navegar exatamente onde as dependências são instanciadas, direto do provedor.
Um leitor atento pode ter notado que esta implementação não resolve o problema de vida útil do serviço. Todas as chamadas para métodos de provedor irão instanciar novos objetos, tornando isso semelhante a Escopo do protótipo do Spring .
Essa e outras considerações estão um pouco fora do escopo deste artigo, pois eu apenas queria apresentar a essência do padrão sem distrair os detalhes. O uso e a implementação completos em um produto, entretanto, precisam levar em consideração a solução completa com suporte vitalício.
Esteja você acostumado com estruturas de injeção de dependência ou escrevendo seus próprios localizadores de serviço, você pode querer explorar esta alternativa. Considere usar o padrão mixin que acabamos de ver e veja se você pode tornar seu código mais seguro e fácil de raciocinar.
Relacionado: Práticas recomendadas de JS: construir um bot Discord com TypeScript e injeção de dependênciaFalamos sobre inversão de controle quando um trecho de código é capaz de delegar comportamento a outro componente que não é conhecido no momento da escrita desse código (geralmente por meio de interfaces e pontos de extensão bem conhecidos).
A injeção de dependência é um padrão pelo qual podemos alcançar a inversão de controle no nível de componente de um sistema. Uma classe declara apenas os componentes de que precisa para cumprir seu objetivo, não como encontrar ou criar esses componentes.