Python Dependency Management: una comparación de enfoques

imagen

He estado escribiendo en Python durante cinco años, de los cuales los últimos tres años han estado desarrollando mi propio proyecto. La mayor parte de esta manera, mi equipo me ayuda con esto. Y con cada versión, con cada nueva característica, estamos tratando cada vez más de asegurarnos de que el proyecto no se convierta en un desastre debido al código no compatible; luchamos con importaciones cíclicas, dependencias mutuas, asignamos módulos reutilizados, reconstruimos la estructura.

Desafortunadamente, en la comunidad de Python no existe un concepto universal de "buena arquitectura", solo existe el concepto de "pitonicidad", por lo que tenemos que idear la arquitectura nosotros mismos. Bajo el corte, Longrid con reflexiones sobre la arquitectura y, en primer lugar, sobre la gestión de dependencias, es aplicable a Python.

django.setup ()


Comenzaré con una pregunta a los dzhangistas. ¿Escribes a menudo estas dos líneas?

import django django.setup() 

Debe iniciar el archivo a partir de esto si desea trabajar con objetos django sin iniciar el servidor web django. Esto se aplica a los modelos y herramientas para trabajar con el tiempo ( django.utils.timezone ) y las django.urls.reverse ( django.urls.reverse ), y mucho más. Si esto no se hace, obtendrá un error:

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

Constantemente escribo estas dos líneas. Soy un gran admirador del código de expulsión; Me gusta crear un archivo .py separado, torcer cosas en él, descifrarlo y luego incrustarlo en el proyecto.

Y esta constante django.setup() me molesta mucho. En primer lugar, te cansas de repetirlo en todas partes; y, en segundo lugar, la inicialización de django lleva unos segundos (tenemos un gran monolito), y cuando reinicia el mismo archivo 10, 20, 100 veces, simplemente ralentiza el desarrollo.

¿Cómo deshacerse de django.setup() ? Necesita escribir código que dependa mínimamente de django.

Por ejemplo, si escribimos un cliente de una API externa, entonces podemos hacerlo dependiente de django:

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

o puede ser independiente de django:

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

En el segundo caso, el constructor es más engorroso, pero cualquier manipulación con esta clase se puede hacer sin cargar toda la maquinaria dzhangovskoy.

Las pruebas también se están volviendo más fáciles. ¿Cómo probar un componente que depende de la configuración de django.conf.settings ? Solo ciérrelos con el decorador @override_settings . Y si el componente no depende de nada, entonces no habrá nada que mojar: pasó los parámetros al constructor y lo condujo.

Gestión de dependencias


La historia de dependencia de django es el ejemplo más sorprendente de un problema que encuentro todos los días: problemas de administración de dependencias en python, y la arquitectura general de las aplicaciones de python.

La relación con la gestión de dependencias en la comunidad Python es mixta. Se pueden distinguir tres campamentos principales:

  • Python es un lenguaje flexible. Escribimos como queramos, dependiendo de lo que queramos. No somos tímidos con respecto a las dependencias cíclicas, la sustitución de atributos para clases en tiempo de ejecución, etc.

  • Python es un lenguaje especial. Hay formas idiomáticas de construir arquitectura y dependencias. La transferencia de datos hacia arriba y hacia abajo en la pila de llamadas es realizada por iteradores, corutinas y administradores de contexto.

    Informe de clase sobre este tema y ejemplo
    Brandon Rhodes, Dropbox: Eleva tu IO .

    Ejemplo del informe:

     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 


  • La flexibilidad de Python es una forma adicional de dispararte en el pie. Necesita un conjunto rígido de reglas para administrar dependencias. Un buen ejemplo son los chicos rusos de pitón seco . Todavía hay un enfoque menos duro: la estructura de Django para escala y longevidad , pero la idea es la misma.

Hay varios artículos sobre gestión de dependencias en python ( ejemplo 1 , ejemplo 2 ), pero todos se reducen a anunciar los marcos de inyección de dependencias de alguien. Este artículo es una nueva entrada sobre el mismo tema, pero esta vez es un experimento de pensamiento puro sin publicidad. Este es un intento de encontrar un equilibrio entre los tres enfoques anteriores, prescindir de un marco adicional y hacerlo "pitónico".

Recientemente leí Clean Architecture , y parece que entiendo cuál es el valor de la inyección de dependencia en python y cómo se puede implementar. Vi esto en el ejemplo de mi propio proyecto. En pocas palabras, esto está protegiendo el código para que no se rompa cuando otro código cambia .

Datos de origen


Hay un cliente API que ejecuta solicitudes HTTP para el acortador de servicios:

 # 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'] 

