Раздел 23 · Rust

Внедрение зависимостей в Rust: трейты, axum, bevy и жизнь до main

lead~45 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Внедрение зависимостей в Rust: трейты, axum, bevy и жизнь до main

Слово «внедрение зависимостей» звучит как корпоративный паттерн с фабриками фабрик. На деле за ним прячется один простой вопрос, и сегодня мы разберём, как Rust отвечает на него четырьмя разными способами: руками, трейтами с дженериками, экстракторами веб-фреймворка и самым развязанным из всех, регистрацией ещё до того, как запустился main.

Что такое DI на самом деле

Возьми любую функцию, которой что-то нужно снаружи: часы, чтобы узнать время, соединение с базой, отправитель писем. У неё есть выбор, откуда это взять. Первый путь: достать самой. Позвать SystemTime::now(), открыть соединение внутри, создать отправителя на месте. Второй путь: попросить, чтобы зависимость передали извне, и не знать, откуда она взялась.

Внедрение зависимостей это и есть второй путь. Единственный его смысл: кто решает, откуда придёт зависимость. Если решает сама функция, её нельзя протестировать с фейковыми часами, нельзя переключить базу на тестовую, нельзя подменить отправителя писем заглушкой. Если решает тот, кто функцию вызывает, всё это становится бесплатным.

В мире Java и C# для этого заводят DI-контейнер: отдельную машину, которая в рантайме по типу находит нужную реализацию и подставляет. В Rust такого контейнера в стандарте нет, и это не упущение. Система типов и трейты решают ту же задачу на этапе компиляции, без рефлексии и без словаря типов в памяти. Дальше четыре уровня, от самого прямого к самому хитрому.

Уровень 0: передать руками

Самый честный DI это обычный параметр. Зависимость становится полем структуры, которое заполняют снаружи при создании.

struct SystemClock;
impl SystemClock {
    fn now_secs(&self) -> u64 {
        // в реальности SystemTime::now(), тут упрощаем
        1_700_000_000
    }
}

struct Greeter {
    clock: SystemClock,
}

impl Greeter {
    fn new(clock: SystemClock) -> Self {
        Self { clock }
    }
    fn greet(&self) -> String {
        format!("привет, секунда {}", self.clock.now_secs())
    }
}

Greeter не зовёт время сам, он принимает часы в new. Это уже инверсия: решение о часах переехало в вызывающий код. Беда одна: тип часов жёстко прибит. Greeter умеет работать только с SystemClock, и подменить его в тесте на фейковые часы нельзя, потому что фейк это другой тип. Чтобы развязать руки, нужен общий интерфейс. В Rust интерфейс это трейт.

Уровень 1: трейт и дженерик (бесплатный DI)

Опиши то, что тебе нужно, трейтом. Дальше принимай не конкретный тип, а любой, кто этот трейт реализует.

trait Clock {
    fn now_secs(&self) -> u64;
}

struct RealClock;
impl Clock for RealClock {
    fn now_secs(&self) -> u64 {
        1_700_000_000
    }
}

struct Greeter<C: Clock> {
    clock: C,
}

impl<C: Clock> Greeter<C> {
    fn new(clock: C) -> Self {
        Self { clock }
    }
    fn greet(&self) -> String {
        format!("привет, секунда {}", self.clock.now_secs())
    }
}

Теперь Greeter объявляет свою потребность: «мне нужно что-то, что умеет в Clock». Кто именно, решит вызывающий. В тесте подставляем фейк, и вся логика проверяется без реального времени:

struct FixedClock(u64);
impl Clock for FixedClock {
    fn now_secs(&self) -> u64 {
        self.0
    }
}

#[test]
fn greeter_uses_injected_clock() {
    let g = Greeter::new(FixedClock(42));
    assert_eq!(g.greet(), "привет, секунда 42");
}

Это идиоматичный Rust-ответ на DI и в большинстве случаев правильный. Через дженерик с ограничением трейтом зависимость подставляется на этапе компиляции. Компилятор разворачивает Greeter<RealClock> и Greeter<FixedClock> в два отдельных типа, каждый вызов часов превращается в прямой вызов конкретного метода. Это статическая диспетчеризация: ни таблицы методов, ни лишнего указателя, ни цены в рантайме. DI здесь не стоит вообще ничего. Связь дженериков и ограничений мы разбирали в уроке про дженерики, а сами трейты в уроке про трейты.

Уровень 2: трейт-объект, когда тип решается в рантайме

У дженерика есть граница: тип зависимости должен быть известен при компиляции. Иногда это не так. Реализацию выбирают по конфигу при старте: в проде настоящий отправитель писем, в стейджинге пишущий в лог, в тесте молчащий. Все три не должны размножать тип вокруг себя. Тогда берут трейт-объект:

struct Greeter {
    clock: Box<dyn Clock>,
}

