Внедрение зависимостей в Rust: трейты, axum, bevy и жизнь до main
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Внедрение зависимостей в 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. Главное держать в голове не список приёмов, а единственный вопрос за всеми ними и цену каждого уровня развязки. Эти же приёмы лягут в основу архитектуры твоих сервисов и игр: тестируемое ядро на трейтах, фреймворк, который подаёт зависимости по сигнатуре, и аккуратный реестр там, где плагинов слишком много, чтобы перечислять их руками.