Zenject: Como um contêiner de IoC pode matar a injeção de dependência em seu projeto

Onde começa o perigo? Suponha que você esteja firmemente determinado a desenvolver um projeto, aderindo a um conceito ou abordagem específica. Em nossa situação, isso é DI, embora a Programação Reativa, por exemplo, também esteja em seu lugar. É lógico que, para alcançar seu objetivo, você se voltará para soluções prontas (em nosso exemplo, o recipiente DI Zenject). Você se familiarizará com a documentação e começará a construir a estrutura do aplicativo usando a funcionalidade principal. Se, nos primeiros estágios do uso da solução, você não tiver sensações desagradáveis, provavelmente isso permanecerá no seu projeto por toda a vida. Ao trabalhar com as funções básicas da solução (contêiner), você pode ter dúvidas ou desejos para tornar algumas funcionalidades mais bonitas ou eficazes. Certamente, em primeiro lugar, você se voltará para os "recursos" mais avançados da solução (contêiner) para isso. E, nesse estágio, pode surgir a seguinte situação: você já conhece bem e confia na solução escolhida, devido à qual muitos podem não pensar em quão ideologicamente correto o uso de um ou outro funcional na solução pode ser ou a transição para outra solução já é bastante cara e inadequada ( por exemplo, o prazo está se aproximando). É nesse estágio que a situação mais perigosa pode surgir - a funcionalidade da solução é usada com pouco cuidado ou, em casos raros, simplesmente na máquina (sem pensar).

Quem poderia estar interessado nisso?


Este artigo será útil para aqueles que estão familiarizados com os adeptos de DI e iniciantes. Para entender o conhecimento básico suficiente sobre quais padrões são usados ​​pelo DI, o objetivo do DI e as funções que um contêiner de IoC executa. Não se trata dos meandros da implementação do Zenject, mas da aplicação de parte de sua funcionalidade. O artigo se baseia apenas na documentação oficial do Zenject e nos exemplos de código dele, bem como no livro de Mark Siman, "Dependency Injection in .NET", que é um trabalho exaustivo clássico sobre o tema da teoria da DI. Todas as citações neste artigo são trechos do livro de Mark Siman. Apesar de falarmos sobre um contêiner específico, o artigo pode ser útil para quem usa outros contêineres.

O objetivo deste artigo é mostrar como uma ferramenta cujo objetivo é ajudá-lo a implementar a DI no seu projeto pode levá-lo a uma direção completamente diferente, forçando-o a cometer erros que vinculam seu código, reduzir a testabilidade do código e geralmente privá-lo de todas as vantagens que podem oferecer você DI.

Isenção de responsabilidade : O objetivo deste artigo não é criticar o Zenject ou seus autores. O Zenject pode ser usado para o propósito a que se destina e serve como uma excelente ferramenta para implementar a DI, desde que você não use um conjunto completo de funções, tendo definido algumas limitações para si mesmo.

1. Introdução


O Zenject é um contêiner de injeção de dependência de código aberto projetado para ser usado com o mecanismo de jogo Unity3D, que funciona na maioria das plataformas suportadas pelo Unity3D. Vale ressaltar que o Zenject também pode ser usado para aplicativos C # desenvolvidos sem o Unity3D. Esse contêiner é bastante popular entre os desenvolvedores do Unity, é suportado e desenvolvido ativamente. Além disso, o Zenject possui toda a funcionalidade necessária do contêiner DI.

Eu usei o Zenject em 3 grandes projetos do Unity e também me comuniquei com um grande número de desenvolvedores que o usaram. O motivo de escrever este artigo são perguntas frequentes:

  • O uso do Zenject é uma boa solução?
  • O que há de errado com o Zenject?
  • Que dificuldades surgem ao usar o Zenject?

E também alguns projetos nos quais o uso do Zenject não levou à solução de problemas de forte conectividade de código e arquitetura malsucedida, mas, pelo contrário, exacerbaram a situação.

Vamos ver por que os desenvolvedores têm essas perguntas e problemas. Você pode responder da seguinte maneira:
Ironicamente, os próprios contêineres DI tendem a ser dependências estáveis. ... Ao decidir desenvolver seu aplicativo com base em um contêiner DI específico, você corre o risco de ficar limitado a essa opção por todo o ciclo de vida do aplicativo.
Vale a pena notar que, com o uso adequado e limitado do contêiner, mudar para o uso de outro contêiner no aplicativo (ou recusar o uso do contêiner em favor da “ implementação para os pobres ”) é bem possível e não levará muito tempo. É verdade que, em tal situação, é improvável que você precise.

