Dependency Injection в Rust: Полное руководство для разработчиков

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение в Dependency Injection в Rust

Dependency Injection (DI) — это паттерн проектирования, при котором объект получает свои зависимости извне, а не создаёт их самостоятельно. В экосистеме Rust этот подход приобретает особое значение благодаря строгой системе типов и владения. DI позволяет сделать код более модульным, тестируемым и поддерживаемым, что особенно важно для больших проектов.

В отличие от языков с динамической типизацией или сборкой мусора, Rust требует особого подхода к внедрению зависимостей. Здесь нет рефлексии или runtime-контейнеров в классическом понимании. Вместо этого разработчики используют статические механизмы: trait-объекты, generics и композицию через структуры.

В этой статье мы рассмотрим три основных подхода к реализации Dependency Injection в Rust, начиная от простейшего ручного внедрения и заканчивая использованием библиотек. Каждый подход будет проиллюстрирован практическими примерами кода.

1. Ручное внедрение зависимостей (Manual DI)

Самый простой и прозрачный способ — передавать зависимости через конструктор структуры. Этот подход не требует дополнительных библиотек и полностью контролируется компилятором Rust.

Пример: Логгер и Сервис

// Определяем trait для логирования
trait Logger {
    fn log(&self, message: &str);
}

// Реализация логгера для продакшена
struct ConsoleLogger;

impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("[LOG]: {}", message);
    }
}

// Реализация для тестов
struct MockLogger {
    pub messages: Vec<String>,
}

impl Logger for MockLogger {
    fn log(&self, message: &str) {
        self.messages.push(message.to_string());
    }
}

// Сервис, зависящий от Logger
struct 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!("Создан пользователь: {}", name));
    }
}

fn main() {
    let logger = ConsoleLogger;
    let service = UserService::new(logger);
    service.create_user("Алексей");
}

Преимущества:

  • Полная типобезопасность на этапе компиляции
  • Максимальная производительность (zero-cost abstraction)
  • Простота понимания и отладки

Недостатки:

  • При росте количества зависимостей код становится громоздким
  • Сложно менять реализации в runtime
  • Требуется явно передавать все зависимости через цепочку вызовов

2. Внедрение через trait-объекты (Dynamic Dispatch)

Если вам нужна возможность менять реализации в runtime или скрыть конкретный тип, используйте trait-объекты (Box<dyn Trait>). Это классический подход для DI, известный по другим языкам.

Пример: Абстрактный репозиторий

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> {
        self.repository.find_by_id(id)
    }
}

fn main() {
    let repo = Box::new(InMemoryRepository {
        users: Mutex::new(Vec::new()),
    });
    let controller = UserController::new(repo);
    println!("{:?}", controller.get_user(0));
}

Особенности:

  • Динамическая диспетчеризация — небольшой overhead на вызов методов
  • Гибкость: можно подменить реализацию в любой момент
  • Удобно для тестирования: легко подставить mock-объект

3. Использование DI-контейнеров (библиотеки)

Для крупных проектов ручное управление зависимостями становится утомительным. На помощь приходят библиотеки, такие как shaku, dice или inject. Рассмотрим пример с shaku — одной из самых популярных DI-библиотек для Rust.

Пример с библиотекой shaku

use shaku::{module, Component, Interface};

// 1. Определяем интерфейс
trait Database: Interface {
    fn query(&self, sql: &str) -> String;
}

// 2. Реализация
#[derive(Component)]
#[interface(Database)]
struct PostgresDb;

impl Database for PostgresDb {
    fn query(&self, sql: &str) -> String {
        format!("Postgres result for: {}", sql)
    }
}

// 3. Сервис, который использует Database
struct AppService {
    db: Box<dyn Database>,
}

impl AppService {
    fn new(db: Box<dyn Database>) -> Self {
        Self { db }
    }

    fn get_data(&self) -> String {
        self.db.query("SELECT * FROM users")
    }
}

// 4. Модуль DI
module! {
    MyModule {
        components = [PostgresDb],
        providers = []
    }
}

fn main() {
    let module = MyModule::builder()
        .with_component::<dyn Database>(Box::new(PostgresDb))
        .build();

    let db: Box<dyn Database> = module.resolve().unwrap();
    let service = AppService::new(db);
    println!("{}", service.get_data());
}

Преимущества использования DI-контейнеров:

  • Автоматическое разрешение зависимостей
  • Управление жизненным циклом объектов (singleton, transient)
  • Упрощение конфигурации приложения

Недостатки:

  • Дополнительная зависимость в проекте
  • Усложнение понимания потока управления для новичков
  • Возможны проблемы с временем компиляции

4

Блоги

Рекомендаций по книгам