Rust Cargo для команды: workspace, общие зависимости и единые версии библиотек
Покажем, как организовать монорепо без хаоса: workspace, shared crates, настройка версий и feature flags. Статья поможет уменьшить “сюрпризы” при сборке на разных машинах.
Содержание
Rust Cargo для команды: workspace, общие зависимости и единые версии библиотек
Сборка Rust-проекта в одиночку — относительно предсказуемая история. Но стоит подключить команду, добавить CI и начать разворачивать сборки на разных машинах (включая разные версии Rust, разные профили сборки, разные OS и кэши), как хаос становится заметным: где-то «случайно» подтянулась другая версия зависимости, где-то включилась лишняя функция через features, а где-то crates.io ответил иначе, потому что обновления зависимостей происходили не синхронно.
Вывод из практики простой: если проект становится монорепой (или даже просто набором связанных репозиториев), нужно явно управлять границами: какие части живут вместе, какие зависимости общие, как фиксируются версии, и кто и как включает feature flags. Rust Cargo даёт для этого инструменты, но их легко использовать неправильно.
Ниже — практический разбор, как организовать монорепо на Rust без «сюрпризов», используя:
workspaceдля сборки набора crates как единого целого;- общие зависимости и единые версии через
Cargo.tomlна уровне workspace; - стратегии с shared crates (общие библиотеки внутри репо);
- аккуратное управление
featuresи условными зависимостями; - воспроизводимость: lock-файлы, версионирование и правила обновления.
Модель монорепо в Cargo: что реально контролируется
Прежде чем перейти к конфигурациям, важно понимать модель.
Workspace — это не просто «удобная папка»
[workspace] задаёт корневое пространство проекта: Cargo рассматривает перечисленные члены workspace как единый набор пакетов. Это даёт:
- единый
Cargo.lockна уровне workspace (в большинстве конфигураций); - единое разрешение версий зависимостей;
- одинаковый набор членов при сборке через workspace.
Crates — единицы версионирования
Каждый crate — отдельный пакет: он может иметь свой Cargo.toml, свою структуру зависимостей и features. Но источники единства (версии зависимостей, lock-файл, общие настройки) задаются на верхнем уровне и/или через явные зависимости.
Единые версии не «магия», а контракт
Единство версий достигается не тем, что «Cargo сам разберётся», а тем, что вы:
- фиксируете версии (обычно через lock-файл);
- управляемо обновляете их (через общий процесс);
- минимизируете «локальные» расхождения (например, разные диапазоны версий в разных crates);
- централизуете зависимость на уровне workspace (или используете
[workspace.dependencies]).
Базовая структура монорепо: типовой layout
Рассмотрим конфигурацию, которая хорошо работает в командах. Пусть у вас есть:
- несколько бинарников (например, сервисы);
- общие библиотеки;
- утилиты.
Пример структуры:
repo-root/
Cargo.toml # workspace root
Cargo.lock # единый lock
crates/
api/
Cargo.toml
src/
core/
Cargo.toml
src/
storage/
Cargo.toml
src/
services/
auth-service/
Cargo.toml
src/
billing-service/
Cargo.toml
src/
Вы заметите: у каждого crate свой Cargo.toml. Но корневой Cargo.toml описывает workspace и общие правила.
workspace в корне: правильный Cargo.toml
Минимальный корневой Cargo.toml выглядит так:
[workspace]
members = [
"crates/core",
"crates/api",
"crates/storage",
"services/auth-service",
"services/billing-service",
]
resolver = "2"
Ключевые моменты:
resolver = "2"— рекомендуемая настройка для современных проектов. Она меняет алгоритм разрешения features и часто уменьшает неожиданные эффекты при зависимостях.membersдолжен включать все crates, которые вы хотите собирать и/или тестировать внутри монорепо.
Как это использовать в команде
Дальше в CI и для разработчиков можно закрепить одинаковые команды:
- сборка:
cargo build -p auth-service - тесты:
cargo test -p core - проверка:
cargo fmt --allиcargo clippy --workspace
Если вы каждый раз собираете «в разнобой», вы гарантированно получите несоответствия в lock-файлах и включённых features.
Централизация зависимостей: [workspace.dependencies] и единые версии
Самая практичная часть — закрепить версии основных внешних зависимостей так, чтобы разные crates не расходились по диапазонам.
Начиная с Cargo 2, удобно использовать секцию [workspace.dependencies]. Пример:
# repo-root/Cargo.toml
[workspace]
members = [
"crates/core",
"crates/api",
"crates/storage",
"services/auth-service",
"services/billing-service",
]
resolver = "2"
[workspace.dependencies]
anyhow = "1.0.86"
thiserror = "1.0.61"
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117"
tokio = { version = "1.38.0", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
reqwest = { version = "0.12.7", features = ["json", "rustls-tls"] }
Теперь внутри отдельных crates зависимости можно указывать так:
# crates/core/Cargo.toml
[package]
name = "core"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
tracing = { workspace = true }
Аналогично в services/auth-service/Cargo.toml:
[package]
name = "auth-service"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../../crates/core" }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest = { workspace = true }
serde_json = { workspace = true }
Почему это снижает «сюрпризы»
Если вы не централизуете зависимости, в одном crate может быть tokio = "1", в другом — tokio = { version = "1.37", ... }, и в итоге Cargo будет разрешать версии исходя из диапазонов. Он, конечно, согласует зависимости в рамках lock-файла, но:
- lock-файл может обновиться иначе, чем ожидает команда;
- features на уровне зависимостей могут проявиться неожиданно;
- разница в lock-файлах между ветками будет мешать воспроизводимости.
С [workspace.dependencies] вы задаёте единый источник истины — и это сильно упрощает code review.
Shared crates: когда общий код лучше выделить
В реальных проектах общая логика редко ограничивается «одним-двумя helper functions». Со временем появляются повторения:
- сериализация доменных структур;
- модели ошибок;
- общие схемы конфигурации;
- клиенты внешних сервисов;
- адаптеры к БД.
В такой момент shared crates внутри workspace обычно лучше, чем копирование кода или path = с хаотичными зависимостями.
Пример: crates/core как библиотека-ядро
coreхранит доменные типы и бизнес-ошибки;apiможет зависеть отcoreдля контрактов;- сервисы используют
apiиstorage.
Пример зависимости:
# crates/api/Cargo.toml
[package]
name = "api"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
serde = { workspace = true }
serde_json = { workspace = true }
# services/auth-service/Cargo.toml
[dependencies]
api = { path = "../../crates/api" }
storage = { path = "../../crates/storage" }
tokio = { workspace = true }
Подводный камень: не превращать core в “god crate”
Если core станет зависеть абсолютно от всего (включая HTTP-клиенты, конкретные адаптеры БД, особенности конкретной инфраструктуры), он превратится в узкое горлышко: любое изменение тянет за собой пересборку всего.
Практическая стратегия:
- ядро: домен, ошибки, интерфейсы;
- адаптеры: инфраструктурные детали можно держать отдельно (
storage,clients,infrastructurecrates); - сервисы: композиция и wiring.
Это уменьшает «каскады» зависимостей и улучшает время сборки.
Единые версии внутри workspace: lock-файл и политика обновлений
Cargo.lock как артефакт воспроизводимости
В монорепо важно, чтобы lock-файл был общим и коммитился в репозиторий. Тогда разработчики на разных машинах получают одинаковое граф зависимостей.
Практика для команд:
- включить
Cargo.lockв репозиторий (обычно так и делается для бинаров workspace); - в CI проверять, что lock-файл не меняется неожиданно.
Команда для обновления:
- точечно обновлять зависимости через
cargo update -p <crate>. - или разом обновлять:
cargo update.
Но здесь критично: обновление должно быть управляемым процессом, а не результатом «кто-то у себя обновил».
Диапазоны версий vs фиксированные версии
В [workspace.dependencies] часто пишут «диапазон» вида "1.0.86" или "^1". Даже если вы оставляете диапазоны, итоговая версия будет зафиксирована lock-файлом. Поэтому ключевой механизм воспроизводимости — lock.
Если же вы хотите минимизировать риск расхождений даже без lock (например, в ранних стадиях), лучше явно задавать версии без «прыжков» (и всё равно коммитить lock).
Feature flags: единые правила для команды
Features — источник большинства реальных проблем в больших Rust-проектах: разные crates могут включать разные features одной и той же зависимости, и граф меняется. resolver = "2" помогает, но не решает всё.
Базовый принцип: features должны быть детерминированными по входу
В идеале:
- на уровне workspace вы определяете «конфигурируемость»;
- на уровне конкретных crates — включаете только то, что требуется;
- избегаете «самовольных» features в каждом месте.
Правильная практика: features только там, где они реально нужны
Допустим, вы хотите использовать reqwest с TLS, но не хотите тянуть определённые backend’ы везде.
В [workspace.dependencies] можно указать дефолтный набор:
[workspace.dependencies]
reqwest = { version = "0.12.7", features = ["json", "rustls-tls"] }
Тогда все crates будут собираться одинаково. Но что если какой-то crate должен работать с native-tls, а другой — с rustls? Тогда возникает конфликт стратегии.
В таком случае лучше разделять зависимости на уровне crate через разные объявления, но делать это осознанно и документированно:
- либо вынести разные зависимости в разные crates (например,
clients-rustlsиclients-native); - либо использовать Cargo features собственного crate и включать нужный вариант.
Собственные features crates: пример feature flags без хаоса
Допустим, core должен поддерживать режимы обработки:
metrics— включить интеграцию с системой метрик;logging— выбрать реализацию логирования.
В crates/core/Cargo.toml:
[package]
name = "core"
version = "0.1.0"
edition = "2021"
[features]
default = ["logging"]
logging = ["dep:tracing"]
metrics = ["dep:metrics", "dep:metrics-exporter-prometheus"]
[dependencies]
tracing = { workspace = true, optional = true }
metrics = { version = "0.22.3", optional = true }
metrics-exporter-prometheus = { version = "0.16.0", optional = true }
Обратите внимание:
- зависимости, завязанные на feature, помечены
optional = true; - feature включает ровно нужные зависимости.
А теперь важно — кто будет включать эти features? Обычно сервисы.
В services/auth-service/Cargo.toml:
[dependencies]
core = { path = "../../crates/core", features = ["metrics"] }
Частая ошибка: включать features на уровне transitive dependencies
Если вы в core включите metrics, а auth-service ожидает «выключено», вы получите неожиданное поведение. Следите за тем, чтобы features не включались «по умолчанию» там, где логика должна быть управляемой на верхнем уровне.
Ещё одна ошибка: разные версии features у зависимостей
Когда разные crates включают разные features одной внешней зависимости, у вас может появляться ситуация «одна половина кода вызывает API, другая — нет». В Rust это проявляется как:
- ошибки компиляции при включении не той ветки;
- или, хуже, поведение меняется без компиляционных ошибок.
Практика: сведите features к минимально необходимым, а верхний уровень (сервисы) пусть определяет «сборочную конфигурацию».
Условные зависимости и единый контроль версий
В реальном проекте вы можете использовать разные реализации под платформы или фичи. Например, для storage один адаптер для PostgreSQL, другой — для SQLite.
В crates/storage/Cargo.toml:
[features]
default = ["postgres"]
postgres = ["dep:sqlx-postgres"]
sqlite = ["dep:sqlx-sqlite"]
[dependencies]
sqlx-postgres = { package = "sqlx", version = "0.8.3", optional = true, features = ["postgres", "runtime-tokio-native-tls"] }
sqlx-sqlite = { package = "sqlx", version = "0.8.3", optional = true, features = ["sqlite", "runtime-tokio-native-tls"] }
И включение на сервисном уровне:
# services/auth-service/Cargo.toml
storage = { path = "../../crates/storage", features = ["postgres"] }
Подводный камень: дублирование зависимостей
Тут возможна путаница: фактически вы используете один crate sqlx, но в Cargo записываете его как «разные входы» через package = "sqlx". Это допустимо, но требует аккуратной конфигурации кода (какие модули компилируются, как они импортируются).
Лучше сделать тонкую прослойку в storage и спрятать различия за trait’ами/модулями, чтобы наружу интерфейс был одинаковым.
Команды для команды: как собирать и тестировать одинаково
Чтобы уменьшить сюрпризы, полезно зафиксировать «одинаковые пути» в документации (и желательно — скриптами/Makefile).
Рекомендованный набор команд:
- Форматирование по всем crates:
cargo fmt --all
- Проверка:
cargo clippy --workspace --all-targets -- -D warnings
- Тесты:
cargo test --workspace
- Сборка одного сервиса:
cargo build -p auth-service
Важно: в CI лучше запускать cargo test --workspace или минимум тесты ключевых crates. Иначе вы получите ситуацию, когда core компилируется, а сборка api падает на другом наборе features (или из-за другой ветки conditional compilation).
Изоляция конфигураций: profile, RUSTFLAGS и reproducible builds
Сборка может отличаться не только из-за зависимостей, но и из-за окружения компилятора.
profiles и оптимизации
Если в dev и release разные флаги, вы можете получить расхождения в логике, зависящей от cfg(debug_assertions) или тонких UB-проблем. Для команды полезно:
- хранить профиль в
Cargo.tomlworkspace (или.cargo/config.toml); - минимизировать «разъезд» по настройкам между разработчиками.
Пример добавления в корневой Cargo.toml:
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"
.cargo/config.toml для единых флагов
Если в проекте нужна единая настройка (например, net.git-fetch-with-cli, build.jobs, rustflags), лучше положить её в .cargo/config.toml на уровне репозитория.
Пример:
# .cargo/config.toml
[build]
jobs = 8
[target.'cfg(all())']
rustflags = ["-C", "target-cpu=native"]
Но будьте осторожны: target-cpu=native может ухудшить воспроизводимость между машинами. Для production чаще используют более консервативные флаги.
Пример полного минимального набора Cargo.toml для монорепо
Чтобы собрать всё воедино, приведём «скелет» конфигурации.
repo-root/Cargo.toml
[workspace]
members = [
"crates/core",
"crates/api",
"crates/storage",
"services/auth-service",
Комментарии
Пока нет комментариев