Antes de começar a desmontar a funcionalidade potencialmente perigosa do Zenject, faz sentido atualizar superficialmente vários aspectos básicos do DI.

O primeiro aspecto é o objetivo dos contêineres DI. Mark Siman escreve o seguinte em seu livro sobre este assunto:
Um contêiner de DI é uma biblioteca de software que pode automatizar muitas das tarefas executadas ao montar objetos e gerenciar seu ciclo de vida.
Não espere que o contêiner de DI transforme magicamente código fortemente acoplado em código pouco acoplado. Um contêiner pode melhorar a eficiência do uso de DI, mas a ênfase no aplicativo deve ser colocada principalmente no uso de padrões e no trabalho com DI.
O segundo aspecto são os padrões de DI . Mark Siman identifica quatro padrões principais, classificados por frequência e pela necessidade de seu uso:

  1. Implementação do construtor - Como podemos garantir que a dependência necessária estará sempre disponível para a classe que está sendo desenvolvida?
  2. Implementação de propriedade - como posso ativar o DI como uma opção na classe se houver um padrão local adequado?
  3. Implementação do método - Como injetar dependências em uma classe se elas são diferentes para cada operação?
  4. Contexto do ambiente - Como podemos disponibilizar uma dependência em cada módulo sem incluir aspectos transversais do aplicativo em cada componente da API?

As perguntas indicadas ao lado do nome dos padrões descrevem completamente seu escopo. Ao mesmo tempo, o artigo não discutirá a Implementação do Construtor (já que praticamente não há queixas sobre sua implementação no Zenject) e o Contexto Ambiental (sua implementação não está no contêiner, mas você pode implementá-lo facilmente com base na funcionalidade existente).
Agora você pode ir diretamente para a funcionalidade potencialmente perigosa do Zenject.

Funcionalidade perigosa.


Propriedades do Implemento


Este é o segundo padrão de DI mais comum, após a implementação do construtor, mas é usado com muito menos frequência. Implementado no Zenject da seguinte maneira:

public class Foo { [Inject] public IBar Bar { get; private set; } } 

Além disso, o Zenject também possui um conceito como “Injeção de Campo”. Vamos ver por que em todo Zenject essa funcionalidade é a mais perigosa.

  • Um atributo é usado para mostrar ao container qual campo incorporar. Esta é uma solução completamente compreensível, do ponto de vista da simplicidade e lógica de implementação do próprio contêiner. No entanto, vemos um atributo (assim como espaço para nome) no código da classe. Ou seja, pelo menos indiretamente, mas a classe começa a saber de onde obtém a dependência. Além disso, estamos começando a apertar o código da classe no contêiner. Em outras palavras, não podemos mais nos recusar a usar o Zenject sem manipular o código da classe.
  • O próprio padrão é usado em situações em que a dependência possui um padrão local. Ou seja, essa é uma dependência opcional e, se o contêiner não puder fornecê-lo, não haverá erros no projeto e tudo funcionará. No entanto, usando o Zenject, você sempre obtém essa dependência - a dependência se torna não opcional.
  • Como a dependência nesse caso não é opcional, ela começa a estragar toda a lógica da implementação do construtor, porque apenas as dependências necessárias devem ser introduzidas lá. Ao implementar dependências não opcionais por meio de propriedades, você tem a oportunidade de criar dependências circulares no código. Eles não serão tão óbvios, porque no Zenject, a implementação do construtor é realizada primeiro e, em seguida, a implementação da propriedade, e você não receberá um aviso do contêiner.
  • O uso do contêiner de DI implica a implementação do padrão Raiz de Composição, no entanto, o uso do atributo para configurar a implementação da propriedade leva ao fato de você configurar o código não apenas na Raiz da Composição, mas também conforme necessário em cada classe.

Fábricas (e MemoryPool)


A documentação do Zenject tem uma seção inteira sobre fábricas. Essa funcionalidade é implementada no nível do próprio contêiner e também é possível criar suas próprias fábricas personalizadas. Vamos dar uma olhada no primeiro exemplo da documentação:

 public class Enemy { DiContainer Container; public Enemy(DiContainer container) { Container = container; } public void Update() { ... var player = Container.Resolve<Player>(); WalkTowards(player.Position); ... etc. } } 