impl Greeter {
    fn new(clock: Box<dyn Clock>) -> Self {
        Self { clock }
    }
}

// выбор реализации в рантайме
let clock: Box<dyn Clock> = if cfg!(test) {
    Box::new(FixedClock(0))
} else {
    Box::new(RealClock)
};
let greeter = Greeter::new(clock);

Цена честная: вызов идёт через таблицу методов, встраивание чаще всего невозможно, добавляется указатель и обычно аллокация на куче. Зато тип зависимости перестал течь в сигнатуры вокруг. Правило простое: дженерик по умолчанию, dyn когда набор реализаций решается в рантайме или когда дженерик расползается слишком широко. Подробный разбор обоих видов диспетчеризации лежит в уроке про трейт-объекты.

axum: DI по сигнатуре функции

Теперь посмотрим, как этот же принцип выглядит в живом фреймворке. В веб-фреймворке axum обработчик это обычная асинхронная функция, а её параметры это объявление зависимостей. Фреймворк смотрит на типы параметров и подаёт каждую зависимость сам. Называется это экстрактор.

use axum::{extract::State, routing::get, Router};

#[derive(Clone)]
struct AppState {
    db_url: String,
}

// State<AppState> это объявленная зависимость. Роутер подаёт её сам.
async fn handler(State(state): State<AppState>) -> String {
    format!("база: {}", state.db_url)
}

let app = Router::new()
    .route("/", get(handler))
    .with_state(AppState { db_url: "postgres://...".into() });

Обработчик нигде не достаёт состояние сам, он лишь объявляет в сигнатуре, что ему нужен State<AppState>. Это та же инверсия, что на уровне 1, только подстановку делает не компилятор по дженерику, а роутер по типу параметра. За кулисами State, Extension и любой другой экстрактор реализуют один из двух трейтов:

use axum::extract::FromRequestParts;
use axum::http::{request::Parts, StatusCode};

struct ApiVersion(u8);

impl<S: Send + Sync> FromRequestParts<S> for ApiVersion {
    type Rejection = StatusCode;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let raw = parts
            .headers
            .get("x-api-version")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.parse().ok());
        match raw {
            Some(v) => Ok(ApiVersion(v)),
            None => Err(StatusCode::BAD_REQUEST),
        }
    }
}

// теперь ApiVersion можно объявлять параметром любого обработчика
async fn versioned(ApiVersion(v): ApiVersion) -> String {
    format!("версия API: {v}")
}

Стоит реализовать трейт, и твой тип становится зависимостью, которую можно требовать в любом обработчике. FromRequestParts читает только метаданные запроса (заголовки, путь, состояние). Его старший брат FromRequest умеет ещё и забрать тело, но тело это поток, который вычитывается один раз, поэтому такой экстрактор в обработчике допустим только один и обязательно последним. Всё остальное это FromRequestParts. Заметь главное: ты добавляешь новую зависимость, не трогая ни роутер, ни другие обработчики. Точно как развязка через трейт, но на уровне всего фреймворка.

bevy: ECS как контейнер зависимостей

В игровом движке bevy тот же приём доведён до предела. Игровая логика это набор систем, а система это обычная функция. Её параметры снова объявляют зависимости, а подаёт их планировщик из общего хранилища, которое в bevy зовётся World.

use bevy::prelude::*;

#[derive(Resource)]
struct Score(u32);

#[derive(Component)]
struct Velocity(Vec3);

// система объявляет: мне нужен ресурс Score на изменение и все Transform с Velocity
fn movement(
    mut score: ResMut<Score>,
    mut query: Query<(&mut Transform, &Velocity)>,
) {
    for (mut transform, velocity) in &mut query {
        transform.translation += velocity.0;
        score.0 += 1;
    }
}

fn main() {
    App::new()
        .insert_resource(Score(0))
        .add_systems(Update, movement)
        .run();
}

ResMut<Score> и Query<...> это параметры системы, ровно как экстракторы в axum. Планировщик читает типы, достаёт из World нужные данные и зовёт movement уже с заполненными аргументами. World тут играет роль того самого DI-контейнера из мира Java: хранилище, которое по типу отдаёт зависимость. Только ключ не строка и не рефлексия, а тип Rust, проверенный компилятором, а сам контейнер заточен под игровые данные. И опять: новая система объявляет, что ей нужно, и встаёт в расписание, ничего вокруг не переписывая.

Уровень 3: жизнь до main

Во всех примерах выше зависимости собирал кто-то один: вызывающий код, роутер, планировщик. Он знал список поставщиков или умел их найти. Бывает развязка ещё сильнее, когда поставщик регистрирует себя сам, по месту определения, а сборщик не называет ни одного поставщика по имени и даже не знает, сколько их. Чтобы это устроить, нужно научиться выполнять код раньше, чем стартует main.

