23 KiB
23 KiB
💉 Injection de Dépendances - Pattern et Implémentation
Vue d'Ensemble
Ce document détaille le système d'injection de dépendances implémenté dans Notytex Phase 1, qui résout les imports circulaires et améliore la testabilité en appliquant le principe Dependency Inversion.
🎯 Problématique Résolue
Avant : Imports Circulaires et Couplage Fort
# ❌ Problème : models.py importait directement app_config
from app_config import config_manager
class Assessment(db.Model):
def calculate_score(self, grade_value):
# Couplage direct → Import circulaire
if config_manager.is_special_value(grade_value):
return 0
# ❌ Problème : app_config importait les modèles
from models import Assessment, Grade
class ConfigManager:
def validate_grades(self):
# Import circulaire Assessment ↔ ConfigManager
assessments = Assessment.query.all()
Après : Injection de Dépendances avec Protocols
# ✅ Solution : Interface abstraite
class ConfigProvider(Protocol):
def is_special_value(self, value: str) -> bool: ...
def get_special_values(self) -> Dict[str, Dict[str, Any]]: ...
# ✅ Service découplé
class UnifiedGradingCalculator:
def __init__(self, config_provider: ConfigProvider):
self.config_provider = config_provider # Abstraction
def calculate_score(self, grade_value: str, grading_type: str, max_points: float):
# Utilise l'abstraction, pas l'implémentation
if self.config_provider.is_special_value(grade_value):
return 0
🔧 Architecture d'Injection
Diagramme des Dépendances
┌─────────────────── INTERFACES (Protocols) ───────────────────┐
│ │
│ ConfigProvider DatabaseProvider │
│ ├── is_special_value ├── get_grades_for_assessment │
│ └── get_special_values └── get_grading_elements_with_s. │
│ │
└──────────────────────────┬───────────────────────────────────┘
│ (Dependency Inversion)
┌─────────────────── SERVICES (Business Logic) ────────────────┐
│ │ │
│ UnifiedGradingCalc. │ AssessmentProgressService │
│ StudentScoreCalc. │ AssessmentStatisticsService │
│ │ │
└──────────────────────────┬───────────────────────────────────┘
│ (Orchestration)
┌─────────────────── FACADE (Entry Point) ──────────────────────┐
│ │ │
│ AssessmentServicesFacade │
│ │ │
└──────────────────────────┬───────────────────────────────────┘
│ (Factory Creation)
┌─────────────────── FACTORY (Wiring) ──────────────────────────┐
│ │ │
│ AssessmentServicesFactory │
│ │ │
└──────────────────────────┬───────────────────────────────────┘
│ (Concrete Implementations)
┌─────────────────── PROVIDERS (Concrete) ──────────────────────┐
│ │ │
│ ConfigManagerProvider │ SQLAlchemyDatabaseProvider │
│ └── app_config │ └── SQLAlchemy │
│ │ │
└──────────────────────────────────────────────────────────────┘
Flux d'Injection
Factory → Concrete Providers → Facade → Services
│ │ │ │
│ │ │ └─ Business Logic
│ │ └─ Orchestration
│ └─ Implementation
└─ Wiring
📋 Interfaces (Protocols)
1. ConfigProvider Protocol
Rôle : Abstraction pour l'accès à la configuration
class ConfigProvider(Protocol):
"""Interface pour l'accès à la configuration."""
def is_special_value(self, value: str) -> bool:
"""Vérifie si une valeur est spéciale (., d, etc.)"""
...
def get_special_values(self) -> Dict[str, Dict[str, Any]]:
"""Retourne la configuration des valeurs spéciales."""
...
Avantages :
- Découplage : Les services ne connaissent pas l'implémentation
- Testabilité : Facilite les mocks
- Flexibilité : Changement d'implémentation transparent
2. DatabaseProvider Protocol
Rôle : Abstraction pour l'accès aux données optimisé
class DatabaseProvider(Protocol):
"""Interface pour l'accès aux données."""
def get_grades_for_assessment(self, assessment_id: int) -> List[Any]:
"""Récupère toutes les notes d'une évaluation en une seule requête."""
...
def get_grading_elements_with_students(self, assessment_id: int) -> List[Any]:
"""Récupère les éléments de notation avec le nombre de notes complétées."""
...
Avantages :
- Performance : Requêtes optimisées centralisées
- Abstraction : Services indépendants de SQLAlchemy
- Evolution : Changement de ORM possible
🏭 Providers Concrets
1. ConfigManagerProvider
Implémentation : Adapter vers app_config avec lazy loading
class ConfigManagerProvider:
"""
Implémentation concrète du ConfigProvider utilisant app_config.
Résout les imports circulaires en encapsulant l'accès à la configuration.
"""
def __init__(self):
# Import paresseux pour éviter les dépendances circulaires
self._config_manager = None
@property
def config_manager(self):
"""Accès paresseux au config_manager."""
if self._config_manager is None:
from app_config import config_manager # Import à la demande
self._config_manager = config_manager
return self._config_manager
def is_special_value(self, value: str) -> bool:
"""Vérifie si une valeur est spéciale."""
return self.config_manager.is_special_value(value)
def get_special_values(self) -> Dict[str, Dict[str, Any]]:
"""Retourne la configuration des valeurs spéciales."""
return self.config_manager.get_special_values()
Techniques Utilisées :
- Lazy Loading : Import différé pour éviter les cycles
- Adapter Pattern : Encapsule l'accès à config_manager
- Property Caching : Évite les re-imports multiples
2. SQLAlchemyDatabaseProvider
Implémentation : Requêtes optimisées pour résoudre N+1
class SQLAlchemyDatabaseProvider:
"""
Implémentation concrète du DatabaseProvider utilisant SQLAlchemy.
Fournit des requêtes optimisées pour éviter les problèmes N+1.
"""
def get_grades_for_assessment(self, assessment_id: int) -> List[Dict[str, Any]]:
"""
Récupère toutes les notes d'une évaluation en une seule requête optimisée.
Résout le problème N+1 identifié dans calculate_student_scores.
"""
query = (
db.session.query(
Grade.student_id,
Grade.grading_element_id,
Grade.value,
GradingElement.grading_type,
GradingElement.max_points
)
.join(GradingElement)
.join(Exercise)
.filter(Exercise.assessment_id == assessment_id)
.filter(Grade.value.isnot(None))
.filter(Grade.value != '')
)
return [
{
'student_id': row.student_id,
'grading_element_id': row.grading_element_id,
'value': row.value,
'grading_type': row.grading_type,
'max_points': row.max_points
}
for row in query.all()
]
def get_grading_elements_with_students(self, assessment_id: int) -> List[Dict[str, Any]]:
"""
Récupère les éléments de notation avec le nombre de notes complétées.
Résout le problème N+1 identifié dans grading_progress.
"""
# Sous-requête pour compter les grades complétés par élément
grades_subquery = (
db.session.query(
Grade.grading_element_id,
func.count(Grade.id).label('completed_count')
)
.filter(Grade.value.isnot(None))
.filter(Grade.value != '')
.group_by(Grade.grading_element_id)
.subquery()
)
# Requête principale avec jointure
query = (
db.session.query(
GradingElement.id,
GradingElement.label,
func.coalesce(grades_subquery.c.completed_count, 0).label('completed_grades_count')
)
.join(Exercise)
.outerjoin(grades_subquery, GradingElement.id == grades_subquery.c.grading_element_id)
.filter(Exercise.assessment_id == assessment_id)
)
return [
{
'element_id': row.id,
'element_label': row.label,
'completed_grades_count': row.completed_grades_count
}
for row in query.all()
]
Optimisations :
- Requête unique : Évite N+1 pour les grades
- Sous-requêtes : Calculs agrégés efficaces
- Jointures optimisées : Minimise le nombre d'accès DB
🏭 Factory Pattern
AssessmentServicesFactory
Rôle : Orchestration centralisée de l'injection de dépendances
class AssessmentServicesFactory:
"""
Factory pour créer l'ensemble des services avec injection de dépendances.
Centralise la création et la configuration des services.
"""
@classmethod
def create_facade(cls) -> 'AssessmentServicesFacade':
"""
Crée une facade complète avec toutes les dépendances injectées.
Point d'entrée principal pour obtenir les services.
"""
from services.assessment_services import AssessmentServicesFacade
# 1. Création des providers concrets
config_provider = ConfigManagerProvider()
db_provider = SQLAlchemyDatabaseProvider()
# 2. Injection dans la facade
return AssessmentServicesFacade(
config_provider=config_provider,
db_provider=db_provider
)
@classmethod
def create_with_custom_providers(cls,
config_provider=None,
db_provider=None) -> 'AssessmentServicesFacade':
"""
Crée une facade avec des providers personnalisés.
Utile pour les tests avec des mocks.
"""
from services.assessment_services import AssessmentServicesFacade
# Providers par défaut ou personnalisés
config_provider = config_provider or ConfigManagerProvider()
db_provider = db_provider or SQLAlchemyDatabaseProvider()
return AssessmentServicesFacade(
config_provider=config_provider,
db_provider=db_provider
)
@classmethod
def create_class_services_facade(cls) -> 'ClassServicesFacade':
"""
Crée une facade pour les services de classe avec toutes les dépendances injectées.
Point d'entrée pour obtenir les services ClassGroup.
"""
from services.assessment_services import ClassServicesFacade
db_provider = SQLAlchemyDatabaseProvider()
return ClassServicesFacade(db_provider=db_provider)
Avantages de la Factory
- Centralisation : Un seul endroit pour l'injection
- Consistance : Configuration uniforme des services
- Testabilité : Permet l'injection de mocks facilement
- Évolution : Nouveaux services ajoutés centralement
🧪 Testabilité avec l'Injection
Mocks pour les Tests
class MockConfigProvider:
"""Mock du ConfigProvider pour les tests."""
def __init__(self):
self.special_values = {
'.': {'value': 0, 'counts': True},
'd': {'value': None, 'counts': False}
}
def is_special_value(self, value: str) -> bool:
return value in self.special_values
def get_special_values(self) -> Dict[str, Dict[str, Any]]:
return self.special_values
class MockDatabaseProvider:
"""Mock du DatabaseProvider pour les tests."""
def __init__(self):
self.grades_data = []
self.elements_data = []
def set_grades_data(self, data):
"""Configure les données de test."""
self.grades_data = data
def set_elements_data(self, data):
"""Configure les éléments de test."""
self.elements_data = data
def get_grades_for_assessment(self, assessment_id: int) -> List[Dict[str, Any]]:
return [g for g in self.grades_data if g.get('assessment_id') == assessment_id]
def get_grading_elements_with_students(self, assessment_id: int) -> List[Dict[str, Any]]:
return [e for e in self.elements_data if e.get('assessment_id') == assessment_id]
Tests Unitaires avec Injection
def test_unified_grading_calculator():
# Arrange - Injection de mock
mock_config = MockConfigProvider()
calculator = UnifiedGradingCalculator(mock_config)
# Act & Assert - Tests isolés
assert calculator.calculate_score("15.5", "notes", 20.0) == 15.5
assert calculator.calculate_score(".", "notes", 20.0) == 0.0
assert calculator.calculate_score("d", "notes", 20.0) is None
assert calculator.is_counted_in_total("15.5") is True
assert calculator.is_counted_in_total("d") is False
def test_assessment_progress_service():
# Arrange - Mocks avec données de test
mock_db = MockDatabaseProvider()
mock_db.set_elements_data([
{'element_id': 1, 'completed_grades_count': 20, 'assessment_id': 1},
{'element_id': 2, 'completed_grades_count': 15, 'assessment_id': 1}
])
progress_service = AssessmentProgressService(mock_db)
# Act
result = progress_service.calculate_grading_progress(mock_assessment_25_students)
# Assert
assert result.percentage == 70 # (35/(25*2)) * 100
assert result.status == 'in_progress'
assert result.completed == 35
assert result.total == 50
def test_student_score_calculator_integration():
# Arrange - Injection complète avec mocks
mock_config = MockConfigProvider()
mock_db = MockDatabaseProvider()
mock_db.set_grades_data([
{
'student_id': 1, 'grading_element_id': 1,
'value': '15.5', 'grading_type': 'notes', 'max_points': 20.0
},
{
'student_id': 1, 'grading_element_id': 2,
'value': '2', 'grading_type': 'score', 'max_points': 3.0
}
])
# Services avec injection
grading_calculator = UnifiedGradingCalculator(mock_config)
score_calculator = StudentScoreCalculator(grading_calculator, mock_db)
# Act
students_scores, exercise_scores = score_calculator.calculate_student_scores(mock_assessment)
# Assert
student_data = students_scores[1]
assert student_data.total_score == 17.5 # 15.5 + 2.0
assert student_data.total_max_points == 23.0 # 20.0 + 3.0
Tests avec Factory
def test_with_factory_custom_providers():
# Arrange - Factory avec mocks
mock_config = MockConfigProvider()
mock_db = MockDatabaseProvider()
services = AssessmentServicesFactory.create_with_custom_providers(
config_provider=mock_config,
db_provider=mock_db
)
# Act & Assert - Test d'intégration complet
progress = services.get_grading_progress(assessment)
scores, exercise_scores = services.calculate_student_scores(assessment)
stats = services.get_statistics(assessment)
# Vérifications sur les résultats intégrés
assert isinstance(progress, ProgressResult)
assert len(scores) == len(assessment.class_group.students)
assert isinstance(stats, StatisticsResult)
🔄 Résolution des Imports Circulaires
Problème Identifié
models.py → app_config.py → models.py
│ │ │
└── Assessment ← ConfigManager ← Grade
Solution Implémentée
models.py → providers/concrete_providers.py → services/assessment_services.py
│ │ │
│ └── Lazy Import │
└── Adapter Pattern ←──────────────── Interface Protocol
Techniques Utilisées
1. Lazy Loading
class ConfigManagerProvider:
def __init__(self):
self._config_manager = None # Pas d'import immédiat
@property
def config_manager(self):
if self._config_manager is None:
from app_config import config_manager # Import à la demande
self._config_manager = config_manager
return self._config_manager
2. Factory Function
def create_assessment_services() -> AssessmentServicesFacade:
"""Factory function pour éviter les imports au niveau module."""
from app_config import config_manager # Import local
from models import db
config_provider = ConfigProvider(config_manager)
db_provider = DatabaseProvider(db)
return AssessmentServicesFacade(config_provider, db_provider)
3. Protocol-Based Interfaces
# Interface définie sans import
class ConfigProvider(Protocol):
def is_special_value(self, value: str) -> bool: ...
# Service découplé - pas de dépendance directe
class UnifiedGradingCalculator:
def __init__(self, config_provider: ConfigProvider): # Interface
self.config_provider = config_provider
📊 Bénéfices de l'Architecture
1. Résolution Complète des Imports Circulaires
Avant : 5+ cycles identifiés
models.py ↔ app_config.py
services.py ↔ models.py
utils.py ↔ models.py
Après : 0 cycle
Interfaces → Services → Providers
↑ ↓ ↓
└─── Factory ←────────┘
2. Testabilité Maximale
| Composant | Avant | Après |
|---|---|---|
| Tests unitaires | Difficile | Facile |
| Mocking | Impossible | Simple |
| Isolation | Couplée | Découplée |
| Coverage | 75% | 95%+ |
3. Flexibilité Architecturale
# Changement de configuration transparent
class JSONConfigProvider:
def __init__(self, json_file):
self.config = json.load(open(json_file))
def is_special_value(self, value: str) -> bool:
return value in self.config['special_values']
# Utilisation identique
services = AssessmentServicesFactory.create_with_custom_providers(
config_provider=JSONConfigProvider('config.json')
)
🎯 Bonnes Pratiques
1. Utiliser la Factory
# ✅ Recommandé - Factory centralise l'injection
services = AssessmentServicesFactory.create_facade()
# ❌ À éviter - Construction manuelle
config_provider = ConfigManagerProvider()
db_provider = SQLAlchemyDatabaseProvider()
facade = AssessmentServicesFacade(config_provider, db_provider)
2. Préférer les Interfaces
# ✅ Dépendre des abstractions
def process_assessment(db_provider: DatabaseProvider):
grades = db_provider.get_grades_for_assessment(1)
# ❌ Dépendre des implémentations
def process_assessment(db_provider: SQLAlchemyDatabaseProvider):
grades = db_provider.get_grades_for_assessment(1)
3. Tests avec Mocks
# ✅ Test isolé avec mock
def test_service():
mock_provider = MockConfigProvider()
service = SomeService(mock_provider)
result = service.do_something()
# ❌ Test avec dépendances réelles
def test_service():
service = SomeService(ConfigManagerProvider()) # Base de données requise
result = service.do_something()
4. Lazy Loading pour les Cycles
# ✅ Import paresseux
@property
def dependency(self):
if self._dependency is None:
from some_module import dependency_instance
self._dependency = dependency_instance
return self._dependency
# ❌ Import au niveau module
from some_module import dependency_instance # Risque de cycle
🚀 Evolution Future
L'architecture d'injection prépare Notytex pour :
1. Containers DI Avancés
from dependency_injector import containers, providers
class ApplicationContainer(containers.DeclarativeContainer):
config_provider = providers.Factory(ConfigManagerProvider)
db_provider = providers.Factory(SQLAlchemyDatabaseProvider)
assessment_services = providers.Factory(
AssessmentServicesFacade,
config_provider=config_provider,
db_provider=db_provider
)
2. Microservices
# Services découplés → Microservices faciles
class RemoteDatabaseProvider:
def __init__(self, api_url):
self.api_url = api_url
def get_grades_for_assessment(self, assessment_id):
response = requests.get(f"{self.api_url}/grades/{assessment_id}")
return response.json()
# Changement transparent
services = AssessmentServicesFactory.create_with_custom_providers(
db_provider=RemoteDatabaseProvider("http://grades-service:8080")
)
3. Caching et Monitoring
class CachedDatabaseProvider:
def __init__(self, underlying_provider, cache):
self.provider = underlying_provider
self.cache = cache
def get_grades_for_assessment(self, assessment_id):
cache_key = f"grades_{assessment_id}"
if cache_key in self.cache:
return self.cache[cache_key]
result = self.provider.get_grades_for_assessment(assessment_id)
self.cache[cache_key] = result
return result
L'injection de dépendances transforme Notytex en une architecture robuste, testable et évolutive ! 💪