Gerenciamento de dependência do Python: uma comparação de abordagens

imagem

Escrevo em python há cinco anos, dos quais nos últimos três anos desenvolvemos meu próprio projeto. A maior parte dessa maneira, minha equipe me ajuda com isso. E a cada versão, a cada novo recurso, estamos cada vez mais tentando garantir que o projeto não se transforme em confusão por código não suportado; lutamos com importações cíclicas, dependências mútuas, alocamos módulos reutilizáveis, reconstruímos a estrutura.

Infelizmente, na comunidade Python não existe um conceito universal de "boa arquitetura", existe apenas o conceito de "pitonicidade"; portanto, nós mesmos temos que criar a arquitetura. Sob o corte - Longrid com reflexões sobre arquitetura, e antes de mais - o gerenciamento de dependências é aplicável ao Python.

django.setup ()


Vou começar com uma pergunta para os junglers. Você costuma escrever essas duas linhas?

import django django.setup() 

Você precisa iniciar o arquivo a partir disso se quiser trabalhar com objetos django sem iniciar o próprio servidor web django. Isso se aplica a modelos e ferramentas para trabalhar com time ( django.utils.timezone ) e django.urls.reverse ( django.urls.reverse ) e muito mais. Se isso não for feito, você receberá um erro:

 django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet. 

Estou constantemente escrevendo essas duas linhas. Eu sou um grande fã do código de ejeção; Gosto de criar um arquivo .py separado, distorcer as coisas, descobrir - e incorporá-lo ao projeto.

E essa constante django.setup() me irrita muito. Em primeiro lugar, você se cansa de repeti-lo em qualquer lugar; e, em segundo lugar, a inicialização do django leva vários segundos (temos um grande monólito) e, quando você reinicia o mesmo arquivo 10, 20, 100 vezes - apenas atrasa o desenvolvimento.

Como se livrar de django.setup() ? Você precisa escrever um código que dependa minimamente do django.

Por exemplo, se escrevermos um cliente de uma API externa, podemos torná-la dependente do django:

 from django.conf import settings class APIClient: def __init__(self): self.api_key = settings.SOME_API_KEY # : client = APIClient() 

ou pode ser independente do django:

 class APIClient: def __init__(self, api_key): self.api_key = api_key # : client = APIClient(api_key='abc') 

No segundo caso, o construtor é mais complicado, mas qualquer manipulação com essa classe pode ser feita sem carregar toda a maquinaria dzhangovskoy.

Os testes também estão ficando mais fáceis. Como testar um componente que depende das configurações do django.conf.settings ? Basta bloqueá-los com o decorador @override_settings . E se o componente não depender de nada, não haverá nada para se molhar: ele passou os parâmetros para o construtor - e o conduziu.

Gerenciamento de Dependências


A história de dependência do django é o exemplo mais impressionante de um problema que enfrento todos os dias: problemas de gerenciamento de dependência em python - e a arquitetura geral dos aplicativos python.

O relacionamento com o gerenciamento de dependências na comunidade Python é misto. Três campos principais podem ser distinguidos:

  • Python é uma linguagem flexível. Nós escrevemos como queremos, dependendo do que queremos. Não temos vergonha de dependências cíclicas, substituição de atributos por classes em tempo de execução, etc.

  • Python é uma linguagem especial. Existem maneiras idiomáticas de construir arquitetura e dependências. A transferência de dados para cima e para baixo na pilha de chamadas é realizada por iteradores, corotinas e gerenciadores de contexto.

    Relatório de classe sobre este assunto e exemplo
    Brandon Rhodes, Dropbox: guie seu IO .

    Exemplo do relatório:

     def main(): """          """ with open("/etc/hosts") as file: for line in parse_hosts(file): print(line) def parse_hosts(lines): """    -   """ for line in lines: if line.startswith("#"): continue yield line 


  • A flexibilidade do Python é uma maneira extra de dar um tiro no próprio pé. Você precisa de um conjunto rígido de regras para gerenciar dependências. Um bom exemplo são os caras russos de python seco . Ainda existe uma abordagem menos explícita - a estrutura do Django para escala e longevidade , mas a idéia é a mesma.

