Metafísica da injeção de dependência

imagem


Injeção de Dependência é uma técnica comumente usada em programação orientada a objetos, projetada para reduzir a conectividade de componentes. Quando usado corretamente, além de atingir esse objetivo, pode trazer qualidades verdadeiramente mágicas para suas aplicações. Como qualquer mágica, essa técnica é percebida como um conjunto de feitiços, e não como um tratado científico rigoroso. Isso leva a uma má interpretação dos fenômenos e, como conseqüência, ao mau uso de artefatos. Em meu material autoral, sugiro que o leitor, passo a passo, curta e em essência, siga o caminho lógico dos fundamentos apropriados do design orientado a objetos até a própria mágica da injeção automática de dependência.

O material é baseado no desenvolvimento do container Hypo IoC , que mencionei em um artigo anterior . Em exemplos de código em miniatura, usarei o Ruby como uma das linguagens orientadas a objetos mais concisas para escrever exemplos curtos. Isso não deve causar problemas para os desenvolvedores de outros idiomas entenderem.

Nível 1: Princípio da inversão de dependência


Os desenvolvedores no paradigma orientado a objetos são confrontados diariamente com a criação de objetos, que, por sua vez, podem depender de outros objetos. Isso leva a um gráfico de dependência. Suponha que estamos lidando com um modelo de objeto do formulário:
imagem

- algum serviço de cobrança (InvoiceProcessor) e serviço de notificação (NotificationService). O serviço de processamento de faturas envia notificações quando certas condições são atendidas. Vamos tirar essa lógica do escopo. Em princípio, esse modelo já é bom, pois os componentes individuais são responsáveis ​​por diferentes responsabilidades. O problema está em como implementamos essas dependências. Um erro comum é inicializar uma dependência em que essa dependência é usada:

class InvoiceProcessor def process(invoice) #      notificationService = NotificationService.new notificationService.notify(invoice.owner) end end 

Isso é um erro, visto que obtemos alta conectividade de objetos logicamente independentes (High Coupling). Isso leva a uma violação do Princípio da Responsabilidade Única - um objeto dependente, além de suas responsabilidades imediatas, deve inicializar suas dependências; e também “conhece” a interface do construtor de dependência, o que levará a um motivo adicional de mudança ( “motivo para mudar”, R. Martin ). É mais correto passar esse tipo de dependência, inicializada fora do objeto dependente:

 class InvoiceProcessor def initialize(notificationService) @notificationService = notificationService end def process(invoice) @notificationService.notify(invoice.owner) end end notificationService = NotificationService.new invoiceProcessor = InvoiceProcessor.new(notificationService) 

Essa abordagem é consistente com o Princípio da inversão de dependência. Agora estamos transferindo um objeto com uma interface de envio de mensagens - não é mais necessário que o serviço de cobrança "saiba" como construir o objeto de serviço de notificação. Ao escrever testes de unidade para um serviço de processamento de faturas, o desenvolvedor não precisa entender como substituir a implementação da interface do serviço de notificação por um stub. Em idiomas com digitação dinâmica, como Ruby, você pode substituir qualquer objeto que atenda ao método de notificação; com digitação estática, como C # / Java, você pode usar a interface INotificationService, para a qual é fácil criar um Mock. A questão da inversão de dependência foi divulgada em detalhes por Alexander Byndyu em um artigo que comemorou recentemente seu 10º aniversário!

Nível 2: registro de objetos relacionados


Usar o princípio de inversão de dependência não parece uma prática complicada. Mas com o tempo, devido a um aumento no número de objetos e relacionamentos, novos desafios aparecem. NotificationService pode ser usado por outros serviços que não o InvoiceProcessor. Além disso, ele próprio pode depender de outros serviços, que, por sua vez, dependem de terceiros, etc. Além disso, alguns componentes nem sempre podem ser usados ​​em uma única cópia. A principal tarefa é encontrar a resposta para a pergunta - “quando criar dependências?”.
Para resolver esse problema, você pode tentar criar uma solução baseada em uma matriz associativa de dependências. Um exemplo de interface de seu trabalho pode ser assim:

 registry.add(InvoiceProcessor) .depends_on(NotificationService) registry.add(NotificationService) .depends_on(ServiceX) invoiceProcessor = registry.resolve(InvoiceProcessor) invoiceProcessor.process(invoice) 

Não é difícil de implementar na prática:

imagem

