Deep EVM #22: Dependency Injection в Rust — ServiceLocator, Arc и трейт-объекты
Engineering Team
DI в Rust: другой подход
Dependency Injection (DI) в Rust выглядит иначе, чем в Java или C#. Здесь нет рефлексии, нет аннотаций @Inject, нет DI-контейнеров в привычном смысле. Вместо этого Rust предлагает мощную систему трейтов, дженериков и lifetime’ов, которая позволяет реализовать DI на уровне типов — с нулевыми runtime-затратами.
В этой статье мы рассмотрим четыре подхода к DI в Rust: трейт-объекты, дженерики, паттерн ServiceLocator и compile-time DI.
Подход 1: Трейт-объекты и Arc
Самый распространённый подход — определить трейт для каждой зависимости и передавать Arc<dyn Trait>:
use std::sync::Arc;
#[async_trait::async_trait]
trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid) -> Result<User>;
async fn save(&self, user: &User) -> Result<()>;
}
#[async_trait::async_trait]
trait EmailService: Send + Sync {
async fn send(&self, to: &str, subject: &str, body: &str) -> Result<()>;
}
struct UserService {
repo: Arc<dyn UserRepository>,
email: Arc<dyn EmailService>,
}
impl UserService {
fn new(
repo: Arc<dyn UserRepository>,
email: Arc<dyn EmailService>,
) -> Self {
Self { repo, email }
}
async fn register(&self, name: String, email_addr: String) -> Result<User> {
let user = User::new(name, email_addr.clone());
self.repo.save(&user).await?;
self.email.send(&email_addr, "Welcome", "Hello!").await?;
Ok(user)
}
}
Плюсы: простота, привычно для разработчиков из других языков. Минусы: динамическая диспетчеризация (vtable), аллокация через Arc.
Подход 2: Дженерики (compile-time DI)
Для максимальной производительности используйте дженерики — компилятор мономорфизирует код:
struct UserService<R, E>
where
R: UserRepository,
E: EmailService,
{
repo: R,
email: E,
}
impl<R: UserRepository, E: EmailService> UserService<R, E> {
fn new(repo: R, email: E) -> Self {
Self { repo, email }
}
}
Плюсы: нулевые runtime-затраты, инлайнинг. Минусы: сложные типы, дублирование кода при мономорфизации.
Подход 3: Паттерн ServiceLocator
ServiceLocator — это контейнер зависимостей, из которого компоненты извлекают нужные сервисы:
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::sync::Arc;
struct ServiceLocator {
services: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
}
impl ServiceLocator {
fn new() -> Self {
Self { services: HashMap::new() }
}
fn register<T: Send + Sync + 'static>(&mut self, service: T) {
self.services.insert(
TypeId::of::<T>(),
Arc::new(service),
);
}
fn resolve<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
self.services
.get(&TypeId::of::<T>())
.and_then(|s| s.clone().downcast::<T>().ok())
}
}
Использование:
let mut locator = ServiceLocator::new();
locator.register(PgUserRepository::new(pool.clone()));
locator.register(SmtpEmailService::new(config));
let repo: Arc<PgUserRepository> = locator.resolve().unwrap();
Плюсы: гибкость, динамическая регистрация. Минусы: нет compile-time проверки, downcast может упасть.
Подход 4: AppState (идиома Axum)
В Axum идиоматический подход — создать структуру AppState со всеми зависимостями:
#[derive(Clone)]
struct AppState {
db: PgPool,
redis: RedisPool,
jwt_secret: String,
email: Arc<dyn EmailService>,
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
let user = User::new(payload.name);
sqlx::query!("INSERT INTO users ...")
.execute(&state.db)
.await?;
state.email.send(&user.email, "Welcome", "Hello!").await?;
Ok(Json(user))
}
Это самый прагматичный подход для веб-приложений на Axum.
Тестирование с DI
Главное преимущество DI — тестируемость. Создавайте mock-реализации трейтов:
#[cfg(test)]
mod tests {
struct MockUserRepo {
users: Mutex<Vec<User>>,
}
#[async_trait]
impl UserRepository for MockUserRepo {
async fn find_by_id(&self, id: Uuid) -> Result<User> {
self.users.lock().unwrap()
.iter()
.find(|u| u.id == id)
.cloned()
.ok_or(Error::NotFound)
}
async fn save(&self, user: &User) -> Result<()> {
self.users.lock().unwrap().push(user.clone());
Ok(())
}
}
#[tokio::test]
async fn test_register_user() {
let repo = Arc::new(MockUserRepo::default());
let email = Arc::new(MockEmailService::default());
let service = UserService::new(repo.clone(), email);
let user = service.register("Alice".into(), "a@b.com".into())
.await.unwrap();
assert_eq!(user.name, "Alice");
}
}
Выбор подхода
| Подход | Runtime-затраты | Тестируемость | Сложность |
|---|---|---|---|
| Трейт-объекты | Низкие (vtable) | Высокая | Средняя |
| Дженерики | Нулевые | Высокая | Высокая |
| ServiceLocator | Средние | Средняя | Низкая |
| AppState | Низкие | Высокая | Низкая |
Для большинства веб-приложений на Axum рекомендуем AppState + трейт-объекты для сервисов, которые нужно подменять в тестах.
Заключение
DI в Rust — это не фреймворк, а набор паттернов. Система типов Rust обеспечивает безопасность на этапе компиляции, что делает многие DI-фреймворки просто ненужными. Выбирайте подход исходя из требований проекта: для максимальной производительности — дженерики, для гибкости — трейт-объекты, для простоты — AppState.