Existem vários artigos sobre gerenciamento de dependências em python ( exemplo 1 , exemplo 2 ), mas todos eles se resumem a anunciar as estruturas de injeção de dependência de alguém. Este artigo é uma nova entrada sobre o mesmo tópico, mas desta vez é uma pura experiência de pensamento sem publicidade. Esta é uma tentativa de encontrar um equilíbrio entre as três abordagens acima, sem uma estrutura extra e tornando-a “pitônica”.

Recentemente, li Arquitetura Limpa - e pareço entender qual é o valor da injeção de dependência em python e como ela pode ser implementada. Eu vi isso no exemplo do meu próprio projeto. Em poucas palavras, isso está protegendo o código de quebra quando outro código é alterado .

Dados de origem


Há um cliente de API que executa solicitações HTTP para o encurtador de serviço:

 # shortener_client.py import requests class ShortenerClient: def __init__(self, api_key): self.api_key = api_key def shorten_link(self, url): response = requests.post( url='https://fstrk.cc/short', headers={'Authorization': self.api_key}, json={'url': url} ) return response.json()['url'] 

E há um módulo que reduz todos os links no texto. Para fazer isso, ele usa o cliente da API encurtadora:

 # text_processor.py import re from shortener_client import ShortenerClient class TextProcessor: def __init__(self, text): self.text = text def process(self): changed_text = self.text links = re.findall( r'https?://[^\r\n\t") ]*', self.text, flags=re.MULTILINE ) api_client = ShortenerClient('abc') for link in links: shortened = api_client.shorten_link(link) changed_text = changed_text.replace(link, shortened) return changed_text 

A lógica da execução do código reside em um arquivo de controle separado (vamos chamá-lo de controlador):

 # controller.py from text_processor import TextProcessor processor = TextProcessor("""  1: https://ya.ru  2: https://google.com """) print(processor.process()) 

Tudo funciona. O processador analisa o texto, encurta os links usando um encurtador e retorna o resultado. As dependências são assim:

imagem

O problema


Aqui está o problema: a classe TextProcessor depende da classe ShortenerClient - e é interrompida quando a interface ShortenerClient alterada .

Como isso pode acontecer?

Suponha que em nosso projeto decidimos rastrear shorten_link e adicionamos o argumento callback_url ao método shorten_link . Esse argumento significa o endereço para o qual as notificações devem chegar ao clicar em um link.

O método ShortenerClient.shorten_link começou a ficar assim:

 def shorten_link(self, url, callback_url): response = requests.post( url='https://fstrk.cc/short', headers={'Authorization': self.api_key}, json={'url': url, 'callback_on_click': callback_url} ) return response.json()['url'] 

E o que acontece? E acontece que, quando tentamos iniciar, recebemos um erro:

 TypeError: shorten_link() missing 1 required positional argument: 'callback_url' 

Ou seja, mudamos o encurtador, mas não foi ele quem quebrou, mas seu cliente:

imagem

E daí? Bem, o arquivo de chamada quebrou, nós fomos consertar. Qual é o problema?

Se isso for resolvido em um minuto - eles foram corrigidos -, é claro que isso não é um problema. Se houver pouco código nas classes e se você os apoiar (este é o seu projeto paralelo, são duas pequenas classes do mesmo subsistema etc.), você poderá parar por aí.

Os problemas começam quando:

  • os módulos chamado e chamado possuem muito código;
  • módulos diferentes são suportados por pessoas / equipes diferentes.

Se você escreve a classe ShortenerClient e seu colega escreve o TextProcessor , você fica com uma situação ofensiva: alterou o código, mas ele quebrou. E quebrou em um lugar que você não viu na vida, e agora você precisa sentar e entender o código de outra pessoa.

