Introduction à l'injection de dépendances en Rust
Lorsque vous écrivez des programmes en Rust, vous finissez tôt ou tard par rencontrer le problème suivant : comment rendre le code flexible, testable et faiblement couplé ? La réponse est l'injection de dépendances (DI). Il s'agit d'un patron 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 cet article, nous allons voir comment implémenter l'injection de dépendances en Rust avec des exemples pratiques. Vous apprendrez à utiliser les objets trait et les generics pour créer une architecture flexible. Ce contenu est destiné aux développeurs Rust débutants, familiers avec les bases du langage.
Pourquoi l'injection de dépendances ?
Imaginez que vous écrivez une application pour envoyer des notifications. Sans DI, la classe NotificationService serait fortement liée à une implémentation spécifique (par exemple, EmailSender). Cela rend le code :
- Difficile à tester — impossible de remplacer le service réel par un objet mock.
- Difficile à étendre — l'ajout d'un nouveau mode d'envoi (SMS, Push) nécessite de modifier le code existant.
- Fortement couplé — les modifications dans une partie cassent l'autre.
L'injection de dépendances résout ces problèmes en transmettant les dépendances via le constructeur, une méthode ou un paramètre. Rust, en tant que langage système avec un typage fort, offre des moyens élégants d'implémenter la DI.
Les principales approches de la DI en Rust
En Rust, il existe deux manières principales d'implémenter l'injection de dépendances : via les objets trait (dispatch dynamique) et via les generics (dispatch statique). Examinons les deux approches.
1. DI via les objets trait (dispatch dynamique)
Cette approche utilise le polymorphisme à l'exécution. Vous définissez un trait, puis vous transmettez un Box<dyn Trait> ou une référence &dyn Trait.
Exemple : service de journalisation
// Définition du trait pour la journalisationtrait Logger { fn log(&self, message: &str);}
// Implémentation pour la sortie consolestruct ConsoleLogger;
impl Logger for ConsoleLogger { fn log(&self, message: &str) { println!("[LOG]: {}", message); }}
// Implémentation pour un fichierstruct FileLogger { filename: String,}
impl Logger for FileLogger { fn log(&self, message: &str) { // Écriture simplifiée dans un fichier std::fs::write(&self.filename, message).unwrap(); }}
// Service utilisant la DIstruct UserService { logger: Box<dyn Logger>,}
impl UserService { // Injection de dépendance via le constructeur fn new(logger: Box<dyn Logger>) -> Self { UserService { logger } }
fn create_user(&self, name: &str) { self.logger.log(&format!("Utilisateur créé : {}", name)); // Logique de création d'utilisateur... }}
fn main() { // Utilisation de ConsoleLogger let console_logger = Box::new(ConsoleLogger); let service = UserService::new(console_logger); service.create_user("Alice");
// Utilisation de FileLogger let file_logger = Box::new(FileLogger { filename: "log.txt".into() }); let service2 = UserService::new(file_logger); service2.create_user("Bob");}Avantages : flexibilité, possibilité de changer l'implémentation à l'exécution. Inconvénients : léger surcoût dû aux appels virtuels.
2. DI via les generics (dispatch statique)
Ici, les dépendances sont déterminées à la compilation via des paramètres de type. C'est une approche plus performante, mais moins flexible.
Exemple : le même service de journalisation avec des generics
trait Logger { fn log(&self, message: &str);}
struct ConsoleLogge