La creciente popularidad de Python ha dado lugar al desarrollo de proyectos más grandes e intrincados. Esta expansión ha llevado a los desarrolladores a explorar patrones de diseño de software de alto nivel, como los del diseño dirigido por dominios (DDD).

Sin embargo, la implementación de estos patrones en Python puede plantear desafíos.

Esta serie práctica está diseñada para equipar a los desarrolladores de Python con ejemplos prácticos, haciendo hincapié en los patrones de diseño arquitectónico probados y comprobados para gestionar la complejidad de las aplicaciones de manera eficaz.

En esta entrega de la serie, profundizaremos en los conceptos de Inyección de Dependencias y su implementación en Python, proporcionando información valiosa para los desarrolladores que buscan mejorar sus proyectos.

Todo el código discutido en este artículo se encuentra en el repositorio GitHub adjunto. El repositorio proporciona una manera conveniente de acceder y explorar los ejemplos con más detalle.

Inyección de dependencia


La inyección de dependencias (DI) es un patrón de diseño que fomenta el acoplamiento flexible, el mantenimiento y la comprobabilidad de las aplicaciones de software.

DI y los marcos de DI han sido populares durante mucho tiempo en lenguajes de típado estático como Java y C#. Sin embargo, su necesidad en lenguajes dinámicos como Python ha sido objeto de debate. Las características inherentes de Python, como la tipificación de pato, ya ofrecen algunos beneficios asociados con DI.

No nos centraremos en este debate, sino que mostraremos aplicaciones prácticas de DI y marcos DI dentro del ecosistema Python. Esto te ayudará a entender sus beneficios potenciales y casos de uso.

Utilizaremos la aplicación de ejemplo que construimos durante el artículo anterior sobre los patrones Repositories y Unit Of Work.

¿Qué es exactamente la inyección de dependencia?


La inyección de dependencias (DI) es un patrón de diseño en el desarrollo de software que facilita la gestión de las relaciones entre los diferentes componentes u objetos de un programa.

En términos sencillos, es una forma de proporcionar las dependencias necesarias (servicios, objetos o valores) a un componente desde una fuente externa en lugar de hacer que el componente las cree o las encuentre por sí mismo.

Imagina que estás construyendo un coche de juguete y, en lugar de hacer que cada pieza (como las ruedas, la carrocería y el motor) encuentre o cree sus tornillos, se los proporcionas desde el exterior. Así es más fácil cambiar los tornillos o reutilizar las piezas en distintas combinaciones sin cambiar las propias piezas.

¿Cuáles son las ventajas de la inyección de dependencia?


La inyección de dependencia ayuda en lo siguiente:

  • Flexibilidad: Facilita el cambio o intercambio de dependencias sin modificar los componentes que las utilizan.
  • Reutilización: Los componentes son más reutilizables porque no están estrechamente vinculados a dependencias específicas.
  • Comprobabilidad: Es más fácil probar los componentes al proporcionar dependencias simuladas durante las pruebas.

¿Qué tipos de inyección de dependencias existen?


Existen tres formas habituales de implementar la inyección de dependencias. Describiremos cada enfoque y proporcionaremos un ejemplo para cada uno. Antes de entrar en los ejemplos, veamos la implementación de una clase Car sin inyección de dependencias.

class Car:
    def __init__(self):
        self.engine = Engine()

    def start(self):
        return self.engine.start()

En esta implementación, la clase Coche crea un objeto Motor en su constructor, lo que resulta en un acoplamiento estrecho entre las clases. Ahora, vamos a explorar las tres técnicas de inyección de dependencia junto con ejemplos para mejorar este código.

Inyección en el Constructor

En este enfoque, las dependencias de una clase se suministran a través de su constructor. En el siguiente ejemplo, la clase Car depende de la clase Engine. Usando la inyección de constructor, la clase Coche recibe una instancia del Motor como argumento del constructor, eliminando así la necesidad de que la clase Coche cree la instancia del Motor por sí misma. De esta forma, la clase Coche sigue dependiendo de la clase Motorpero ya no tiene la responsabilidad de instanciarla.

class Car:
    def __init__(self, engine):
        self.engine = engine

    def start(self):
        return self.engine.start()