Ainda mais interessante é quando seu módulo é usado em vários lugares, e não em um; e sua edição quebrará o código na pilha de arquivos.

Portanto, a tarefa pode ser formulada da seguinte maneira: como organizar o código para que, quando a interface ShortenerClient for alterada, o próprio ShortenerClient se ShortenerClient e não para seus consumidores (pode haver muitos)?

A solução aqui é:

  • Os consumidores da classe e a própria classe devem concordar com uma interface comum. Essa interface deve se tornar lei.
  • Se a classe deixar de corresponder à sua interface, serão seus problemas, e não os problemas dos consumidores.

imagem

Congelar a interface


Como é a fixação de uma interface em python? Esta é uma classe abstrata:

 from abc import ABC, abstractmethod class AbstractClient(ABC): @abstractmethod def __init__(self, api_key): pass @abstractmethod def shorten_link(self, link): pass 

Se agora herdarmos essa classe e esquecermos de implementar algum método, obteremos um erro:

 class ShortenerClient(AbstractClient): def __ini__(self, api_key): self.api_key = api_key client = ShortenerClient('123') >>> TypeError: Can't instantiate abstract class ShortenerClient with abstract methods __init__, shorten_link 

Mas isso não é suficiente. Uma classe abstrata captura apenas os nomes dos métodos, mas não a assinatura.

Precisa de uma segunda ferramenta de verificação de assinatura Esta segunda ferramenta é mypy . Isso ajudará a verificar as assinaturas dos métodos herdados. Para fazer isso, devemos adicionar anotações à interface:

 # shortener_client.py from abc import ABC, abstractmethod class AbstractClient(ABC): @abstractmethod def __init__(self, api_key: str) -> None: pass @abstractmethod def shorten_link(self, link: str) -> str: pass class ShortenerClient(AbstractClient): def __init__(self, api_key: str) -> None: self.api_key = api_key def shorten_link(self, link: str, callback_url: str) -> str: return 'xxx' 

Se agora verificarmos esse código com mypy , mypy um erro devido ao argumento extra callback_url :

 mypy shortener_client.py >>> error: Signature of "shorten_link" incompatible with supertype "AbstractClient" 

Agora, temos uma maneira confiável de confirmar a interface da classe.

Inversão de dependência


Depois de depurar a interface, precisamos movê-la para outro local para eliminar completamente a dependência do consumidor no arquivo shortener_client.py . Por exemplo, você pode arrastar a interface diretamente para o consumidor - para um arquivo com o processador TextProcessor :

 # text_processor.py import re from abc import ABC, abstractmethod class AbstractClient(ABC): @abstractmethod def __init__(self, api_key: str) -> None: pass @abstractmethod def shorten_link(self, link: str) -> str: pass class TextProcessor: def __init__(self, text, shortener_client: AbstractClient) -> None: self.text = text self.shortener_client = shortener_client def process(self) -> str: changed_text = self.text links = re.findall( r'https?://[^\r\n\t") ]*', self.text, flags=re.MULTILINE ) for link in links: shortened = self.shortener_client.shorten_link(link) changed_text = changed_text.replace(link, shortened) return changed_text 

E isso mudará a direção do vício! Agora o TextProcessor possui a interface de interação e, como resultado, ShortenerClient depende dela, e não vice-versa.

imagem

Em palavras simples, podemos descrever a essência de nossa transformação da seguinte maneira:

  • TextProcessor diz: Eu sou um processador e estou envolvido na conversão de texto. Não quero saber nada sobre o mecanismo de redução: esse não é da minha conta. Eu quero puxar o método shorten_link para que ele shorten_link tudo para mim. Então, por favor, me dê um objeto que toque de acordo com minhas regras. As decisões sobre como interajo são tomadas por mim, não por ele.
  • ShortenerClient diz: parece que não posso existir no vácuo e eles exigem certo comportamento de mim. Vou perguntar ao TextProcessor o que preciso corresponder para não quebrar.

Vários consumidores


