Explorez les modèles d'injection de dépendances avancés dans FastAPI pour créer des applications évolutives, maintenables et testables. Apprenez à structurer un conteneur DI robuste.
Injection de dépendances FastAPI : architecture de conteneur DI avancée
FastAPI, avec sa conception intuitive et ses fonctionnalités puissantes, est devenu un favori pour la création d'API Web modernes en Python. L'une de ses principales forces réside dans son intégration transparente avec l'injection de dépendances (DI), permettant aux développeurs de créer des applications faiblement couplées, testables et maintenables. Bien que le système DI intégré de FastAPI soit excellent pour les cas d'utilisation simples, les projets plus complexes bénéficient souvent d'une architecture de conteneur DI plus structurée et avancée. Cet article explore diverses stratégies pour construire une telle architecture, en fournissant des exemples pratiques et des informations pour la conception d'applications robustes et évolutives.
Comprendre l'injection de dépendances (DI) et l'inversion de contrôle (IoC)
Avant de plonger dans les architectures de conteneur DI avancées, clarifions les concepts fondamentaux :
- Injection de dépendances (DI) : Un modèle de conception dans lequel les dépendances sont fournies à un composant à partir de sources externes plutôt que d'être créées en interne. Cela favorise le découplage faible, ce qui facilite les tests et la réutilisation des composants.
- Inversion de contrôle (IoC) : Un principe plus large où le contrôle de la création et de la gestion des objets est inversé – délégué à un framework ou un conteneur. DI est un type spécifique d'IoC.
FastAPI prend en charge intrinsèquement DI grâce à son système de dépendances. Vous définissez les dépendances comme des objets appelables (fonctions, classes, etc.) et FastAPI les résout et les injecte automatiquement dans vos fonctions de point de terminaison ou d'autres dépendances.
Exemple (DI FastAPI de base)Â :
from fastapi import FastAPI, Depends
app = FastAPI()
# Dépendance
def get_db():
db = {"items": []} # Simuler une connexion à la base de données
try:
yield db
finally:
# Fermer la connexion à la base de données (si nécessaire)
pass
# Point de terminaison avec injection de dépendances
@app.get("/items/")
async def read_items(db: dict = Depends(get_db)):
return db["items"]
Dans cet exemple, get_db est une dépendance qui fournit une connexion à la base de données. FastAPI appelle automatiquement get_db et injecte le résultat (le dictionnaire db) dans la fonction de point de terminaison read_items.
Pourquoi un conteneur DI avancé ?
Le DI intégré de FastAPI fonctionne bien pour les projets simples, mais à mesure que les applications gagnent en complexité, un conteneur DI plus sophistiqué offre plusieurs avantages :
- Gestion centralisée des dépendances : Un conteneur dédié fournit une source unique de vérité pour toutes les dépendances, ce qui facilite la gestion et la compréhension des dépendances de l'application.
- Configuration et gestion du cycle de vie : Le conteneur peut gérer la configuration et le cycle de vie des dépendances, telles que la création de singletons, la gestion des connexions et la libération des ressources.
- Testabilité : Un conteneur avancé simplifie les tests en vous permettant de remplacer facilement les dépendances par des objets simulés ou des doublons de test.
- Découplage : Favorise un plus grand découplage entre les composants, réduisant les dépendances et améliorant la maintenabilité du code.
- Extensibilité : Un conteneur extensible vous permet d'ajouter des fonctionnalités et des intégrations personnalisées selon vos besoins.
Stratégies pour la création d'un conteneur DI avancé
Il existe plusieurs approches pour créer un conteneur DI avancé dans FastAPI. Voici quelques stratégies courantes :
1. Utilisation d'une bibliothèque DI dédiée (par exemple, injector, dependency_injector)
Plusieurs bibliothèques DI puissantes sont disponibles pour Python, telles que injector et dependency_injector. Ces bibliothèques fournissent un ensemble complet de fonctionnalités pour la gestion des dépendances, notamment :
- Liaison : Définir comment les dépendances sont résolues et injectées.
- Portées : Contrôler le cycle de vie des dépendances (par exemple, singleton, transitoire).
- Configuration : Gestion des paramètres de configuration pour les dépendances.
- AOP (Programmation orientée aspect) : Interception des appels de méthode pour les préoccupations transversales.
Exemple avec dependency_injector
dependency_injector est un choix populaire pour la création de conteneurs DI. Illustrons son utilisation avec un exemple :
from dependency_injector import containers, providers
from fastapi import FastAPI, Depends
# Définir les dépendances
class Database:
def __init__(self, connection_string: str):
self.connection_string = connection_string
# Initialiser la connexion à la base de données
print(f"Connexion à la base de données : {self.connection_string}")
def get_items(self):
# Simuler la récupération d'éléments de la base de données
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
class UserRepository:
def __init__(self, database: Database):
self.database = database
def get_all_users(self):
# Simulation d'une requête de base de données pour obtenir tous les utilisateurs
return [{"id": "user1", "name": "Alice"},{"id": "user2", "name": "Bob"}]
class Settings:
def __init__(self, database_url):
self.database_url = database_url
# Définir le conteneur
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
settings = providers.Singleton(Settings, database_url = config.database_url)
database = providers.Singleton(Database, connection_string=config.database_url)
user_repository = providers.Factory(UserRepository, database=database)
# Créer une application FastAPI
app = FastAPI()
# Configurer le conteneur (Ă partir d'une variable d'environnement)
container = Container()
container.config.database_url.from_env("DATABASE_URL", default="sqlite:///:memory:")
container.wire([__name__]) # active l'injection de dépendances dans les points de terminaison FastAPI
# Dépendance pour FastAPI
def get_user_repository(user_repository: UserRepository = Depends(container.user_repository.provided)) -> UserRepository:
return user_repository
# Point de terminaison utilisant la dépendance injectée
@app.get("/users/")
async def read_users(user_repository: UserRepository = Depends(get_user_repository)):
return user_repository.get_all_users()
@app.on_event("startup")
async def startup_event():
# Initialisation du conteneur
container.init_resources()
Explication :
- Nous définissons nos dépendances (
Database,UserRepository,Settings) comme des classes Python régulières. - Nous créons une classe
Containerqui hérite decontainers.DeclarativeContainer. Cette classe définit les dépendances et leurs fournisseurs (par exemple,providers.Singletonpour les singletons,providers.Factorypour la création de nouvelles instances à chaque fois). - La ligne
container.wire([__name__])active l'injection de dépendances dans les points de terminaison FastAPI. - La fonction
get_user_repositoryest une dépendance FastAPI qui utilisecontainer.user_repository.providedpour récupérer l'instance UserRepository du conteneur. - La fonction de point de terminaison
read_usersinjecte la dépendanceUserRepository. - La
configvous permet d'externaliser les configurations de dépendance. Elle peut ensuite provenir de variables d'environnement, de fichiers de configuration, etc. - L'
startup_eventest utilisé pour initialiser les ressources gérées dans le conteneur
2. Implémentation d'un conteneur DI personnalisé
Pour un meilleur contrôle sur le processus DI, vous pouvez implémenter un conteneur DI personnalisé. Cette approche nécessite plus d'efforts mais vous permet d'adapter le conteneur à vos besoins spécifiques.
Exemple de conteneur DI personnalisé de base :
from typing import Callable, Dict, Type, Any
from fastapi import FastAPI, Depends
class Container:
def __init__(self):
self.dependencies: Dict[Type[Any], Callable[..., Any]] = {}
self.instances: Dict[Type[Any], Any] = {}
def register(self, dependency_type: Type[Any], provider: Callable[..., Any]):
self.dependencies[dependency_type] = provider
def resolve(self, dependency_type: Type[Any]) -> Any:
if dependency_type in self.instances:
return self.instances[dependency_type]
if dependency_type not in self.dependencies:
raise Exception(f"Dépendance {dependency_type} non enregistrée.")
provider = self.dependencies[dependency_type]
instance = provider()
return instance
def singleton(self, dependency_type: Type[Any], provider: Callable[..., Any]):
self.register(dependency_type, provider)
self.instances[dependency_type] = provider()
# Exemples de dépendances
class PaymentGateway:
def process_payment(self, amount: float) -> bool:
print(f"Traitement du paiement de ${amount}")
return True # Simuler un paiement réussi
class NotificationService:
def send_notification(self, message: str):
print(f"Envoi de la notification : {message}")
# Exemple d'utilisation
container = Container()
container.singleton(PaymentGateway, PaymentGateway)
container.singleton(NotificationService, NotificationService)
app = FastAPI()
# Dépendance FastAPI
def get_payment_gateway(payment_gateway: PaymentGateway = Depends(lambda: container.resolve(PaymentGateway))):
return payment_gateway
def get_notification_service(notification_service: NotificationService = Depends(lambda: container.resolve(NotificationService))):
return notification_service
@app.post("/purchase/")
async def purchase_item(payment_gateway: PaymentGateway = Depends(get_payment_gateway), notification_service: NotificationService = Depends(get_notification_service)):
if payment_gateway.process_payment(100.0):
notification_service.send_notification("Achat réussi !")
return {"message": "Achat réussi"}
else:
return {"message": "Échec de l'achat"}
Explication :
- La classe
Containergère un dictionnaire de dépendances et de leurs fournisseurs. - La méthode
registerenregistre une dépendance auprès de son fournisseur. - La méthode
resolverésout une dépendance en appelant son fournisseur. - La méthode
singletonenregistre une dépendance et crée une seule instance de celle-ci. - Les dépendances FastAPI sont créées à l'aide d'une fonction lambda pour résoudre les dépendances du conteneur.
3. Utilisation de Depends de FastAPI avec une fonction factory
Au lieu d'un conteneur DI complet, vous pouvez utiliser Depends de FastAPI avec des fonctions factory pour atteindre un certain niveau de gestion des dépendances. Cette approche est plus simple que la mise en œuvre d'un conteneur personnalisé, mais offre toujours certains avantages par rapport à l'instanciation directe des dépendances dans les fonctions de point de terminaison.
from fastapi import FastAPI, Depends
from typing import Callable
# Définir les dépendances
class EmailService:
def __init__(self, smtp_server: str):
self.smtp_server = smtp_server
def send_email(self, recipient: str, subject: str, body: str):
print(f"Envoi d'un e-mail Ă {recipient} via {self.smtp_server}Â : {subject} - {body}")
# Fonction factory pour EmailService
def create_email_service(smtp_server: str) -> EmailService:
return EmailService(smtp_server=smtp_server)
# FastAPI
app = FastAPI()
# Dépendance FastAPI, tirant parti de la fonction factory et Depends
def get_email_service(email_service: EmailService = Depends(lambda: create_email_service(smtp_server="smtp.example.com"))):
return email_service
@app.post("/send-email/")
async def send_email(recipient: str, subject: str, body: str, email_service: EmailService = Depends(get_email_service)):
email_service.send_email(recipient=recipient, subject=subject, body=body)
return {"message": "E-mail envoyé !"}
Explication :
- Nous définissons une fonction factory (
create_email_service) qui crée des instances de la dépendanceEmailService. - La dépendance
get_email_serviceutiliseDependset une lambda pour appeler la fonction factory et fournir une instance deEmailService. - La fonction de point de terminaison
send_emailinjecte la dépendanceEmailService.
Considérations avancées
1. Portées et cycles de vie
Les conteneurs DI offrent souvent des fonctionnalités pour la gestion du cycle de vie des dépendances. Les portées courantes incluent :
- Singleton : Une seule instance de la dépendance est créée et réutilisée tout au long de la durée de vie de l'application. Ceci convient aux dépendances sans état ou ayant une portée globale.
- Transitoire : Une nouvelle instance de la dépendance est créée chaque fois qu'elle est demandée. Ceci convient aux dépendances qui ont un état ou qui doivent être isolées les unes des autres.
- Requête : Une seule instance de la dépendance est créée pour chaque requête entrante. Ceci convient aux dépendances qui doivent maintenir un état dans le contexte d'une seule requête.
La bibliothèque dependency_injector fournit une prise en charge intégrée des portées. Pour les conteneurs personnalisés, vous devrez implémenter vous-même la logique de gestion des portées.
2. Configuration
Les dépendances nécessitent souvent des paramètres de configuration, tels que des chaînes de connexion à la base de données, des clés API et des indicateurs de fonctionnalité. Les conteneurs DI peuvent aider à gérer ces paramètres en fournissant un moyen centralisé d'accéder aux valeurs de configuration et de les injecter.
Dans l'exemple dependency_injector, le fournisseur config permet la configuration à partir de variables d'environnement. Pour les conteneurs personnalisés, vous pouvez charger la configuration à partir de fichiers ou de variables d'environnement et les stocker dans le conteneur.
3. Tests
L'un des principaux avantages de DI est l'amélioration de la testabilité. Avec un conteneur DI, vous pouvez facilement remplacer les dépendances réelles par des objets simulés ou des doublons de test pendant les tests.
Exemple (Tests avec dependency_injector)Â :
import pytest
from unittest.mock import MagicMock
from dependency_injector import containers, providers
from fastapi import FastAPI, Depends
from fastapi.testclient import TestClient
# Définir les dépendances (mêmes qu'avant)
class Database:
def __init__(self, connection_string: str):
self.connection_string = connection_string
def get_items(self):
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
class UserRepository:
def __init__(self, database: Database):
self.database = database
def get_all_users(self):
return [{"id": "user1", "name": "Alice"},{"id": "user2", "name": "Bob"}]
class Settings:
def __init__(self, database_url):
self.database_url = database_url
# Définir le conteneur (mêmes qu'avant)
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
settings = providers.Singleton(Settings, database_url = config.database_url)
database = providers.Singleton(Database, connection_string=config.database_url)
user_repository = providers.Factory(UserRepository, database=database)
# Créer une application FastAPI (mêmes qu'avant)
app = FastAPI()
# Configurer le conteneur (Ă partir d'une variable d'environnement)
container = Container()
container.config.database_url.from_env("DATABASE_URL", default="sqlite:///:memory:")
container.wire([__name__]) # active l'injection de dépendances dans les points de terminaison FastAPI
# Dépendance pour FastAPI
def get_user_repository(user_repository: UserRepository = Depends(container.user_repository.provided)) -> UserRepository:
return user_repository
# Point de terminaison utilisant la dépendance injectée (mêmes qu'avant)
@app.get("/users/")
async def read_users(user_repository: UserRepository = Depends(get_user_repository)):
return user_repository.get_all_users()
@app.on_event("startup")
async def startup_event():
# Initialisation du conteneur
container.init_resources()
# Test
@pytest.fixture
def test_client():
# Remplacer la dépendance de la base de données par une simulation
database_mock = MagicMock(spec=Database)
database_mock.get_items.return_value = [{"id": 3, "name": "Test Item"}]
user_repository_mock = MagicMock(spec = UserRepository)
user_repository_mock.get_all_users.return_value = [{"id": "test_user", "name": "Test User"}]
# Remplacer le conteneur par des dépendances simulées
container.user_repository.override(providers.Factory(lambda: user_repository_mock))
with TestClient(app) as client:
yield client
container.user_repository.reset()
def test_read_users(test_client: TestClient):
response = test_client.get("/users/")
assert response.status_code == 200
assert response.json() == [{"id": "test_user", "name": "Test User"}]
Explication :
- Nous créons un objet simulé pour la dépendance
Databaseen utilisantMagicMock. - Nous remplaçons le fournisseur
databasedans le conteneur par l'objet simulé en utilisantcontainer.database.override(). - La fonction de test
test_read_itemsutilise désormais la dépendance de base de données simulée. - Après l'exécution du test, il réinitialise la dépendance remplacée du conteneur.
4. Dépendances asynchrones
FastAPI est basé sur la programmation asynchrone (async/await). Lorsque vous travaillez avec des dépendances asynchrones (par exemple, des connexions de base de données asynchrones), assurez-vous que votre conteneur DI et vos fournisseurs de dépendances prennent en charge les opérations asynchrones.
Exemple (Dépendance asynchrone avec dependency_injector) :
import asyncio
from dependency_injector import containers, providers
from fastapi import FastAPI, Depends
# Définir la dépendance asynchrone
class AsyncDatabase:
def __init__(self, connection_string: str):
self.connection_string = connection_string
async def connect(self):
print(f"Connexion à la base de données : {self.connection_string}")
await asyncio.sleep(0.1) # Simuler le temps de connexion
async def fetch_data(self):
await asyncio.sleep(0.1) # Simuler la requête de base de données
return [{"id": 1, "name": "Item Async 1"}]
# Définir le conteneur
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
database = providers.Singleton(AsyncDatabase, connection_string=config.database_url)
# Créer une application FastAPI
app = FastAPI()
# Configurer le conteneur
container = Container()
container.config.database_url.from_env("DATABASE_URL", default="sqlite:///:memory:")
container.wire([__name__])
# Dépendance pour FastAPI
async def get_async_database(database: AsyncDatabase = Depends(container.database.provided)) -> AsyncDatabase:
await database.connect()
return database
# Point de terminaison utilisant la dépendance injectée
@app.get("/async-items/")
async def read_async_items(database: AsyncDatabase = Depends(get_async_database)):
data = await database.fetch_data()
return data
@app.on_event("startup")
async def startup_event():
# Initialisation du conteneur
container.init_resources()
Explication :
- La classe
AsyncDatabasedéfinit des méthodes asynchrones en utilisantasyncetawait. - La dépendance
get_async_databaseest également définie comme une fonction asynchrone. - La fonction de point de terminaison
read_async_itemsest marquée commeasyncet attend le résultat dedatabase.fetch_data().
Choisir la bonne approche
La meilleure approche pour la création d'un conteneur DI avancé dépend de la complexité de votre application et de vos exigences spécifiques :
- Pour les projets de petite et moyenne taille : le DI intégré de FastAPI ou une approche de fonction factory avec
Dependspeut suffire. - Pour les projets plus importants et plus complexes : Une bibliothèque DI dédiée comme
dependency_injectorfournit un ensemble complet de fonctionnalités pour la gestion des dépendances. - Pour les projets qui nécessitent un contrôle précis sur le processus DI : La mise en œuvre d'un conteneur DI personnalisé peut être la meilleure option.
Conclusion
L'injection de dépendances est une technique puissante pour créer des applications évolutives, maintenables et testables. Bien que le système DI intégré de FastAPI soit excellent pour les cas d'utilisation simples, une architecture de conteneur DI avancée peut offrir des avantages significatifs pour les projets plus complexes. En choisissant la bonne approche et en tirant parti des fonctionnalités des bibliothèques DI ou en implémentant un conteneur personnalisé, vous pouvez créer un système de gestion des dépendances robuste et flexible qui améliore la qualité globale et la maintenabilité de vos applications FastAPI.
Considérations globales
Lors de la conception de conteneurs DI pour les applications globales, il est important de prendre en compte les éléments suivants :
- Localisation : Les dépendances liées à la localisation (par exemple, les paramètres de langue, les formats de date) doivent être gérées par le conteneur DI pour assurer la cohérence entre les différentes régions.
- Fuseaux horaires : Les dépendances qui gèrent les conversions de fuseaux horaires doivent être injectées pour éviter de coder en dur les informations de fuseau horaire.
- Devise : Les dépendances pour la conversion et le formatage des devises doivent être gérées par le conteneur pour prendre en charge différentes devises.
- Paramètres régionaux : D'autres paramètres régionaux, tels que les formats de nombres et les formats d'adresse, doivent également être gérés par le conteneur DI.
- Multilocataire : Pour les applications multilocataires, le conteneur DI doit être en mesure de fournir différentes dépendances pour différents locataires. Ceci peut être réalisé en utilisant des portées ou une logique de résolution de dépendance personnalisée.
- Conformité et sécurité : Assurez-vous que votre stratégie de gestion des dépendances est conforme aux réglementations pertinentes en matière de confidentialité des données (par exemple, RGPD, CCPA) et aux meilleures pratiques de sécurité dans diverses régions. Gérez les informations d'identification sensibles et les configurations en toute sécurité au sein du conteneur.
En tenant compte de ces facteurs globaux, vous pouvez créer des conteneurs DI qui sont bien adaptés à la création d'applications fonctionnant dans un environnement mondial.