Inyección de Setter

Los métodos setter de la clase proporcionan las dependencias. En el siguiente ejemplo, la dependencia del Coche, el motor se inyecta a través del método set_engine.

class Car:
    def __init__(self):
        self.engine = None

    def set_engine(self, engine):
        self.engine = engine

Inyección de parámetros de métodos

Las dependencias se proporcionan como parámetros a los métodos que las utilizan. En el ejemplo siguiente, la dependencia del coche, el motor, se inyecta a través del método start.

class Car:
    def start(self, engine):
        return engine.start()

Cada enfoque tiene sus ventajas y casos de uso. Las inyecciones de constructor y setter son las más utilizadas, ya que equilibran flexibilidad y simplicidad.

Implementación de la inyección de dependencias en Python


Inyectando dependencias a través del constructor, podemos estar seguros de que todas las dependencias están correctamente configuradas.

La figura que se presenta a continuación ilustra los componentes que utilizaremos a lo largo de este artículo. Tenemos un único Caso de Uso llamado CreatePersonAndOrderUseCase que crea un Pedido y una Persona y los almacena en una base de datos SQLite utilizando una única transacción.

En nuestro proyecto, utilizamos un único archivo principal responsable de construir e inyectar todas las dependencias en el caso de uso. Puede consultar el código fuente a continuación para una comprensión detallada de la implementación.

@contextmanager
def create_database_connection():
    db_connection = sqlite3.connect("./db/data.db")
    try:
        yield db_connection
    finally:
        db_connection.close()


with create_database_connection() as conn:
    connection = SQLiteConnection(conn)
    person_repository = SQLitePersonRepository(conn)
    order_repository = SQLiteOrderRepository(conn)

    unit_of_work = UnitOfWork(connection, person_repository,
                              order_repository)
    create_use_case = CreatePersonAndOrderUseCase(unit_of_work)

    new_person = Person(name="John Doe", age=30)
    new_order = Order(person_id=None, order_date="2023-04-03",
                      total_amount=100.0)

    person, order = create_use_case.execute(new_person, new_order)

Cuando creamos un gráfico de dependencias basado en el código proporcionado, la visualización resultante es la siguiente:

La visualización ilustra que CreatePersonAndOrderUseCase en la parte inferior depende del componente UnitOfWork, que depende de tres dependencias específicas: SQLiteConnection, SQLitePersonRepository y SQLiteOrderRepository.

Examinando el código fuente, podemos ver que al inicializar el CreatePersonAndOrderUseCase, inyectamos una instancia de la clase UnitOfWork. Del mismo modo, al crear la UnitOfWork, inyectamos instancias de SQLiteConnection, SQLitePersonRepository y SQLiteOrderRepository.

Inyección manual de dependencias


Este proceso se denomina inyección manual de dependencias. Implica crear e inyectar explícitamente todas las instancias en las clases necesarias, lo que garantiza una separación clara de las preocupaciones y mejora la capacidad de mantenimiento del código.

A medida que la aplicación crece en complejidad y tamaño, la gestión de dependencias mediante la inyección manual de dependencias puede resultar cada vez más tediosa y difícil de mantener.

Uso del patrón constructor (fluido)


Una opción para mejorar la mantenibilidad es el uso del patrón Builder. El patrón Builder es un patrón de diseño de creación que separa la construcción de objetos complejos de su representación.

Esto permite que el mismo proceso de construcción cree diferentes representaciones de objetos a través de una interfaz encadenable paso a paso.

Véase a continuación una implementación de UseCaseBuilder, que emplea el patrón Fluent Builder, una versión especializada del patrón Builder, para construir una instancia de la clase CreatePersonAndOrderUseCase.

class UseCaseBuilder:
    def __init__(self):
        self._connection = None
        self._person_repository = None
        self._order_repository = None

    def with_connection(self, connection):
        self._connection = connection
        return self

    def with_person_repository(self, person_repository):
        self._person_repository = person_repository
        return self

    def with_order_repository(self, order_repository):
        self._order_repository = order_repository
        return self

    def build(self):
        if not self._connection or not self._person_repository or not self._order_repository:
            raise ValueError("All dependencies must be provided before building the use case.")

        unit_of_work = UnitOfWork(self._connection, self._person_repository, self._order_repository)
        return CreatePersonAndOrderUseCase(unit_of_work)


