Olá pessoal!
Finalmente, temos um contrato para atualizar o livro de Mark Siman, "
Dependency Injection in .NET " - o principal é que ele termine o mais rápido possível. Também temos um
livro no editor do respeitado Dinesh Rajput sobre padrões de design na primavera 5, onde um dos capítulos também é dedicado à implementação de dependências.
Há muito tempo procuramos um material interessante que lembre os pontos fortes do paradigma de DI e esclareça nosso interesse nele - e agora ele foi encontrado. É verdade que o autor preferiu dar exemplos no Go. Esperamos que isso não impeça que você siga seus pensamentos e ajude a entender os princípios gerais da inversão de controle e trabalhar com interfaces, se este tópico estiver próximo de você.
A coloração emocional do original é um pouco mais silenciosa, o número de pontos de exclamação na tradução é reduzido. Boa leitura!
O uso de
interfaces é uma técnica compreensível que permite criar código fácil de testar e extensível. Estive repetidamente convencido de que esta é a ferramenta de design de arquitetura mais poderosa de todas.
O objetivo deste artigo é explicar o que são interfaces, como são usadas e como fornecem extensibilidade e testabilidade de código. Por fim, o artigo deve mostrar como as interfaces podem ajudar a otimizar o gerenciamento de entrega de software e simplificar o planejamento!
InterfacesA interface descreve o contrato. Dependendo da linguagem ou estrutura, o uso de interfaces pode ser ditado explícita ou implicitamente. Portanto, na linguagem Go, as
interfaces são ditadas explicitamente . Se você tentar usar uma entidade como uma interface, mas ela não será totalmente consistente com as regras dessa interface, ocorrerá um erro em tempo de compilação. Por exemplo, executando o exemplo acima, obtemos o seguinte erro:
prog.go:22:85: cannot use BadPricer literal (type BadPricer) as type StockPricer in argument to isPricerHigherThan100: BadPricer does not implement StockPricer (missing CurrentPrice method) Program exited.
Interfaces é uma ferramenta para ajudar a desconectar o chamador do chamado, isso é feito usando um contrato.
Vamos concretizar esse problema usando um exemplo de programa para negociação automática de câmbio. O programa do trader será chamado com um preço de compra definido e um símbolo de cotação. Em seguida, o programa irá para a bolsa de valores para descobrir a cotação atual deste ticker. Além disso, se o preço de compra deste ticker não exceder o preço fixado, o programa fará uma compra.