Já neste exemplo, há uma violação grave da DI. Mas este é um exemplo de como fazer uma fábrica totalmente personalizada. Qual é o principal problema aqui?
Um contêiner de DI pode ser erroneamente considerado um localizador de serviço, mas deve ser usado apenas como um mecanismo para vincular gráficos de objetos. Se considerarmos o contêiner desse ponto de vista, faz sentido limitar seu uso apenas à raiz do layout. Essa abordagem tem a vantagem importante de eliminar qualquer ligação entre o contêiner e o restante do código do aplicativo.
Vejamos como as fábricas "embutidas" da Zenject funcionam. Existe uma interface IFactory para isso, cuja implementação nos leva à classe PlaceholderFactory:

  public abstract class PlaceholderFactory<TValue> : IPlaceholderFactory { [Inject] void Construct(IProvider provider, InjectContext injectContext) 

Nele, vemos o parâmetro InjectContext, que possui muitos construtores, no formato:

  public InjectContext(DiContainer container, Type memberType) : this() { Container = container; MemberType = memberType; } 

E, novamente, obtemos a transferência do próprio contêiner como uma dependência para a classe. Essa abordagem é uma violação grave da DI e uma transformação parcial do contêiner em um Localizador de Serviços.
Além disso, a desvantagem desta solução é que o contêiner é usado para criar dependências de curto prazo e deve criar apenas dependências de longo prazo.

Para evitar essas violações, os autores do contêiner podem excluir completamente a possibilidade de passar o contêiner como uma dependência para todas as classes registradas. Não seria difícil implementá-lo, uma vez que todo o contêiner funciona por meio de reflexão e análise dos parâmetros de métodos e construtores para criar e distribuir o gráfico de objetos de aplicativo.

Implementação de método


A lógica da implementação do Método no Zenject é a seguinte: primeiro, em todas as classes, o construtor é implementado, depois as propriedades são implementadas e, finalmente, o método é implementado. Considere o exemplo de implementação fornecido na documentação:

 public class Foo { [Inject] public Init(IBar bar, Qux qux) { _bar = bar; _qux = qux; } } 

Quais são as desvantagens aqui:

  • Você pode escrever qualquer número de métodos que serão implementados na estrutura de uma classe. Assim, como no caso da implementação da propriedade, temos a oportunidade de fazer o maior número possível de dependências cíclicas.
  • Como a implementação de uma propriedade, a implementação de um método é implementada por meio de um atributo, que associa seu código ao código do próprio contêiner.
  • A implementação do método no Zenject é usada apenas como uma alternativa aos construtores, o que é conveniente no caso das classes MonoBehavior, mas contradiz absolutamente a teoria descrita por Mark Siman. O exemplo clássico da implementação canônica do método pode ser considerado o uso de fábricas (métodos de fábrica).
  • Se houver vários métodos introduzidos na classe ou, além do método, também houver um construtor, as dependências necessárias para a classe serão espalhadas em locais diferentes, o que interferirá na imagem como um todo. Ou seja, se a classe 1 tiver um construtor, o número de seus parâmetros poderá mostrar claramente se há erros de design na classe e se o princípio de responsabilidade exclusiva é violado e se as dependências são dispersas por vários métodos, pelo construtor ou talvez por algumas propriedades, então a imagem não será tão óbvia quanto poderia ser.

Conclui-se que a presença dessa implementação da implementação do método no contêiner, o que contradiz a teoria da DI, não possui uma única vantagem. Com uma grande ressalva, uma vantagem só pode ser considerada a possibilidade de usar o método implementado, como um construtor para o MonoBehaviour. Mas este é um momento bastante controverso, já que, do ponto de vista da lógica do contêiner, dos padrões DI e do dispositivo de memória interna do Unity3D, todos os objetos MonoBehaviour em seu aplicativo podem ser considerados gerenciados por recursos e, nesse caso, será muito mais eficiente delegar o gerenciamento do ciclo de vida desses objetos. não um contêiner de DI, mas uma classe auxiliar (seja Wrapper, ViewModel, Fasade ou qualquer outra coisa).

Ligações Globais


Essa é uma funcionalidade auxiliar bastante conveniente que permite definir aglutinantes globais que podem viver independentemente da transição entre as cenas. Você pode ler mais na documentação . Essa funcionalidade é extremamente conveniente e bastante útil. Vale ressaltar que ele não viola os padrões e princípios da DI, mas possui uma implementação não óbvia e feia. A linha inferior é que você cria um tipo especial de pré-fabricação, anexa um script com a configuração do contêiner (Installer) e salva em uma pasta de projeto estritamente definida, sem a capacidade de mover-se para algum lugar e sem links para ele. A desvantagem desta ferramenta reside unicamente na sua implicitação. Quando se trata de instaladores comuns, tudo é bem simples: você tem um objeto no palco, o script do instalador fica nele. Se um novo desenvolvedor vier ao projeto, o instalador se tornará um excelente ponto para imersão no projeto. Com base em um único instalador, um desenvolvedor pode ter uma idéia de quais módulos um projeto consiste e como um gráfico de objetos é construído. Porém, com o uso de ligantes globais, o instalador no palco deixa de ser uma fonte suficiente dessas informações. Não há um único link para a ligação global no código de outros instaladores (presente nas cenas) e, portanto, você não vê o gráfico completo dos objetos. E somente durante a análise das classes você entende que alguns dos ligantes não são suficientes no instalador no palco. Mais uma vez, farei uma reserva de que essa desvantagem é puramente cosmética.

Identificadores


A capacidade de definir uma ligação específica para um identificador para obter uma certa dependência de um conjunto de dependências semelhantes em uma classe. Um exemplo:

 Container.Bind<IFoo>().WithId("foo").To<Foo1>().AsSingle(); Container.Bind<IFoo>().To<Foo2>().AsSingle(); public class Bar1 { [Inject(Id = "foo")] IFoo _foo; } public class Bar2 { [Inject] IFoo _foo; } 

Essa funcionalidade pode ser realmente situacionalmente útil e é uma opção adicional para a implementação de propriedades. No entanto, além da conveniência, ele herda todos os problemas identificados na seção "Propriedades de implementação", adicionando ainda mais coerência ao código, introduzindo uma certa constante que você precisa lembrar ao configurar seu código. Se você excluir acidentalmente esse identificador, poderá obter facilmente um não ativo do aplicativo em funcionamento.

Sinais e ITickable


Os sinais são análogos do mecanismo do Agregador de Eventos embutido no contêiner. A idéia de implementar essa funcionalidade é, sem dúvida, nobre, pois visa reduzir o número de conexões entre objetos que se comunicam através do mecanismo de assinatura de eventos. Um exemplo bastante volumoso pode ser encontrado na documentação , no entanto, não estará no artigo, porque a implementação específica não importa.

Suporte para a interface ITickable - substituindo os métodos padrão Update, LateUpdate e FixedUpdate no Unity, delegando chamadas para métodos de atualização de objetos com a interface ITickable no contêiner. Um exemplo também está na documentação , e sua implementação no contexto do artigo também não importa.

O problema dos sinais e do ITickable não se refere a aspectos de sua implementação, sua raiz está no uso de efeitos colaterais do contêiner. Em sua essência, o contêiner conhece quase todas as classes e suas instâncias no projeto, mas sua responsabilidade é criar um gráfico de objetos e gerenciar seu ciclo de vida. Adicionando mecanismos como Signals, ITickable, etc., adicionamos mais responsabilidades ao contêiner e cada vez mais anexamos o código do aplicativo, tornando-o a parte exclusiva e insubstituível do código, praticamente um "objeto divino".

Em vez de saída


O mais importante sobre contêineres é entender que o uso de DI é independente do uso de um contêiner de DI. Um aplicativo pode ser criado a partir de muitas classes e módulos fracamente acoplados, e nenhum desses módulos deve saber nada sobre o contêiner.
Cuidado ao usar soluções prontas para o uso (em caixa) ou pequenos plugins. Use-os pensativamente. De fato, coisas ainda mais grandiosas nas quais você confia (por exemplo, mecanismos de jogos da escala do próprio Unity3D) podem pecar com esses erros e borrões teóricos. E isso, em última análise, afetará não o trabalho da solução que você usa, mas a sustentabilidade, o trabalho e a qualidade do seu produto final. Espero que todo mundo que leu até o final, o artigo seja útil ou, pelo menos, não se arrependa pelo tempo gasto na leitura.

Source: https://habr.com/ru/post/pt420235/


All Articles