# Usage
with create_database_connection() as conn:
    builder = UseCaseBuilder()

    use_case = (
        builder.with_connection(SQLiteConnection(conn))
        .with_person_repository(SQLitePersonRepository(conn))
        .with_order_repository(SQLiteOrderRepository(conn))
        .build()
    )

    new_person = Person(name="John Doe", age=30)
    new_order = Order(person_id=None, order_date="2023-04-03", total_amount=100.0)

    person, order = use_case.execute(new_person, new_order)

Otra opción es utilizar un framework de inyección de dependencias. Un framework de inyección de dependencias (DI) es una librería o herramienta que ayuda a gestionar y automatizar el proceso de inyección de dependencias en aplicaciones Python.

En el próximo capítulo, exploraremos dos marcos de inyección de dependencias (DI) de Python y demostraremos su uso. Además, te guiaremos a través de la creación de un framework DI simple y personalizado adaptado a tus necesidades.

Uso de Frameworks DI en Python

Un framework de inyección de dependencias (DI) simplifica la gestión de dependencias y mejora la mantenibilidad del código. El framework crea y suministra dependencias a los componentes requeridos, reduciendo la gestión manual de dependencias y el código repetitivo.

Dados los numerosos frameworks de inyección de dependencias de Python, nos centraremos en dos de los más populares basándonos en su número de estrellas en GitHub.

Exploraremos tres enfoques diferentes para la gestión de dependencias. En primer lugar, crearemos una solución DI personalizada para comprender mejor el funcionamiento interno de los frameworks DI. Después, examinaremos los frameworks DI de Python: Dependency Injector e Injector, y compararemos sus características e implementaciones.

Un simple contenedor DI personalizado


La mayoría de los frameworks de inyección de dependencias (DI) utilizan el concepto de contenedor. Usted registra todas las dependencias de su aplicación dentro de este contenedor. Cuando necesitas una instancia de una clase, en lugar de crearla directamente, la solicitas al contenedor.

Si la instancia solicitada tiene dependencias, el contenedor las creará e inyectará automáticamente según sea necesario.

A continuación se muestra nuestra primera implementación de un contenedor DI personalizado. Presenta dos métodos clave: register y resolve. El método register permite añadir tipos al contenedor, mientras que el método resolve crea y devuelve una instancia de un tipo especificado y sus dependencias.

import inspect

class SimpleContainer:
    def __init__(self):
        self._registry = {}

    def register(self, cls):
        self._registry[cls] = cls

    def resolve(self, cls):
        if cls not in self._registry:
            raise ValueError(f"{cls} is not registered in the container.")

        target_cls = self._registry[cls]
        constructor_params = inspect.signature(target_cls.__init__).parameters.values()
        dependencies = [
            self.resolve(param.annotation)
            for param in constructor_params
            if param.annotation is not inspect.Parameter.empty
        ]
        return target_cls(*dependencies)

El método resolve del SimpleContainer comienza comprobando si el tipo de clase solicitado está disponible en el diccionario del registro. Si se encuentra el tipo de clase, emplea el método inspect.signature para obtener todos los parámetros del constructor para el tipo especificado.

A continuación, el método utiliza una comprensión de lista para llamar recursivamente a resolve para cada argumento del constructor, asegurándose de que se resuelven todas las dependencias.

Por último, el método resolve crea y devuelve una instancia del tipo de clase solicitado, con todas sus dependencias correctamente inyectadas.

El método resolve del SimpleContainer comienza comprobando si el tipo de clase solicitado está disponible en el diccionario del registro. Si se encuentra el tipo de clase, emplea el método inspect.signature para obtener todos los parámetros del constructor para el tipo especificado. A continuación, el método utiliza una comprensión de lista para llamar recursivamente a resolve para cada argumento del constructor, asegurándose de que se resuelven todas las dependencias.

Por último, el método resolve crea y devuelve una instancia del tipo de clase solicitado, con todas sus dependencias correctamente inyectadas.