Se vários módulos usam links de encurtamento, a interface deve ser colocada não em um deles, mas em algum arquivo separado, localizado acima dos outros arquivos, com maior hierarquia:

imagem

Componente de controle


Se os consumidores não importarem o ShortenerClient , quem o importará e criará um objeto de classe? Deve ser um componente de controle - no nosso caso, é controller.py .

A abordagem mais simples é uma injeção direta de dependência, injeção de dependência "na testa". Criamos objetos no código de chamada, transferimos um objeto para outro. Lucro

 # controller.py import TextProcessor import ShortenerClient processor = TextProcessor( text=' 1: https://ya.ru  2: https://google.com', shortener_client=ShortenerClient(api_key='123') ) print(processor.process()) 

Abordagem Python


Acredita-se que uma abordagem mais “pitônica” seja a injeção de dependência por herança.

Raymond Hettinger fala sobre isso detalhadamente em seu relatório Super considerado Super.

Para adaptar o código a esse estilo, você precisa alterar levemente o TextProcessor , tornando-o herdável:

 # text_processor.py class TextProcessor: def __init__(self, text: str) -> None: self.text = text self.shortener_client: AbstractClient = self.get_shortener_client() def get_shortener_client(self) -> AbstractClient: """      """ raise NotImplementedError 

E então, no código de chamada, herde-o:

 # controller.py import TextProcessor import ShortenerClient class ProcessorWithClient(TextProcessor): """   ,    """ def get_shortener_client(self) -> ShortenerClient: return ShortenerClient(api_key='abc') processor = ProcessorWithClient( text=' 1: https://ya.ru  2: https://google.com' ) print(processor.process()) 

O segundo exemplo é onipresente em estruturas populares:

  • No Django, somos constantemente herdados. Redefinimos métodos de visão, modelos, formulários baseados em classe; em outras palavras, injete nossas dependências no trabalho já depurado da estrutura.
  • Na DRF, a mesma coisa. Estamos expandindo visualizações, serializadores, permissões.
  • E assim por diante Existem muitos exemplos.

O segundo exemplo parece mais bonito e mais familiar, não é? Vamos desenvolvê-lo e ver se essa beleza é preservada.

Desenvolvimento Python


Na lógica de negócios, geralmente existem mais de dois componentes. Suponha que nosso TextProcessor não seja uma classe independente, mas apenas um dos elementos do TextPipeline que processa o texto e o envia para o correio:

 class TextPipeline: def __init__(self, text, email): self.text_processor = TextProcessor(text) self.mailer = Mailer(email) def process_and_mail(self) -> None: processed_text = self.text_processor.process() self.mailer.send_text(text=processed_text) 

Se quisermos isolar o TextPipeline das classes usadas, devemos seguir o mesmo procedimento de antes:

  • a classe TextPipeline declarará interfaces para os componentes usados;
  • os componentes usados ​​serão forçados a obedecer a essas interfaces;
  • algum código externo reunirá tudo e será executado.

O diagrama de dependência ficará assim:

imagem

Mas como será o código de montagem dessas dependências agora?

 import TextProcessor import ShortenerClient import Mailer import TextPipeline class ProcessorWithClient(TextProcessor): def get_shortener_client(self) -> ShortenerClient: return ShortenerClient(api_key='123') class PipelineWithDependencies(TextPipeline): def get_text_processor(self, text: str) -> ProcessorWithClient: return ProcessorWithClient(text) def get_mailer(self, email: str) -> Mailer: return Mailer(email) pipeline = PipelineWithDependencies( email='abc@def.com', text=' 1: https://ya.ru  2: https://google.com' ) pipeline.process_and_mail() 

Você já reparou? Primeiro, herdamos a classe TextProcessor para inserir o ShortenerClient nele e, em seguida, herdamos o TextPipeline para inserir nosso TextProcessor redefinido (além do Mailer ) nele. Temos vários níveis de redefinição sequencial. Já é complicado.

