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 Motor
pero 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.