El método resolve del SimpleContainer comienza comprobando si el tipo de clase solicitado está disponible en el diccionario del registro. Si se encuentra el tipo de clase, emplea el método inspect.signature para obtener todos los parámetros del constructor para el tipo especificado. A continuación, el método utiliza una comprensión de lista para llamar recursivamente a resolve para cada argumento del constructor, asegurándose de que se resuelven todas las dependencias.

Por último, el método resolve crea y devuelve una instancia del tipo de clase solicitado, con todas sus dependencias correctamente inyectadas.

⚠️ Advertencia: manejo de clases base como argumentos del constructor
Nuestro SimpleContainer funciona perfectamente cuando las clases base no se utilizan como argumentos del constructor. Sin embargo, en nuestros ejemplos anteriores, se utilizaron clases base como BaseRepository en los argumentos del constructor. En estos casos, la implementación actual de SimpleContainer no funciona correctamente. No tiene en cuenta la resolución de clases derivadas cuando se espera una clase base.

Un contenedor DI personalizado mejorado


Para gestionar correctamente las clases base y sus clases derivadas, debemos ampliar el contenedor DI con lógica adicional para manejarlas como argumentos de constructor.

La implementación mejorada del Contenedor, como se demuestra a continuación, incorpora la funcionalidad necesaria para acomodar los argumentos del constructor de la clase base, haciéndolo más versátil y adecuado para una gama más amplia de casos de uso.

En comparación con la clase SimpleContainer mostrada anteriormente, la clase Container mejorada presenta ahora un método register actualizado que acepta dos argumentos: el tipo y la implementación a utilizar.

import inspect


class Container:
    def __init__(self):
        self._registry = {}

    def register(self, dependency_type, implementation=None):
        if not implementation:
            implementation = dependency_type

        for base in inspect.getmro(implementation):
            if base not in (object, dependency_type):
                self._registry[base] = implementation

        self._registry[dependency_type] = implementation

    def resolve(self, dependency_type):
        if dependency_type not in self._registry:
            raise ValueError(f"Dependency {dependency_type} not registered")
        implementation = self._registry[dependency_type]
        constructor_signature = inspect.signature(implementation.__init__)
        constructor_params = constructor_signature.parameters.values()

        dependencies = [
            self.resolve(param.annotation)
            for param in constructor_params
            if param.annotation is not inspect.Parameter.empty
        ]

        return implementation(*dependencies)

La nueva parte de la implementación del registro es el bucle for que utiliza inspect.getmro. Recorre el orden de resolución de métodos (MRO) de la clase de implementación proporcionada. El MRO es el orden en el que Python busca un método en la jerarquía de clases. Esto se hace usando la función inspect.getmro() que devuelve una tupla de clases de las que deriva la clase dada.

A continuación, el método comprueba si la clase base actual del MRO no es objeto y no es el dependency_type original. Si se cumplen ambas condiciones, el método registra la clase de implementación para la clase base actual en el _registro. Esto permite al contenedor resolver la dependencia para el dependency_type específico y cualquier clase base intermedia en la jerarquía de clases.

Con este contenedor mejorado, podemos aplicarlo a nuestro ejemplo.

En primer lugar, utilizamos el método register para registrar los tipos necesarios y, a continuación, empleamos el método resolve para crear la instancia create_use_case

container = Container()
container.register(BaseConnection, InMemoryConnection)
container.register(BaseRepository[Person], InMemoryPersonRepository)
container.register(BaseRepository[Order], InMemoryOrderRepository)
container.register(UnitOfWork)
container.register(CreatePersonAndOrderUseCase)

create_use_case = container.resolve(CreatePersonAndOrderUseCase)

new_person = Person(id=1, name="John Doe", age=30)
new_order = Order(id=1, order_date="2023-04-03", total_amount=100.0)

person, order = create_use_case.execute(new_person, new_order)
print(person, order)

Inyector de dependencias


El primer framework DI que exploraremos es Dependency Injector.

Dependency Injector es un popular framework de inyección de dependencias diseñado específicamente para aplicaciones Python.

Antes de usar Dependency Injector, debes instalarlo usando pip install dependency_injector.

En el siguiente ejemplo, demostramos cómo utilizar Dependency Injector. Al utilizar este framework, debes crear una clase contenedora y registrar tus tipos.