Por que todas as estruturas são organizadas dessa maneira? Sim, porque é adequado apenas para estruturas.

  • Todos os níveis da estrutura são claramente definidos e seu número é limitado. Por exemplo, no Django, você pode substituir o FormField para inseri-lo em uma substituição de um Form para inserir um formulário em uma substituição do View . Só isso. Três níveis
  • Cada estrutura serve a um propósito. Esta tarefa está claramente definida.
  • Cada estrutura possui documentação detalhada que descreve como e o que herdar; o que e com o que combinar.

Você pode identificar e documentar de maneira clara e inequívoca sua lógica de negócios? Especialmente a arquitetura dos níveis em que trabalha? Eu não Infelizmente, a abordagem de Raymond Hettinger não se ajusta à lógica de negócios.

Voltar à abordagem da testa


Em vários níveis de dificuldade, uma abordagem simples vence. Parece mais simples - e mais fácil mudar quando a lógica muda.

 import TextProcessor import ShortenerClient import Mailer import TextPipeline pipeline = TextPipeline( text_processor=TextProcessor( text=' 1: https://ya.ru  2: https://google.com', shortener_client=ShortenerClient(api_key='abc') ), mailer=Mailer('abc@def.com') ) pipeline.process_and_mail() 

Porém, quando o número de níveis de lógica aumenta, mesmo essa abordagem se torna inconveniente. Temos que iniciar imperativamente um grupo de classes, passando-as uma para a outra. Eu quero evitar muitos níveis de aninhamento.

Vamos tentar mais uma ligação.

Armazenamento de Instância Global


Vamos tentar criar um dicionário global no qual as instâncias dos componentes de que precisamos estejam. E permita que esses componentes se interajam através do acesso a este dicionário.

Vamos chamá-lo de INSTANCE_DICT :

 # text_processor.py import INSTANCE_DICT class TextProcessor(AbstractTextProcessor): def __init__(self, text) -> None: self.text = text def process(self) -> str: shortener_client: AbstractClient = INSTANCE_DICT['Shortener'] # ...   

 # text_pipeline.py import INSTANCE_DICT class TextPipeline: def __init__(self) -> None: self.text_processor: AbstractTextProcessor = INSTANCE_DICT[ 'TextProcessor'] self.mailer: AbstractMailer = INSTANCE_DICT['Mailer'] def process_and_mail(self) -> None: processed_text = self.text_processor.process() self.mailer.send_text(text=processed_text) 

O truque é colocar nossos objetos neste dicionário antes de serem acessados . Isto é o que faremos no controller.py :

 # controller.py import INSTANCE_DICT import TextProcessor import ShortenerClient import Mailer import TextPipeline INSTANCE_DICT['Shortener'] = ShortenerClient('123') INSTANCE_DICT['Mailer'] = Mailer('abc@def.com') INSTANCE_DICT['TextProcessor'] = TextProcessor(text=' : https://ya.ru') pipeline = TextPipeline() pipeline.process_and_mail() 

Vantagens de trabalhar com um dicionário global:

  • nenhuma mágica no capô do motor e estruturas DI extras;
  • uma lista simples de dependências nas quais você não precisa gerenciar o aninhamento;
  • todos os bônus DI: teste simples, independência, proteção de componentes contra falhas quando outros componentes mudam.

Obviamente, em vez de criar INSTANCE_DICT , você pode usar algum tipo de estrutura DI; mas a essência disso não vai mudar. A estrutura fornecerá um gerenciamento mais flexível das instâncias; ele permitirá que você os crie na forma de singletones ou pacotes, como uma fábrica; mas a ideia permanecerá a mesma.

Talvez em algum momento isso não seja suficiente para mim, e ainda assim eu escolha algum tipo de estrutura.

E, talvez, tudo isso seja desnecessário, e é mais fácil ficar sem ele: escreva importações diretas e não crie interfaces abstratas desnecessárias.

Qual é a sua experiência com gerenciamento de dependências em python? E, em geral - é necessário, ou estou inventando um problema do ar?

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


All Articles