Y hay un módulo que acorta todos los enlaces en el texto. Para hacer esto, usa el cliente API acortador:

 # 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 

La lógica de la ejecución del código vive en un archivo de control separado (llamémoslo controlador):

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

Todo funciona El procesador analiza el texto, acorta los enlaces con un acortador y devuelve el resultado. Las dependencias se ven así:

imagen

El problema


Este es el problema: la clase TextProcessor depende de la clase TextProcessor , y se interrumpe cuando cambia la interfaz de ShortenerClient .

¿Cómo puede pasar esto?

Supongamos que en nuestro proyecto decidimos rastrear shorten_link y agregamos el argumento callback_url al método shorten_link . Este argumento significa la dirección a la que deben llegar las notificaciones al hacer clic en un enlace.

El método ShortenerClient.shorten_link comenzó a verse así:

 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'] 

Y que pasa Y resulta que cuando intentamos comenzar, recibimos un error:

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

Es decir, cambiamos el acortador, pero no fue él quien rompió, sino su cliente:

imagen

¿Y qué? Bueno, el archivo de llamadas se rompió, fuimos y lo arreglamos. Cual es el problema

Si esto se resuelve en un minuto, fueron y se corrigieron, entonces esto, por supuesto, no es un problema en absoluto. Si hay poco código en las clases y si los admite usted mismo (este es su proyecto paralelo, estas son dos clases pequeñas del mismo subsistema, etc.), puede detenerse allí.

Los problemas comienzan cuando:

  • los módulos que llaman y los que llaman tienen mucho código;
  • diferentes módulos son compatibles con diferentes personas / equipos.

Si escribe la clase TextProcessor y su colega escribe TextProcessor , obtendrá una situación ofensiva: cambió el código, pero se rompió. Y se rompió en un lugar que no has visto en la vida, y ahora necesitas sentarte y entender el código de otra persona.

Aún más interesante es cuando su módulo se usa en varios lugares, y no en uno; y su edición romperá el código en el montón de archivos.

Por lo tanto, el problema se puede formular de la siguiente manera: ¿cómo organizar el código para que cuando se cambie la interfaz de ShortenerClient , ShortenerClient ShortenerClient y no sus consumidores (de los cuales puede haber muchos)?

La solución aquí es:

  • Los consumidores de la clase y la clase misma deben acordar una interfaz común. Esta interfaz debería convertirse en ley.
  • Si la clase deja de corresponder a su interfaz, serán sus problemas y no los problemas de los consumidores.

imagen

Congelar la interfaz


¿Cómo se ve arreglar una interfaz en Python? Esta es una clase abstracta:

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

Si ahora heredamos de esta clase y olvidamos implementar algún método, obtendremos un error:

 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 

Pero esto no es suficiente. Una clase abstracta captura solo los nombres de los métodos, pero no su firma.

Necesita una segunda herramienta de verificación de firma Esta segunda herramienta es mypy . Ayudará a verificar las firmas de los métodos heredados. Para hacer esto, debemos agregar anotaciones a la interfaz:

 # 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' 

Si ahora verificamos este código con mypy , obtenemos un error debido al argumento adicional callback_url :

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

Ahora tenemos una forma confiable de confirmar la interfaz de la clase.

Inversión de dependencia


Después de depurar la interfaz, debemos moverla a otro lugar para eliminar por completo la dependencia del consumidor del archivo shortener_client.py . Por ejemplo, puede arrastrar la interfaz directamente al consumidor, a un archivo con el procesador 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 

¡Y eso cambiará la dirección de la adicción! Ahora el TextProcessor posee la interfaz de interacción y, como resultado, ShortenerClient depende de ella, y no al revés.

imagen

En palabras simples, podemos describir la esencia de nuestra transformación de la siguiente manera:

  • TextProcessor dice: Soy un procesador y estoy involucrado en la conversión de texto. No quiero saber nada sobre el mecanismo de acortamiento: este no es asunto mío. Quiero extraer el método shorten_link para que shorten_link todo para mí. Entonces, por favor, dame un objeto que juegue de acuerdo con mis reglas. Las decisiones sobre cómo interactúo las tomo yo, no él.
  • ShortenerClient dice: parece que no puedo existir en el vacío, y ellos requieren cierto comportamiento de mi parte. Voy a preguntarle a TextProcessor qué necesito para que no coincida.

Múltiples consumidores


Si varios módulos usan enlaces de acortamiento, entonces la interfaz no debe colocarse en uno de ellos, sino en un archivo separado, que se encuentra por encima de los otros archivos, en una jerarquía más alta:

imagen

Componente de control


Si los consumidores no importan ShortenerClient , ¿quién lo importará y creará un objeto de clase? Debería ser un componente de control, en nuestro caso es controller.py .