class Container(containers.DeclarativeContainer):
    connection = providers.Singleton(
        InMemoryConnection
    )

    person_repository = providers.Singleton(
        InMemoryPersonRepository
    )

    order_repository = providers.Singleton(
        InMemoryOrderRepository
    )

    unit_of_work = providers.Singleton(
        UnitOfWork,
        connection=connection,
        person_repository=person_repository,
        order_repository=order_repository
    )

    create_use_case = providers.Factory(
        CreatePersonAndOrderUseCase,
        unit_of_work=unit_of_work
    )


if __name__ == '__main__':
    container = Container()
    create_use_case = container.create_use_case()

    new_person = Person(id=1, name="John Doe", age=30)
    new_order = Order(id=1, order_date="2023-04-03", total_amount=100.0)

    person, order = create_use_case.execute(new_person, new_order)
    print(person, order)

En este código, creamos una clase Container que hereda de declarativeContainer en la biblioteca dependency_injector. A continuación, definimos proveedores para cada clase que deba inyectarse (es decir, InMemoryPersonRepository, InMemoryOrderRepository, InMemoryConnection, UnitOfWork y CreatePersonAndOrderUseCase).

También especificamos las dependencias para los proveedores UnitOfWork y CreatePersonAndOrderUseCase pasando los proveedores connection, person_repository y order_repository.

Es importante tener en cuenta que este ejemplo no hace más que arañar la superficie de lo que es posible con Dependency Injector.

El framework proporciona muchas características, incluyendo scoping avanzado, lazy evaluation, e incluso soporte para la integración con frameworks web populares como Flask y FastAPI.

Explorando estas capacidades, puedes mejorar y agilizar aún más tu proceso de gestión de dependencias, permitiendo un código más eficiente y mantenible en tus proyectos Python.

Para más información, consulta la documentación del inyector de dependencias

Inyector


Injector es otro popular framework de inyección de dependencias de Python. Utiliza anotaciones, sugerencias de tipo y proveedores para conectar dependencias y gestionar la vida de los objetos.

Si miras el ejemplo de abajo, utiliza el framework Injector para implementar el mismo ejemplo. Injector utiliza decoradores como @provider y @singleton para registrar los tipos.

class AppModule(Module):
    @singleton
    @provider
    def provide_connection(self) -> InMemoryConnection:
        return InMemoryConnection()

    @singleton
    @provider
    def provide_person_repository(self) -> InMemoryPersonRepository:
        return InMemoryPersonRepository()

    @singleton
    @provider
    def provide_order_repository(self) -> InMemoryOrderRepository:
        return InMemoryOrderRepository()

    @inject
    @singleton
    @provider
    def provide_unit_of_work(self,
                             connection: InMemoryConnection,
                             person_repository: InMemoryPersonRepository,
                             order_repository: InMemoryOrderRepository) -> UnitOfWork:
        return UnitOfWork(connection, person_repository, order_repository)

    @inject
    @singleton
    @provider
    def provide_create_use_case(self, unit_of_work: UnitOfWork) -> CreatePersonAndOrderUseCase:
        return CreatePersonAndOrderUseCase(unit_of_work)


injector = Injector(AppModule())
create_use_case = injector.get(CreatePersonAndOrderUseCase)

new_person = Person(id=1, name="John Doe", age=30)
new_order = Order(id=1, order_date="2023-04-03", total_amount=100.0)

person, order = create_use_case.execute(new_person, new_order)
print(person, order)

Al igual que con el Inyector de Dependencias, es importante tener en cuenta que este ejemplo sólo araña lo que es posible con Injector.

El framework proporciona muchas características, automáticamente y transitivamente proporciona dependencias para ti. Como beneficio adicional, Injector fomenta el código bien compartimentado mediante el uso de :ref:modules

Para obtener más información, consulte la documentación de Injector.

Comparación de marcos DI


Las tres soluciones  Custom Container, Dependency Injector, e Injector pueden gestionar dependencias entre componentes en un proyecto.

Aunque su enfoque, características y complejidad difieren, el patrón subyacente permanece consistente en todos ellos. En cada marco, el proceso comienza con el registro de las clases, seguido de la solicitud de las instancias.

