Segure o Framework – Explorando Padrões de Injeção de Dependência
Publicados: 2022-03-11Visões 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 baixo acoplamento entre clientes e suas dependências (geralmente por meio de injeção de construtor) com o mínimo ou nenhum código clichê. Embora isso seja ótimo para o desenvolvimento rápido, algumas pessoas acham que isso pode dificultar o rastreamento e a depuração do código. A “mágica nos 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, sendo tão simples quanto um localizador de serviços, sem exigir ferramentas externas.
Motivação
- Evite dependências externas
- Evite reflexão
- Promover injeção de construtor
- Minimize o comportamento do tempo de execução
Um exemplo
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 possa 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 um framework 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\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }
Notamos algumas coisas:
- A classe TV expressa uma dependência em um TvSource. Uma estrutura externa verá isso e injetará uma instância de uma implementação concreta (Terrestre ou Cabo).
- O padrão de injeção de construtor permite testes fáceis porque você pode criar facilmente instâncias de TV com implementações alternativas.
Começamos bem, mas percebemos que trazer uma estrutura de DI para isso pode ser um pouco exagerado. Alguns desenvolvedores relataram problemas ao depurar problemas de construção (long stack traces, 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. É direto, 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 de dependência em torno delas.
Após avaliar muitas alternativas, optamos por implementá-lo como uma hierarquia de interfaces de provedores. 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 facilitar o uso. Vamos chamá-lo de Mixin Injection porque cada provedor é misturado com outros provedores para localizar suas dependências.
Os detalhes de por que me decidi por essa estrutura são elaborados em Detalhes e Fundamentação, mas aqui está a versão curta:
- Ele segrega o comportamento do local de dependência.
- A extensão de interfaces não cai no problema do diamante.
- As interfaces têm implementações padrão.
- Dependências ausentes impedem a compilação (pontos de bônus!).
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 main para demonstrar como podemos compor nossas dependências e construir um objeto 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\n", 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\n", 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 observações sobre este exemplo:
- A classe TV depende de um TvSource, mas não conhece nenhuma implementação.
- O TV.Provider estende o TvSource.Provider porque precisa do método tvSource() para construir um TvSource, e pode usá-lo mesmo que não esteja implementado lá.
- As fontes Terrestre e Cabo podem ser usadas alternadamente pela TV.
- As interfaces Terrestrial.Provider e Cable.Provider fornecem implementações concretas de TvSource.
- O método main tem uma implementação concreta MainContext de TV.Provider que é usada para obter uma instância de TV.
- O programa requer uma implementação de TvSource.Provider em tempo de compilação para instanciar uma TV, então incluímos Cable.Provider como exemplo.
Detalhes e Justificativa
Vimos o padrão em ação e algumas das razões por trás dele. Você pode não estar convencido de que deveria usá-lo agora e estaria certo; não é exatamente uma bala de prata. Pessoalmente, acredito que seja superior ao padrão de localizador de serviços 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ê.
Provedores estendem outros provedores para localizar suas dependências
Quando um provedor estende outro, as dependências são vinculadas. Isso fornece a base básica para validação estática que impede a criação de contextos inválidos.
Um dos principais pontos problemáticos do padrão do localizador de serviço é que você precisa chamar um GetService<T>()
genérico 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 poderá 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 oculta principalmente do usuário, que também falha em tempo de execução se as dependências não forem atendidas. Ferramentas como o CDI do IntelliJ (disponível apenas na versão paga) fornecem algum nível de verificação estática, mas apenas o Dagger com seu pré-processador de anotação parece resolver esse problema por design.
As classes mantêm a injeção típica de construtor do padrão DI
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, ele permite o tipo de teste de unidade que muitas pessoas aderem, que é construir 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 estende o provedor do seu assunto. O MainContext
acima é um exemplo perfeito onde todas as interfaces possuem implementações padrão, então pode ter uma implementação vazia. A substituição de 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, então 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 à classe TV. A dependência CathodeRayTube funciona como mágica para fazer uma imagem aparecer na tela da TV. Ele está desacoplado da implementação da TV porque podemos querer 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 zombar de 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 aos testes solitários.
Independentemente de sua preferência, você pode ter certeza de que pode recorrer a qualquer forma de teste que melhor atenda às suas necessidades em cada situação.
Evite dependências externas
Como você pode ver, não há referências ou menções a componentes externos. Isso é fundamental para muitos projetos que têm restrições de tamanho ou até mesmo de segurança. Também ajuda na interoperabilidade porque as estruturas não precisam se comprometer com uma estrutura de DI específica. Em Java, houve esforços como JSR-330 Dependency Injection for Java Standard que atenuam problemas de compatibilidade.
Evitar reflexão
As implementações de 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 escanear 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 do localizador de serviços. Esse pequeno trabalho extra remove completamente as chamadas reflexivas, tornando seu código mais rápido e direto.
Dois projetos que recentemente chamaram minha atenção e se beneficiaram de evitar reflexão são o Substrate VM da Graal e o Kotlin/Native. Ambos compilam para bytecode nativo, e isso requer que o compilador saiba com antecedência de quaisquer chamadas reflexivas que você fará. No caso do Graal, ele é 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 Mixin Injection para evitar reflexão em primeiro lugar é uma ótima maneira de obter os benefícios da compilação nativa.
Minimizar o comportamento do tempo de execução
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 ao lado da implementação concreta, que traz ordem e lógica ao seu programa. Esse tipo de camada será familiar se você já usou o padrão Mixin ou o padrão Cake 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. Essa classe inclui todas as interfaces do provedor e é fundamental para habilitar verificações estáticas. Se voltarmos ao exemplo e removermos Cable.Provider de sua lista de implementos, veremos isso claramente:
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 capturou 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 que 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.
Capturar dependências circulares
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.Provider:
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 falha em tempo de execução quando isso acontece, e os desenvolvedores tendem a contornar isso apenas para fazer o programa rodar. Mesmo que esse antipadrão possa ser encontrado no mundo real, geralmente é um sinal de design ruim. Quando o código falha ao compilar, devemos ser encorajados a procurar soluções melhores antes que seja tarde demais para mudar.
Manter a simplicidade na construção do objeto
Um dos argumentos a favor do SL sobre DI é que ele é direto e fácil de depurar. Fica claro pelos 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 as duas alternativas porque você pode navegar exatamente onde as dependências são instanciadas, diretamente do provedor.
Vida útil do serviço
Um leitor atento pode ter notado que essa implementação não resolve o problema do tempo de vida do serviço. Todas as chamadas para métodos de provedor instanciarão novos objetos, tornando isso semelhante ao escopo Prototype do Spring.
Esta 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. No entanto, o uso e a implementação completos em um produto precisariam levar em consideração a solução completa com suporte vitalício.
Conclusão
Se você está acostumado a estruturas de injeção de dependência ou a escrever seus próprios localizadores de serviço, talvez queira explorar essa 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.