El enfoque más simple es una inyección de dependencia directa, la inyección de dependencia "en la frente". Creamos objetos en el código de llamada, transferimos un objeto a otro. Ganancia

 # 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()) 

Enfoque de Python


Se cree que un enfoque más "pitónico" es la inyección de dependencia a través de la herencia.

Raymond Hettinger habla sobre esto con gran detalle en su informe Super considerado Super.

Para adaptar el código a este estilo, debe cambiar ligeramente el TextProcessor que sea heredable:

 # 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 

Y luego, en el código de llamada, herede:

 # 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()) 

El segundo ejemplo es omnipresente en los marcos populares:

  • En Django, somos constantemente heredados. Redefinimos los métodos de vista basada en clase, modelos, formularios; en otras palabras, inyecta nuestras dependencias en el trabajo ya depurado del framework.
  • En DRF, lo mismo. Estamos ampliando vistas, serializadores, permisos.
  • Y así sucesivamente. Muchos ejemplos

El segundo ejemplo parece más bonito y más familiar, ¿no? Vamos a desarrollarlo y ver si se conserva esta belleza.

Desarrollo de Python


En la lógica de negocios, generalmente hay más de dos componentes. Supongamos que nuestro TextProcessor no es una clase independiente, sino solo uno de los elementos de la TextPipeline que procesa el texto y lo envía al correo:

 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) 

Si queremos aislar TextPipeline de las clases utilizadas, debemos seguir el mismo procedimiento que antes:

  • la clase TextPipeline declarará interfaces para los componentes utilizados;
  • los componentes usados ​​se verán obligados a cumplir con estas interfaces;
  • algún código externo juntará todo y se ejecutará.

El diagrama de dependencia se verá así:

imagen

Pero, ¿cómo será ahora el código de ensamblaje de estas dependencias?

 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() 

¿Te has dado cuenta? Primero heredamos la clase TextProcessor para insertar el ShortenerClient en él, y luego heredamos TextPipeline para insertar nuestro TextProcessor anulado (así como Mailer ) en él. Tenemos varios niveles de redefinición secuencial. Ya complicado

¿Por qué todos los marcos están organizados de esta manera? Sí, porque solo es adecuado para marcos.

  • Todos los niveles del marco están claramente definidos y su número es limitado. Por ejemplo, en Django, puede anular FormField para insertarlo en una anulación de un Form , para insertar un formulario en una anulación de View . Eso es todo. Tres niveles
  • Cada marco tiene un propósito. Esta tarea está claramente definida.
  • Cada marco tiene documentación detallada que describe cómo y qué heredar; qué y con qué combinar.

¿Puede identificar y documentar de manera clara e inequívoca su lógica empresarial? ¿Especialmente la arquitectura de los niveles en los que trabaja? Yo no Desafortunadamente, el enfoque de Raymond Hettinger no se adapta a la lógica empresarial.

Volver al enfoque de la frente


En varios niveles de dificultad, gana un enfoque simple. Parece más simple y más fácil de cambiar cuando cambia la lógica.

 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() 

Pero, cuando aumenta el número de niveles de lógica, incluso este enfoque se vuelve inconveniente. Tenemos que iniciar imperativamente un grupo de clases, pasándolas entre sí. Quiero evitar muchos niveles de anidamiento.

Probemos una llamada más.

Almacenamiento de instancia global


Intentemos crear un diccionario global en el que se encuentren las instancias de los componentes que necesitamos. Y deje que estos componentes se obtengan entre sí mediante el acceso a este diccionario.

Llamémoslo 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) 

El truco es poner nuestros objetos en este diccionario antes de acceder a ellos . Esto es lo que haremos en 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() 

Ventajas de trabajar a través de un diccionario global:

  • sin magia del capó del motor y marcos DI extra;
  • una lista plana de dependencias en las que no necesita administrar el anidamiento;
  • todos los bonos DI: pruebas simples, independencia, protección de componentes contra averías cuando otros componentes cambian.

Por supuesto, en lugar de crear INSTANCE_DICT , puede usar algún tipo de marco DI; pero la esencia de esto no cambiará. El marco proporcionará una gestión más flexible de las instancias; él te permitirá crearlos en forma de singletones o paquetes, como una fábrica; pero la idea seguirá siendo la misma.

Quizás en algún momento esto no sea suficiente para mí, y todavía elijo algún tipo de marco.

Y, tal vez, todo esto es innecesario, y es más fácil prescindir de él: escribir importaciones directas y no crear interfaces abstractas innecesarias.

¿Cuál es su experiencia con la gestión de dependencias en python? Y en general, ¿es necesario o estoy inventando un problema desde el aire?

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


All Articles