Este patrón fundamental garantiza una gestión más organizada y eficiente de las dependencias, independientemente del marco DI específico que se utilice.

Contenedor personalizado


Un contenedor personalizado es un contenedor de inyección de dependencias escrito a mano y adaptado a tu proyecto. Normalmente implica escribir una clase contenedora que gestiona la creación de objetos, el ciclo de vida y la resolución de dependencias. El uso de un contenedor personalizado te da un control total sobre la implementación y le permite decidir cómo manejar casos de uso específicos. Sin embargo, puede necesitar más robustez y características de una librería de inyección de dependencias dedicada.

Ventajas

  • Control total sobre la implementación.
  • Sencillo y fácil de entender.

Contras

  • Limitado en características comparado con librerías dedicadas.
  • Requiere implementación manual para escenarios complejos.
  • Puede resultar difícil de mantener a medida que crece el proyecto.

Inyector de dependencias


Dependency Injector es una librería de inyección de dependencias más completa y compleja. Ofrece varias características como gestión de configuración, gestión del ciclo de vida del contenedor, soporte para inyección asíncrona y más. Proporciona una solución más potente y flexible para la gestión de dependencias en un proyecto.

Ventajas

  • Un amplio conjunto de características.
  • Soporta diferentes tipos de inyecciones (constructor, atributo, método).
  • Soporta inyecciones asíncronas.
  • Gestión de configuración integrada.
  • Bien documentado con ejemplos.

Contras

  • Mayor curva de aprendizaje en comparación con otras soluciones.
  • Más complejo y puede ser excesivo para proyectos pequeños.

Injector


Injector es una librería de inyección de dependencias ligera y fácil de usar inspirada en Guice (una librería de inyección de dependencias de Java). Se centra en la simplicidad, lo que la convierte en una buena opción para proyectos pequeños y medianos. Injector proporciona una forma sencilla de manejar la inyección de dependencias sin la complejidad de las bibliotecas más ricas en características.

Ventajas

  • Fácil de aprender y utilizar.
  • Ligero, adecuado para proyectos pequeños y medianos.
  • Proporciona las características básicas necesarias para la inyección de dependencias.

Contras

  • Menos funcionalidades comparado con Dependency Injector.
  • Carece de algunas características avanzadas como la gestión de la configuración integrada.

En resumen, un Custom Container es lo mejor para proyectos pequeños en los que se prefiere la simplicidad y el control total sobre la implementación. Injector es una buena opción para proyectos pequeños y medianos que necesitan una biblioteca de inyección de dependencias ligera y fácil de usar.

Dependency Injector es ideal para proyectos más grandes que requieren una solución de inyección de dependencias más rica en características y potente con soporte para casos de uso avanzados.

Conclusión


Este artículo dedicado a explorar varios patrones de diseño arquitectónico probados en Python con el objetivo de gestionar la complejidad.

Este artículo examinó la inyección de dependencias en Python, un poderoso patrón de diseño de software que promueve el acoplamiento flexible, la modularidad y la comprobabilidad de las aplicaciones.

Proporcionamos una visión general de la inyección de dependencias en Python, comenzando por los conceptos básicos, seguidos de los ejemplos de inyección de constructores y definidores.

Además, el artículo explora el uso de diferentes frameworks de inyección de dependencias en Python, como Custom Container, Dependency Injector e Injector.

Cada marco de trabajo tiene ventajas y desventajas únicas, haciéndolos adecuados para diferentes requisitos de proyecto y niveles de complejidad. Desde simples contenedores personalizados para proyectos pequeños hasta librerías más ricas en funciones como Dependency Injector para proyectos más grandes.

Dado que la gestión de dependencias es crucial en la construcción de software mantenible y escalable, la comprensión e implementación de la inyección de dependencias puede mejorar significativamente la calidad general y la robustez de tus aplicaciones Python.

Al aprovechar las herramientas y técnicas adecuadas, los desarrolladores pueden crear código flexible, comprobable y modular, lo que en última instancia conduce a un mejor diseño y arquitectura de software.

Todos los ejemplos de código tratados en este artículo se encuentran en el repositorio GitHub adjunto.

Fuente

Plataforma de cursos gratis sobre programación x