Introduction à l'Injection de Dépendances en Rust
L'Injection de Dépendances (DI) est un modèle de conception dans lequel un objet reçoit ses dépendances de l'extérieur, plutôt que de les créer lui-même. Dans l'écosystème Rust, cette approche revêt une importance particulière grâce au système de typage strict et à la gestion de la propriété. La DI permet de rendre le code plus modulaire, testable et maintenable, ce qui est particulièrement important pour les grands projets.
Contrairement aux langages à typage dynamique ou avec ramasse-miettes, Rust nécessite une approche spécifique pour l'injection de dépendances. Il n'y a pas de réflexion ni de conteneurs runtime au sens classique. À la place, les développeurs utilisent des mécanismes statiques : trait-objets, generics et composition via des structures.
Dans cet article, nous examinerons trois approches principales pour implémenter l'Injection de Dépendances en Rust, en commençant par l'injection manuelle la plus simple jusqu'à l'utilisation de bibliothèques. Chaque approche sera illustrée par des exemples de code pratiques.
1. Injection manuelle des dépendances (Manual DI)
La manière la plus simple et la plus transparente consiste à passer les dépendances via le constructeur de la structure. Cette approche ne nécessite pas de bibliothèques supplémentaires et est entièrement contrôlée par le compilateur Rust.
Exemple : Logger et Service
// Définition du trait pour la journalisationtrait Logger { fn log(&self, message: &str);}
// Implémentation du logger pour la productionstruct ConsoleLogger;
impl Logger for ConsoleLogger { fn log(&self, message: &str) { println!("[LOG]: {}", message); }}
// Implémentation pour les testsstruct MockLogger { pub messages: Vec<String>,}
impl Logger for MockLogger { fn log(&self, message: &str) { self.messages.push(message.to_string()); }}
// Service dépendant de Loggerstruct UserService<T: Logger> { logger: T,}
impl<T: Logger> UserService<T> { fn new(logger: T) -> Self { Self { logger } }
fn create_user(&self, name: &str) { self.logger.log(&format!("Utilisateur créé : {}", name)); }}
fn main() { let logger = ConsoleLogger; let service = UserService::new(logger); service.create_user("Alexei");}Avantages :
- Sécurité de typage totale à la compilation
- Performance maximale (abstraction à coût zéro)
- Simplicité de compréhension et de débogage
Inconvénients :
- Avec l'augmentation du nombre de dépendances, le code devient volumineux
- Difficile de changer les implémentations à l'exécution
- Nécessité de passer explicitement toutes les dépendances via la chaîne d'appels
2. Injection via les trait-objets (Dynamic Dispatch)
Si vous avez besoin de pouvoir changer les implémentations à l'exécution ou de masquer le type concret, utilisez les trait-objets (Box<dyn Trait>). C'est l'approche classique pour la DI, connue dans d'autres langages.
Exemple : Référentiel abstrait
use std::sync::Mutex;
trait UserRepository { fn find_by_id(&self, id: u32) -> Option<String>; fn save(&self, user: String);}
struct InMemoryRepository { users: Mutex<Vec<String>>,}
impl UserRepository for InMemoryRepository { fn find_by_id(&self, id: u32) -> Option<String> { let users = self.users.lock().unwrap(); users.get(id as usize).cloned() }
fn save(&self, user: String) { let mut users = self.users.lock().unwrap(); users.push(user); }}
struct UserController { repository: Box<dyn UserRepository>,}
impl UserController { fn new(repository: Box<dyn UserRepository>) -> Self { Self { repository } }
fn get_user(&self, id: u32) -> Option<String>