И тут многих ждёт сюрприз: до main действительно есть жизнь. У каждого языка есть рантайм, который успевает поработать до твоего кода. Эта фаза однопоточная и идёт в строго заданном порядке, поэтому годится для надёжной инициализации. В C для этого есть конструкторы через __attribute__((constructor)), в Rust это даёт крейт ctor:

// псевдокод формы, требует крейта ctor
#[ctor::ctor]
fn before_main() {
    // выполнится до первой строки main, в одном потоке
}

Но конструкторы это только спусковой крючок. Главный приём это линкер-секции. Идея такая. Каждый модуль кладёт свою запись в одну именованную секцию бинарника атрибутом #[link_section = "..."]. Линкер, собирая программу, складывает все такие записи рядом и сам создаёт два символа: __start_имя и __stop_имя, начало и конец секции. Программе остаётся прочитать срез между ними, и она получает всё, что насовали туда все модули, без единого места, которое их перечисляет. Аргументы за и против этого приёма, ровно как у любого DI, мы свели в уроке про unsafe: сырая секция требует осторожности с типами и порядком.

Руками это писать не нужно. Есть два зрелых крейта. inventory даёт типизированный реестр:

// псевдокод формы, требует крейта inventory
pub struct Command {
    pub name: &'static str,
    pub run: fn(&str) -> String,
}
inventory::collect!(Command);

// в модуле build.rs, ничего не зная про остальных:
inventory::submit! {
    Command { name: "build", run: |arg| format!("собираю {arg}") }
}

// в main, ничего не зная про модули:
fn dispatch(name: &str, arg: &str) -> Option<String> {
    inventory::iter::<Command>
        .into_iter()
        .find(|cmd| cmd.name == name)
        .map(|cmd| (cmd.run)(arg))
}

linkme решает ту же задачу через типизированный срез (#[distributed_slice]), который можно ещё и сортировать до main. А процедурные макросы из урока про макросы прячут submit! совсем: ты ставишь над функцией атрибут вроде #[command], а макрос разворачивает его в регистрацию. Так устроены реестры тестов, бенчмарков и плагинов во многих крейтах.

Это самый развязанный DI из возможных. Добавить команду значит создать модуль с одной регистрацией. Список команд не существует как место в коде: его собирает линкер. Поиграй с этим ниже: тогглами включай и выключай модули и смотри, как реестр пересобирается до main, а main диспатчит по имени, не зная ни одного поставщика.

В виджете спрятана и главная цена приёма. Включи --gc-sections и выключи якорь у модуля, который живёт только ради регистрации. Команда исчезнет, хотя submit! в исходнике на месте. Это удаление мёртвого кода: линкер не видит ссылок на модуль и считает его лишним. Лечится якорной ссылкой или атрибутом used. Вторая беда: данные о реестре размазаны по всему проекту, и понять состав по одному файлу нельзя. За удобство платишь аудитом.

Сравнение: что выбрать

СпособКогда подставляетсяЦенаКогда брать
Руками (уровень 0)при созданиинольмало кода, тип не нужно подменять
Трейт и дженериккомпиляциянольдефолт: тестируемость без рантайма
Трейт-объект dynрантаймтаблица методов, аллокацияреализация по конфигу, дженерик расползается
Экстрактор и параметр системырантайм фреймворкацена фреймворкавнутри axum, bevy и им подобных
Жизнь до mainдо main, линкеромхрупкость к DCE, слабый аудитреестры плагинов, команд, тестов

Лестница идёт от прямой связи к полной развязке, и развязка не бесплатна. Чем дальше вниз, тем меньше код знает о своих частях и тем труднее проследить, кто кого собирает. Бери ровно столько развязки, сколько нужно задаче, не больше.

Правило урока

DI это не контейнер и не фреймворк, это ответ на один вопрос: кто решает, откуда придёт зависимость. В Rust ответ почти всегда трейт с дженериком, и стоит он ноль. Трейт-объект, экстрактор и регистрация до main это тот же принцип на разных уровнях развязки, и каждый следующий уровень покупает гибкость ценой прозрачности. Дойдя до линкер-секций, помни про мёртвый код: самый красивый реестр бесполезен, если линкер тихо выкинул половину.

ДЗ

Дальше

Ты прошёл всю лестницу внедрения зависимостей в Rust: от прямой передачи через трейты и трейт-объекты до экстракторов axum, систем bevy и регистрации до main. Главное держать в голове не список приёмов, а единственный вопрос за всеми ними и цену каждого уровня развязки. Эти же приёмы лягут в основу архитектуры твоих сервисов и игр: тестируемое ядро на трейтах, фреймворк, который подаёт зависимости по сигнатуре, и аккуратный реестр там, где плагинов слишком много, чтобы перечислять их руками.