De uma forma simplificada, a arquitetura deste programa pode ser representada da seguinte maneira. A partir do exemplo acima, fica claro que a operação de obtenção do preço atual depende diretamente do protocolo HTTP, pelo qual o programa contata o serviço de câmbio.
O estado da
Action
também depende diretamente do HTTP. Assim, ambos os estados devem entender completamente como usar o HTTP para extrair dados de troca e / ou concluir transações.
Aqui está a aparência da implementação:
func analyze(ticker string, maxTradePrice float64) (bool, err) { resp, err := http.Get( "http://stock-service.com/currentprice/" + ticker ) if err != nil {
Aqui, o chamador (
analyze
) depende diretamente do HTTP. Ela precisa saber como as solicitações HTTP são formuladas. Como é feita a análise deles? Como lidar com novas tentativas, tempos limite, autenticação, etc. Ela tem um
controle próximo do
http
.
Sempre que chamamos de análise, também devemos chamar a biblioteca http
.
Como a interface pode nos ajudar aqui? No contrato fornecido pela interface, você pode descrever o
comportamento , e não a
implementação específica.
type StockExchange interface { CurrentPrice(ticker string) float64 }
O exposto acima define o conceito do
StockExchange
. Diz aqui que o
StockExchange
suporta a chamada da única função
CurrentPrice
. Essas três linhas me parecem a técnica arquitetônica mais poderosa de todas. Eles nos ajudam a controlar as dependências de aplicativos com muito mais confiança. Fornecer testes. Forneça extensibilidade.
Injeção de DependênciaPara entender completamente o valor das interfaces, você precisa dominar a técnica chamada "injeção de dependência".
Injeção de dependência significa que o chamador fornece algo necessário ao chamador. Geralmente fica assim: o chamador configura o objeto e depois o passa para o chamado. Em seguida, a parte chamada abstrai da configuração e implementação. Nesse caso, há uma mediação conhecida. Considere uma solicitação para o serviço HTTP Rest. Para implementar o cliente, precisamos usar uma biblioteca HTTP que possa formular, enviar e receber solicitações HTTP.
Se colocarmos a solicitação HTTP atrás da interface, o chamador poderá ser desconectado e ela "não saberá" que a solicitação HTTP realmente ocorreu.
O chamador deve fazer apenas uma chamada de função genérica. Pode ser uma chamada local, uma chamada remota, uma chamada HTTP, uma chamada RPC, etc. O interlocutor não está ciente do que está acontecendo e, geralmente, combina perfeitamente com ela, desde que obtenha os resultados esperados. A seguir, mostramos como seria a injeção de dependência em nosso método de
analyze
.
func analyze(se StockExchange, ticker string, maxTradePrice float64) (bool, error) { currentPrice := se.CurrentPrice(ticker) var hasTraded bool var err error if currentPrice <= maximumTradePrice { err = doTrade(ticker, currentPrice) if err == nil { hasTraded = true } } return hasTraded, err }
Nunca deixo de me surpreender com o que está acontecendo aqui. Invertemos completamente nossa árvore de dependência e começamos a controlar melhor o programa inteiro. Além disso, mesmo visualmente toda a implementação se tornou mais limpa e compreensível. Vimos claramente que o método de análise deve escolher o preço atual, verificar se esse preço é adequado para nós e, se for o caso, fazer um acordo.
Mais importante, nesse caso, desconectamos o chamador do chamador. Como o chamador e toda a implementação são separados da chamada usando a interface, você pode estender a interface criando muitas implementações diferentes. As interfaces permitem criar muitas implementações específicas diferentes, sem a necessidade de alterar o código da parte chamada!

O status "obter preço atual" neste programa depende apenas da interface do
StockExchange
. Esta implementação não sabe
nada sobre como entrar em contato com o serviço de troca, como os preços são armazenados ou como são feitas as solicitações. Verdadeira ignorância feliz. Além disso, bilateral. A implementação
HTTPStockExchange
também não sabe nada sobre a análise. Sobre o contexto em que a análise será realizada, quando realizada - porque os desafios ocorrem indiretamente.
Como os fragmentos de programa (aqueles que dependem de interfaces) não precisam ser alterados ao alterar / adicionar / excluir implementações específicas,
esse design acaba sendo durável . Suponha que descobrimos que o
StockService
geralmente não
StockService
disponível.
Como o exemplo acima é diferente de chamar uma função? Ao aplicar uma chamada de função, a implementação também ficará mais limpa. A diferença é que, quando você chama a função, ainda precisamos recorrer ao HTTP. O método de
analyze
simplesmente delegará a tarefa da função, que deve chamar
http
, em vez de chamar o próprio
http
diretamente. Toda a força dessa técnica está na "injeção", ou seja, na medida em que o chamador fornece a interface para o chamado. É exatamente assim que ocorre a inversão de dependência, em que os preços de obtenção dependem apenas da interface e não da implementação.
Várias implementações prontas para usoNesta fase, temos a função de
analyze
e a interface
StockExchange
, mas na verdade não podemos fazer nada de útil. Acabei de anunciar nosso programa. No momento, é impossível chamá-lo, pois ainda não temos uma única implementação específica que atenda aos requisitos de nossa interface.
A ênfase principal no diagrama a seguir é feita no estado "obter preço atual" e sua dependência na interface do
StockExchange
. A seguir, mostramos como duas implementações completamente diferentes coexistem e o preço atual não é conhecido. Além disso, as duas implementações não estão relacionadas uma à outra, cada uma delas depende apenas da interface do
StockExchange
.

Produção
A implementação HTTP original já existe na implementação de
analyze
primária; tudo o que resta para nós é extraí-lo e encapsulá-lo atrás de uma implementação concreta da interface.
type HTTPStockExchange struct {} func (se HTTPStockExchange) CurrentPrice(ticker string) float64 { resp, err := http.Get( "http://stock-service.com/currentprice/" + ticker ) if err != nil {
O código que vinculamos anteriormente à função de análise agora é autônomo e satisfaz a interface do
StockExchange
, ou seja, agora podemos transmiti-lo para
analyze
. Como você se lembra dos diagramas acima, a análise não está mais associada à dependência de HTTP. Usando a interface, o
analyze
não "imagina" o que acontece nos bastidores. Ele só sabe que lhe será garantido um objeto com o qual ele pode chamar
CurrentPrice
.
Também aqui aproveitamos as virtudes típicas do encapsulamento. Antes, quando as solicitações de http eram vinculadas à análise, a única maneira de se comunicar com a troca via http era indireta - através do método de
analyze
. Sim, poderíamos encapsular essas chamadas em funções e executar a função independentemente, mas as interfaces nos forçam a desconectar o chamador do chamador. Agora podemos testar o
HTTPStockExchange
independentemente do chamador. Isso afeta fundamentalmente o escopo de nossos testes e como entendemos e respondemos a falhas de teste.
TesteNo código existente, temos a estrutura
HTTPStockService
, que nos permite garantir separadamente que ele pode se comunicar com o serviço de troca e analisar as respostas recebidas dele. Mas agora vamos garantir que a análise possa manipular corretamente a resposta da interface do
StockExchange
, além disso, que essa operação seja confiável e reproduzível.
currentPrice := se.CurrentPrice(ticker) if currentPrice <= maxTradePrice { err := doTrade(ticker, currentPrice) }
Nós poderíamos usar a implementação com HTTP, mas isso teria muitas desvantagens. Fazer chamadas de rede no teste de unidade pode ser lento, especialmente para serviços externos. Devido a atrasos e uma conexão de rede instável, os testes podem não ser confiáveis. Além disso, se precisarmos de testes com a declaração de que podemos concluir a transação e testes com a declaração de que podemos filtrar os casos em que a transação NÃO deve ser concluída, seria difícil encontrar dados reais de produção que atendam de maneira confiável a ambos. condições. Pode-se escolher
maxTradePrice
, imitando artificialmente cada uma das condições dessa maneira, por exemplo, com
maxTradePrice := -100
transação não deve ser concluída e
maxTradePrice := 10000000
obviamente deve terminar com a transação.
Mas o que acontece se uma certa cota nos é alocada no serviço de câmbio? Ou se tivermos que pagar acesso? Nós realmente (e devemos) pagar ou gastar nossa cota quando se trata de testes de unidade? Idealmente, os testes devem ser executados o mais rápido possível, para que sejam rápidos, baratos e confiáveis. Eu acho que, a partir deste parágrafo, fica claro por que usar uma versão com HTTP puro é irracional em termos de teste!
Existe uma maneira melhor, e envolve o uso de interfaces!Tendo uma interface, você pode fabricar cuidadosamente a implementação do
StockExchange
, o que nos permitirá
analyze
rapidez, segurança e confiabilidade.
type StubExchange struct { Price float64 } func (se StubExchange) CurrentPrice(ticker string) float64 { return se.Price } func TestAnalyze_MakeTrade(t *testing.T) { se := StubExchange{Price: 10} maxTradePrice := 11 traded, err := analyze(se, "TSLA", maxTradePrice) if err != nil { t.Errorf("expected err == nil received: %s", err) } if !traded { t.Error("expected traded == true") } } func TestAnalyze_DontTrade(t *testing.T) { se := StubExchange{Price: 10} maxTradePrice := 9 traded, err := analyze(se, "TSLA", maxTradePrice)
O stub do serviço de câmbio é usado acima, graças ao qual o ramo de interesse para nós na
analyze
é lançado. Em seguida, são feitas declarações em cada um dos testes para garantir que a análise faça o que é necessário. Embora este seja um programa de teste, minha experiência sugere que componentes / arquitetura, onde as interfaces são usadas aproximadamente dessa maneira, também são testados quanto à durabilidade no código de batalha !!! Graças às interfaces, podemos usar o
StockExchange
controlado em memória, que fornece testes confiáveis, facilmente configuráveis, fáceis de entender, reprodutíveis e rápidos!
Soltar - Configuração do chamadorAgora que discutimos como usar interfaces para desconectar o chamador do chamado e como realizar várias implementações, ainda não abordamos um aspecto crítico. Como configurar e fornecer uma implementação específica em um horário estritamente definido? Você pode chamar diretamente a função de análise, mas o que fazer na configuração de produção?
É aqui que a implementação de dependências é útil.
func main() { var ticker = flag.String("ticker", "", "stock ticker symbol to trade for") var maxTradePrice = flag.Float64("maxtradeprice", "", "max price to pay for a share of the ticker symbol." se := HTTPStockExchange{} analyze(se, *ticker, *maxTradePrice) }
Assim como no nosso caso de teste, a implementação concreta específica do StockExchange que será usada com a
analyze
é configurada pelo chamador fora da análise. Então é passado (injetado) para
analyze
. Isso garante que a análise de nada se saiba sobre como o
HTTPStockExchange
configurado. Talvez desejemos fornecer o domínio http que usaremos na forma de um sinalizador de linha de comando e, em seguida, a análise não precisará ser alterada. Ou o que fazer se precisarmos fornecer algum tipo de autenticação ou token para acessar o
HTTPStockExchange
, que será extraído do ambiente? Mais uma vez, a análise não deve mudar.
A configuração ocorre em um nível fora da
analyze
, liberando completamente a análise da necessidade de configurar suas próprias dependências. Assim, é alcançada uma separação estrita de tarefas.
Decisões sobre prateleirasTalvez os exemplos acima sejam suficientes, mas ainda existem muitas outras vantagens em interfaces e injeção de dependência. As interfaces permitem adiar decisões sobre implementações específicas. Embora as decisões exijam que decidamos qual comportamento apoiaremos, elas ainda nos permitem tomar decisões sobre implementações específicas posteriormente. Suponhamos que sabíamos que queríamos fazer transações automatizadas, mas ainda não tínhamos certeza de qual provedor de cotações usaríamos. Uma classe semelhante de soluções é constantemente tratada ao trabalhar com data warehouses. O que nosso programa deve usar: mysql, postgres, redis, sistema de arquivos, cassandra? Por fim, tudo isso são detalhes de implementação, e as interfaces nos permitem adiar as decisões finais sobre esses problemas. Eles nos permitem desenvolver a lógica de negócios de nossos programas e mudar para soluções tecnológicas específicas no último momento!
Apesar de esta técnica deixar muitas possibilidades, algo mágico acontece no nível do planejamento do projeto. Imagine o que acontecerá se adicionarmos mais uma dependência à interface do Exchange.

Aqui, reconfiguraremos nossa arquitetura na forma de um gráfico acíclico direcionado, para que, assim que concordarmos com os detalhes da interface do Exchange, possamos COMPETITAMENTE continuar trabalhando com o pipeline usando
HTTPStockExchange
. Criamos uma situação em que a adição de uma nova pessoa ao projeto nos ajuda a avançar mais rapidamente. Ajustando nossa arquitetura dessa maneira, é melhor ver onde, quando e por quanto tempo podemos envolver outras pessoas no projeto, a fim de agilizar a entrega de todo o projeto. Além disso, como a conexão entre nossas interfaces é fraca, geralmente é fácil se envolver no trabalho, começando pelas interfaces de implementação. Você pode desenvolver, testar e testar o
HTTPStockExchange
completamente independentemente do nosso programa!
A análise de dependências arquitetônicas e o planejamento de acordo com essas dependências podem acelerar drasticamente os projetos. Usando essa técnica específica, pude concluir rapidamente projetos para os quais vários meses foram alocados.
AdianteAgora deve ficar mais claro como as interfaces e a implementação de dependências garantem a durabilidade do programa projetado. Suponha que alteremos nosso provedor de cotação ou inicie o fluxo de cotas e salve-as em tempo real; existem tantas outras possibilidades quanto você quiser. O método de análise em sua forma atual oferecerá suporte a qualquer implementação adequada para integração com a interface do
StockExchange
.
se.CurrentPrice(ticker)
Assim, em muitos casos, você pode fazer sem alterações. Não em todos, mas nos casos previsíveis que podemos encontrar. Não estamos apenas imunes à necessidade de alterar o código de
analyze
e verificar novamente sua funcionalidade principal, mas podemos oferecer facilmente novas implementações ou alternar entre fornecedores. Também podemos expandir ou atualizar sem problemas as implementações específicas que já temos, sem a necessidade de alterar ou verificar
analyze
!
Espero que os exemplos acima demonstrem de forma convincente como o enfraquecimento da comunicação entre entidades no programa através do uso de interfaces reorienta completamente as dependências e separa o chamador do chamador. Graças a esse desapego, o programa não depende de uma implementação específica, mas depende de um
comportamento específico. Esse comportamento pode ser fornecido por uma ampla variedade de implementações. Esse princípio crítico de design também é chamado de
digitação de pato .
O conceito de interfaces e a dependência do comportamento, e não da implementação, são tão poderosos que considero as interfaces como uma linguagem primitiva - sim, isso é bastante radical. Espero que os exemplos discutidos acima sejam bastante convincentes, e você concorda que as interfaces e a injeção de dependência devem ser usadas desde o início do projeto. Em quase todos os projetos em que trabalhei, foi necessário não um, mas pelo menos duas implementações: para produção e para teste.