Dependency Injection в Rust: на практике
Dependency Injection (DI) — это шаблон проектирования, который позволяет передавать зависимости объекту извне, а не создавать их внутри. В Rust этот подход особенно полезен благодаря системе владения, трейтам и обобщениям. В этой статье мы рассмотрим, как реализовать DI в Rust на практике, от простых примеров до продвинутых техник с контейнерами зависимостей.
Почему DI важен в Rust?
В Rust, где нет классического ООП с наследованием, DI помогает:
- Уменьшить связанность — модули зависят от абстракций, а не от конкретных реализаций.
- Упростить тестирование — вы можете подменять реальные реализации моками.
- Повысить гибкость — легко менять поведение системы без изменения её кода.
Основы: передача зависимостей через параметры
Самый простой способ внедрения зависимости — передача её в качестве параметра функции или конструктора структуры.
// Определяем трейт для абстракции
trait Logger {
fn log(&self, message: &str);
}
// Реализация для консоли
struct ConsoleLogger;
impl Logger for ConsoleLogger {
fn log(&self, message: &str) {
println!("LOG: {}", message);
}
}
// Реализация для файла
struct FileLogger {
filename: String,
}
impl Logger for FileLogger {
fn log(&self, message: &str) {
// В реальном коде здесь была бы запись в файл
println!("FILE: {} -> {}", self.filename, message);
}
}
// Сервис, использующий DI через конструктор
struct UserService<L: Logger> {
logger: L,
}
impl<L: Logger> UserService<L> {
fn new(logger: L) -> Self {
Self { logger }
}
fn create_user(&self, name: &str) {
self.logger.log(&format!("User created: {}", name));
}
}
fn main() {
let console_logger = ConsoleLogger;
let user_service = UserService::new(console_logger);
user_service.create_user("Alice");
}
В этом примере UserService не знает, какой именно логгер используется — он зависит только от трейта Logger. Это и есть суть DI.
Продвинутый подход: трейт-объекты и динамическая диспетчеризация
Когда вам нужно хранить разные реализации в одном контейнере, используйте трейт-объекты (Box<dyn Trait>).
trait Database {
fn query(&self, sql: &str) -> Vec<String>;
}
struct PostgresDb;
impl Database for PostgresDb {
fn query(&self, sql: &str) -> Vec<String> {
vec!["result1".to_string(), "result2".to_string()]
}
}
struct SqliteDb;
impl Database for SqliteDb {
fn query(&self, sql: &str) -> Vec<String> {
vec!["sqlite_result".to_string()]
}
}
struct App {
db: Box<dyn Database>,
logger: Box<dyn Logger>,
}
impl App {
fn new(db: Box<dyn Database>, logger: Box<dyn Logger>) -> Self {
Self { db, logger }
}
fn run(&self) {
let results = self.db.query("SELECT * FROM users");
for r in results {
self.logger.log(&r);
}
}
}
fn main() {
let app = App::new(
Box::new(PostgresDb),
Box::new(ConsoleLogger),
);
app.run();
}
Преимущество трейт-объектов в том, что вы можете менять реализации во время выполнения. Недостаток — небольшой оверхед на динамическую диспетчеризацию.
Контейнер зависимостей: инверсия управления
Для крупных проектов удобно использовать контейнер зависимостей. Хотя в Rust нет стандартного DI-контейнера, его легко реализовать самостоятельно или использовать крейты, такие как shaku или dice.
Пример простого контейнера вручную:
use std::collections::HashMap;
use std::any::Any;
// Тип для хранения зависимостей
struct Container {
services: HashMap<String, Box<dyn Any>>,
}
impl Container {
fn new() -> Self {
Self { services: HashMap::new() }
}
fn register<T: 'static>(&mut self, name: &str, service: T) {
self.services.insert(name.to_string(), Box::new(service));
}
fn resolve<T: 'static>(&self, name: &str) -> Option<&T> {
self.services.get(name)
.and_then(|s| s.downcast_ref::<T>())
}
}
fn main() {
let mut container = Container::new();
container.register("logger", ConsoleLogger);
container.register("db", PostgresDb);
if let Some(logger) = container.resolve::<ConsoleLogger>("logger") {
logger.log("Container works!");
}
}
Более продвинутые крейты, например shaku, поддерживают автоматическое разрешение зависимостей, жизненные циклы и потокобезопасность.
Тестирование с DI
Одно из главных преимуществ DI — упрощение тестирования. Вы можете легко подменить реальные зависимости моками.
struct MockLogger {
messages: Vec<String>,
}
impl Logger for MockLogger {
fn log(&self, message: &str) {
// В моке просто сохраняем сообщение
// В реальности вы бы использовали RefCell для изменения
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct TestLogger {
pub messages: RefCell<Vec<String>>,
}
impl Logger for TestLogger {
fn log(&self, message: &str) {
self.messages.borrow_mut().push(message.to_string());
}
}
#[test]
fn test_create_user() {
let logger = TestLogger {
messages: RefCell::new(Vec::new()),
};
let service = UserService::new(logger);
service.create_user("Bob");
assert_eq!(logger.messages.borrow().len(), 1);
assert!(logger.messages.borrow()[0].contains("Bob"));
}
}
Обратите внимание на использование RefCell для внутренней мутабельности — это стандартный подход в Rust для тестовых моков.
Заключение
Dependency Injection в Rust — это не просто теория, а мощный инструмент для создания гибких, тестируемых и поддерживаемых приложений. Мы рассмотрели три уровня реализации:
- Базовый — передача зависимостей через обобщения (статическая диспетчеризация).
- Динамический — использование трейт-объектов для гибкости во время выполнения.
- Кон