Balanceamento de injeção de dependência com design de API pública

13

Estive pensando em como equilibrar o design testável usando injeção de dependência com o fornecimento de API pública fixa e simples. Meu dilema é: as pessoas gostariam de fazer algo assim var server = new Server(){ ... }e não precisavam se preocupar em criar as muitas dependências e o gráfico das dependências que Server(,,,,,,)podem ter. Durante o desenvolvimento, não me preocupo muito, pois uso uma estrutura de IoC / DI para lidar com tudo isso (não estou usando os aspectos de gerenciamento do ciclo de vida de nenhum contêiner, o que complicaria ainda mais as coisas).

Agora, é improvável que as dependências sejam reimplementadas. Neste caso, a componente é quase puramente testável (e com um design decente!), Em vez de criar costuras para extensão, etc. As pessoas 99.999% do tempo desejam usar uma configuração padrão. Então. Eu poderia codificar as dependências. Não quero fazer isso, perdemos nossos testes! Eu poderia fornecer um construtor padrão com dependências codificadas e uma que aceita dependências. Isso é ... bagunçado, e provavelmente confuso, mas viável. Eu poderia tornar a dependência do construtor interno e tornar minha unidade testa um assembly amigo (assumindo C #), que arruma a API pública, mas deixa uma armadilha oculta desagradável à espreita para manutenção. Ter dois construtores que estão implicitamente conectados e não explicitamente seria um projeto ruim em geral no meu livro.

No momento, esse é o mínimo mal que posso pensar. Opiniões? Sabedoria?

kolektiv
fonte

Respostas:

11

A injeção de dependência é um padrão poderoso quando bem utilizada, mas muitas vezes seus praticantes se tornam dependentes de alguma estrutura externa. A API resultante é bastante dolorosa para aqueles de nós que não querem amarrar livremente nosso aplicativo com fita adesiva XML. Não se esqueça dos objetos antigos simples (POO).

Primeiro, considere como você esperaria que alguém usasse sua API - sem a estrutura envolvida.

  • Como você instancia o servidor?
  • Como você estende o servidor?
  • O que você deseja expor na API externa?
  • O que você deseja ocultar na API externa?

Gosto da ideia de fornecer implementações padrão para seus componentes e do fato de você ter esses componentes. A abordagem a seguir é uma prática OO perfeitamente válida e decente:

public Server() 
    : this(new HttpListener(80), new HttpResponder())
{}

public Server(Listener listener, Responder responder)
{
    // ...
}

Contanto que você documente quais são as implementações padrão para os componentes nos documentos da API e forneça um construtor que execute toda a configuração, você deverá ficar bem. Os construtores em cascata não são frágeis desde que o código de configuração ocorra em um construtor principal.

Essencialmente, todos os construtores voltados para o público teriam as diferentes combinações de componentes que você deseja expor. Internamente, eles preenchiam os padrões e adiam para um construtor privado que conclui a instalação. Em essência, isso fornece um pouco de SECO e é muito fácil de testar.

Se você precisar fornecer friendacesso aos seus testes para configurar o objeto Server para isolá-lo para teste, faça-o. Não fará parte da API pública, com a qual você deseja ter cuidado.

Seja gentil com seus consumidores de API e não exija uma estrutura de IoC / DI para usar sua biblioteca. Para sentir a dor que está causando, verifique se os testes de unidade também não contam com a estrutura de IoC / DI. (É uma irritação pessoal, mas assim que você introduz a estrutura, não é mais um teste de unidade - ela se torna um teste de integração).

Berin Loritsch
fonte
Eu concordo e certamente não sou fã da sopa de configuração da IoC - a configuração XML não é algo que eu uso em relação à IoC. Certamente exigir o uso de IoC é exatamente o que eu estava evitando - é o tipo de suposição que um designer de API público nunca pode fazer. Obrigado pelo comentário, é ao longo das linhas que eu estava pensando e é reconfortante ter uma verificação de sanidade de vez em quando!
Kolektiv
-1 Essa resposta basicamente diz "não use injeção de dependência" e contém suposições imprecisas. No seu exemplo, se o HttpListenerconstrutor mudar, a Serverclasse também deverá mudar. Eles estão acoplados. Uma solução melhor é o que o @Winston Ewert diz abaixo, que é fornecer uma classe padrão com dependências pré-especificadas, fora da API da biblioteca. Então o programador não é obrigado a configurar todas as dependências desde que você toma a decisão por ele, mas ele ainda tem flexibilidade para alterá-las posteriormente. Isso é importante quando você tem dependências de terceiros.
M. Dudley
FWIW, o exemplo fornecido é chamado de "injeção bastarda". stackoverflow.com/q/2045904/111327 stackoverflow.com/q/6733667/111327
M. Dudley
1
@ MichaelDudley, você leu a pergunta do OP. Todas as "premissas" foram baseadas nas informações fornecidas pelo OP. Por um tempo, a "injeção bastarda", como você a chamou, foi o formato de injeção de dependência preferido - especialmente para sistemas cujas composições não mudam durante o tempo de execução. Setters e getters também são outra forma de injeção de dependência. Simplesmente usei exemplos com base no que o OP forneceu.
Berin Loritsch 22/03
4

Em Java, nesses casos, é comum ter uma configuração "padrão" criada por uma fábrica, mantida independente do servidor "com comportamento" real. Então, seu usuário escreve:

var server = DefaultServer.create();

enquanto o Serverconstrutor ainda aceita todas as suas dependências e pode ser usado para uma customização profunda.

fdreger
fonte
+1, SRP. A responsabilidade de um consultório dentário não é construir um consultório dentário. A responsabilidade de um servidor não é construir um servidor.
R. Schmitz
2

Que tal fornecer uma subclasse que forneça a "API pública"

class StandardServer : Server
{
    public StandardServer():
        this( depends1, depends2, depends3)
    {
    }
}

O usuário pode new StandardServer()e está a caminho. Eles também podem usar a classe base do servidor se quiserem ter mais controle sobre como o servidor funciona. Esta é mais ou menos a abordagem que eu uso. (Não uso uma estrutura porque ainda não entendi o ponto.)

Isso ainda expõe a API interna, mas acho que você deveria. Você dividiu seus objetos em diferentes componentes úteis, que devem funcionar independentemente. Não há como saber quando um terceiro pode querer usar um dos componentes isoladamente.

Winston Ewert
fonte
1

Eu poderia tornar a dependência do construtor interno e tornar minha unidade testa um assembly amigo (assumindo C #), que arruma a API pública, mas deixa uma armadilha oculta desagradável à espreita para manutenção.

Isso não parece uma "armadilha escondida desagradável" para mim. O construtor público deve apenas chamar o interno com as dependências "padrão". Desde que fique claro que o construtor público não deve ser modificado, tudo ficará bem.

Anon.
fonte
0

Eu concordo totalmente com a sua opinião. Não queremos poluir o limite de uso de componentes apenas para fins de teste de unidade. Esse é todo o problema da solução de teste baseada em DI.

Gostaria de sugerir a usar InjectableFactory / InjectableInstance padrão. InjectableInstance são classes de utilidade reutilizáveis ​​simples que são detentoras de valor mutável . A interface do componente contém uma referência a esse detentor de valor que foi inicializado com a implementação padrão. A interface fornecerá um método singleton get () que delega ao detentor do valor. O cliente do componente chamará o método get () em vez de new para obter uma instância de implementação para evitar dependência direta. Durante o tempo de teste, opcionalmente, podemos substituir a implementação padrão por uma simulação. A seguir, usarei um exemplo TimeProviderclasse que é uma abstração de Date () , permitindo que o teste de unidade injete e faça simulações para simular momentos diferentes. Desculpe, vou usar java aqui, pois estou mais familiarizado com java. C # deve ser semelhante.

public interface TimeProvider {
  // A mutable value holder with default implementation
  InjectableInstance<TimeProvider> instance = InjectableInstance.of(Impl.class);  
  static TimeProvider get() { return instance.get(); }  // Singleton method.

  class Impl implements TimeProvider {        // Default implementation                                    
    @Override public Date getDate() { return new Date(); }
    @Override public long getTimeMillis() { return System.currentTimeMillis(); }
  }

  class Mock implements TimeProvider {   // Mock implemention
    @Setter @Getter long timeMillis = System.currentTimeMillis();
    @Override public Date getDate() { return new Date(timeMillis); }
    public void add(long offset) { timeMillis += offset; }
  }

  Date getDate();
  long getTimeMillis();
}

// The client of TimeProvider
Order order = new Order().setCreationDate(TimeProvider.get().getDate()));

// In the unit testing
TimeProvider.Mock timeMock = new TimeProvider.Mock();
TimeProvider.instance.setInstance(timeMock);  // Inject mock implementation

O InjectableInstance é muito fácil de implementar, eu tenho uma implementação de referência em java. Para obter detalhes, consulte a publicação no meu blog Indirection Dependency with Injectable Factory

Jianwu Chen
fonte
Então ... você substitui a injeção de dependência por um singleton mutável? Isso parece horrível, como você executaria testes independentes? Você abre um novo processo para cada teste ou os força a uma sequência específica? Java não é o meu forte, entendi algo errado?
Nvoigt 04/07/19
No projeto Java maven, a instância compartilhada não é um problema, pois o mecanismo junit executará cada teste em um carregador de classes diferente por padrão. No entanto, eu encontro esse problema em algum IDE, pois ele pode reutilizar o mesmo carregador de classes. Para resolver esse problema, a classe fornece um método de redefinição a ser chamado para redefinir para o estado original após cada teste. Na prática, é uma situação rara que testes diferentes desejem usar simulações diferentes. Nós aplicamos esse padrão em projetos de larga escala há anos. Tudo funciona sem problemas, desfrutamos de código legível e limpo, sem sacrificar a portabilidade e a testabilidade.
Jianwu Chen