Cada vez que container.resolve () é chamado, iremos para a fábrica, que criará instâncias de dependência, ignorando recursivamente o gráfico de dependência descrito no registro. No caso de `container.resolve (InvoiceProcessor)`, o seguinte será executado:

  1. factory.resolve (InvoiceProcessor) - a fábrica solicita as dependências InvoiceProcessor no registro, recebe um NotificationService, que também precisa ser montado.
  2. factory.resolve (NotificationService) - a fábrica solicita as dependências NotificationService no registro e recebe o ServiceX, que também precisa ser montado.
  3. factory.resolve (ServiceX) - não possui dependências, cria, retorna ao longo da pilha de chamadas para a etapa 1, obtém um objeto montado do tipo InvoiceProcessor.

Cada componente pode depender de vários outros, portanto, a pergunta óbvia é “como combinar corretamente os parâmetros do designer com as instâncias de dependência resultantes?”. Um exemplo:

 class InvoiceProcessor def initialize(notificationService, paymentService) # ... end end 

Em idiomas com digitação estática, o tipo de parâmetro pode servir como seletor:

 class InvoiceProcessor { constructor(notificationService: NotificationService, paymentService: PaymentService) { // ... } } 

No Ruby, você pode usar a convenção - basta usar o nome do tipo no formato snake_case, este será o nome do parâmetro esperado.

Nível 3: gerenciamento de vida útil da dependência


Já temos uma boa solução de gerenciamento de dependências. Sua única limitação é a necessidade de criar uma nova instância da dependência a cada chamada. Mas e se não pudermos criar mais de uma instância de um componente? Por exemplo, um conjunto de conexões com o banco de dados. Aprofundar e se precisarmos fornecer uma vida útil controlada de dependências? Por exemplo, feche a conexão com o banco de dados após a conclusão da solicitação HTTP.
Torna-se aparente que o candidato para substituição na solução original é InstanceFactory. Gráfico atualizado:

imagem

E a solução lógica é usar um conjunto de estratégias ( Estratégia, GoF ) para obter instâncias de componentes. Agora, nem sempre criamos novas instâncias ao chamar Container :: resolve, portanto, é apropriado renomear Factory para Resolver. Observe que o método Container :: register possui um novo parâmetro - life_time (life). Este parâmetro é opcional - por padrão, seu valor é "transitório" (transitório), que corresponde ao comportamento implementado anteriormente. A estratégia singleton também é óbvia - com seu uso, apenas uma instância do componente é criada, que será retornada toda vez.
O escopo é uma estratégia um pouco mais complexa. Em vez de "caminhos transitórios" e "solitários", muitas vezes é necessário usar algo intermediário - um componente que existe ao longo da vida de outro componente. Um exemplo semelhante pode ser um objeto de solicitação de aplicativo da web, que é o contexto da existência de objetos como, por exemplo, parâmetros HTTP, conexão com o banco de dados, agregados de modelo. Durante toda a vida útil da solicitação, coletamos e usamos essas dependências e, após sua destruição, esperamos que todas elas também sejam destruídas. Para implementar essa funcionalidade, será necessário desenvolver uma estrutura de objetos fechados bastante complexa:

imagem

O diagrama mostra um fragmento refletindo alterações nas classes Component e LifetimeStrategy no contexto da implementação da vida útil do escopo. O resultado foi uma espécie de "ponte dupla" (semelhante ao modelo Bridge, GoF ). Usando os meandros das técnicas de herança e agregação, o Component se torna o núcleo do contêiner. A propósito, o diagrama tem herança múltipla. Onde a linguagem de programação e a consciência permitirem, você pode deixar assim. No Ruby, eu uso impurezas; em outros idiomas, você pode substituir a herança por outra ponte:
imagem

O diagrama de sequência mostra o ciclo de vida do componente da sessão, que está vinculado à vida útil do componente de solicitação:

imagem

Como você pode ver no diagrama, em um determinado momento, quando o componente de solicitação completa sua missão, é chamado o método de liberação, que inicia o processo de destruição do escopo.

Nível 4: Injeção de Dependência


Até agora, falei sobre como determinar o registro de dependências e como criar e destruir componentes de acordo com o gráfico das relações formadas. E para que serve? Suponha que usamos isso como parte do Ruby on Rails:

 class InvoiceController < ApplicationController def pay(params) invoice_repository = registry.resolve(InvoiceRepository) invoice_processor = registry.resolve(InvoiceProcessor) invoice = invoice_repository.find(params[:id]) invoice_processor.pay(invoice) end end 

O código que será escrito dessa maneira não será mais legível, testável ou flexível. Não podemos “forçar” o Rails a injetar dependências do controlador por meio de seu construtor, o que não é fornecido pela estrutura. Mas, por exemplo, no ASP.NET MVC, isso é implementado em um nível básico. Para aproveitar ao máximo o uso do mecanismo automático de resolução de dependências, é necessário implementar a técnica Inversion of Control (IoC, inversion of control). Essa é uma abordagem na qual a responsabilidade pela resolução de dependências vai além do escopo do código do aplicativo e fica com a estrutura. Considere um exemplo.
Imagine que estamos projetando algo como Rails do zero. Implementamos o seguinte esquema:

imagem

O aplicativo recebe a solicitação, o roteador recupera os parâmetros e instrui o controlador apropriado a processar essa solicitação. Esse esquema copia condicionalmente o comportamento de uma estrutura da Web típica com apenas uma pequena diferença - o contêiner de IoC está envolvido na criação e implementação de dependências. Mas aqui surge a questão: onde o próprio contêiner é criado? Para cobrir o maior número possível de objetos da futura aplicação, nossa estrutura deve criar um contêiner no estágio inicial de sua operação. Obviamente, não há lugar mais adequado do que o criador de aplicativos. É também o local mais adequado para configurar todas as dependências:

 class App #   - ,      . def initialize @container = Container.new @container .register(Controller) .using_lifetime(:transient) # ,     @container .register(InvoiceService) .using_lifetime(:singleton) # ,     @container .register(Router) .using_lifetime(:singleton) #  end #     -     , #      . def call(env) router = @container.resolve(Router) router.handle(env.path, env.method, env.params) end end 

Qualquer aplicativo tem um ponto de entrada, por exemplo, o método principal. Neste exemplo, o ponto de entrada é o método de chamada. O objetivo deste método é chamar o roteador para processar solicitações recebidas. O ponto de entrada deve ser o único local para ligar diretamente para o contêiner - a partir desse momento, o contêiner deve ser esquecido, toda a magia subsequente deve ocorrer "sob o capô". A implementação do controlador dentro dessa arquitetura realmente parece incomum. Apesar do fato de não ser instanciado explicitamente, ele possui um construtor com parâmetros:

 class Controller #   . #    . def initialize(invoice_service) @invoice_service = invoice_service end def create_invoice(params) @invoice_service.create(params) end end 

O ambiente "entende" como criar instâncias do controlador. Isso é possível graças ao mecanismo de injeção de dependência fornecido pelo contêiner de IoC incorporado no coração do aplicativo da web. No construtor do controlador, agora você pode listar tudo o que é necessário para sua operação. O principal é que os componentes correspondentes sejam registrados no contêiner. Agora vamos passar para a implementação do roteador:

 class Router #         -  #      #     . def initialize(controller) @controller = controller end def handle(path, method, params) #  ""- if path == '/invoices' && method == 'POST' @controller.create(params) end end end 

Observe que o roteador depende do controlador. Se recordarmos as configurações de dependência, o Controlador é um componente de vida curta e o Roteador, um solitário constante. Como isso pode ser? A resposta é que os componentes não são instâncias das classes correspondentes, como parece externamente. De fato, esses são objetos proxy ( Proxy, GoF ) com a instância do método factory ( Factory Method, GoF ); eles retornam uma instância do componente de acordo com a estratégia atribuída. Como o controlador está registrado como "transitório", o roteador sempre lidará com sua nova instância quando for acessado. O diagrama de seqüência mostra um mecanismo aproximado de trabalho:

imagem

I.e. Além do gerenciamento de dependências, uma boa estrutura baseada em um contêiner de IoC também assume a responsabilidade pelo gerenciamento correto da vida útil dos componentes.

Conclusão


A técnica de injeção de dependência pode ter uma implementação interna bastante sofisticada. Esse é o preço de transferir a complexidade da implementação de aplicativos flexíveis para o núcleo da estrutura. O usuário de tais estruturas não pode se preocupar com os aspectos puramente técnicos, mas dedica mais tempo ao desenvolvimento confortável da lógica de negócios dos programas aplicativos. Usando uma implementação de DI de alta qualidade, um programador de aplicativo inicialmente escreve código testável e bem suportado. Um bom exemplo da implementação da Injeção de Dependências é a estrutura Dandy descrita no meu artigo anterior, Orthodox Backend .

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


All Articles