Cargo для боёв: workspace, фичи, зависимости и воспроизводимые сборки
Поговорим о том, как организовать проекты через workspace, управлять фичами и зависимостями. Научимся делать сборки стабильными и предсказуемыми.
Содержание
Cargo для боёв: workspace, фичи, зависимости и воспроизводимые сборки
Rust славится предсказуемостью сборок и строгим компилятором, но на практике «стабильность» чаще всего ломается не из‑за Rust, а из‑за архитектуры проекта: как вы организовали workspace, как задали фичи, как устроили зависимости и насколько аккуратно относитесь к версиям и параметрам сборки. Cargo при этом остаётся главным инструментом управления всем этим — и именно на уровне «настройки под реальную жизнь» проявляется разница между учебными проектами и боевыми репозиториями.
В этой статье разберём, как проектировать Rust‑репозиторий так, чтобы он:
- удобно развивался в нескольких связанных крейтах,
- корректно включал/выключал фичи без хаоса,
- минимизировал дрейф зависимостей,
- выдавал воспроизводимые (или хотя бы максимально детерминированные) сборки.
Workspace как основа монорепозитория
Когда workspace нужен, а когда вреден
Workspace в Cargo — это способ объединить несколько пакетов (crates) в одном репозитории, чтобы управлять общими настройками и зависимостями, не дублируя конфигурацию.
Практические признаки, что workspace вам нужен:
- в репозитории есть несколько библиотек/бинари‑крейтов, которые развиваются вместе;
- есть общие типы/утилиты, которые логично переиспользовать между компонентами;
- вы хотите единое управление версиями и общими зависимостями в рамках репозитория;
- вы делаете интеграционные тесты, которые зависят от внутренних крейтов.
Признаки, что workspace может быть лишним:
- репозиторий — один crate и не планируется расширяться;
- внутренние «модули» фактически всё время остаются внутри одного crate и не требуют отдельного публикационного/сборочного цикла;
- вы не готовы поддерживать связность между crate’ами (workspace добавляет дисциплину, а не только удобство).
Базовая структура
Типичный layout:
myrepo/
Cargo.toml
crates/
core/
Cargo.toml
src/lib.rs
api/
Cargo.toml
src/lib.rs
cli/
Cargo.toml
src/main.rs
Корневой Cargo.toml объявляет workspace:
[workspace]
resolver = "2"
members = [
"crates/core",
"crates/api",
"crates/cli",
]
Ключевое здесь — resolver = "2". В современных проектах его стоит использовать почти всегда: он корректнее учитывает особенности зависимостей по фичам между разными частями dependency graph.
Общие зависимости и workspace dependencies
В реальной жизни вы быстро начинаете повторять версии зависимостей во многих Cargo.toml. Cargo позволяет централизовать это через workspace.dependencies и затем переиспользовать в членах workspace.
Пример корневого Cargo.toml:
[workspace]
resolver = "2"
members = ["crates/core", "crates/api", "crates/cli"]
[workspace.dependencies]
anyhow = "1.0.86"
serde = { version = "1.0.203", features = ["derive"] }
tokio = { version = "1.38.0", features = ["rt-multi-thread", "macros"] }
Теперь в crates/api/Cargo.toml:
[package]
name = "api"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
Плюсы:
- единый контроль версий;
- меньше «плавающих» отличий между crate’ами;
- проще обновлять зависимость.
Минусы (важные): workspace.dependencies не отменяет необходимости думать о фичах. Если в одном crate вам нужна serde с derive, а в другом — нет, вы всё равно унаследуете набор фич, заданный в workspace. Иногда это нормально, но в больших проектах приходится балансировать: часть фич держать централизованно, часть — локально.
Общие параметры сборки: profiles и lints
Если репозиторий активно разрабатывается, вы почти наверняка захотите единообразные profiles (например, для CI) и настройки качества (clippy/rustfmt). В корневом Cargo.toml можно задать профили:
[profile.dev]
opt-level = 1
debug = 2
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
Для workspace это особенно полезно: разные crate’ы будут собираться в одном стиле — и воспроизводимость повышается.
Отдельно отмечу: panic = "abort" меняет семантику падений и может влиять на трассировку и поведение в критических местах. Это решение лучше принимать осознанно.
Управление фичами: “feature engineering”, а не хаос
Что такое feature в Cargo и почему с ними легко ошибиться
Cargo features — это именованные опциональные возможности крейта. Они позволяют:
- включать/выключать код компиляцией (
cfg(feature = "...")); - включать фичи зависимостей (транзитивно);
- сокращать бинарь/время компиляции для определённых сценариев использования.
Но именно из‑за транзитивности и «протекания» фичи часто превращаются в хаос: один crate включает feature у другого, тот — у третьего, а в итоге получаем набор комбинаций, которые тестами покрыты слабо.
Принцип: фичи должны быть “продуктовыми”, а не “техническими”
Хорошая практика — проектировать features как сценарии:
server/clientpostgres/sqlitetls/no-tlsmetrics
Плохая практика — делать features уровня «включить модуль X для отладки Y» без ясной пользовательской цели. Тогда появляются бесконечные комбинации, которые никто не тестирует.
Пример: аккуратные features в библиотеке
crates/core/Cargo.toml:
[package]
name = "core"
version = "0.1.0"
edition = "2021"
[features]
default = ["std"]
std = []
async-runtime = ["tokio/rt"]
В коде:
// crates/core/src/lib.rs
#[cfg(feature = "std")]
pub fn use_std() {
// ...
}
#[cfg(feature = "async-runtime")]
pub async fn run_async() {
// ...
}
Здесь мы видим важную вещь: feature async-runtime не реализует async сам по себе, он лишь включает feature у зависимости (tokio/rt). Cargo поддерживает такую маршрутизацию.
Как избегать дублирования логики в разных crate’ах
Частая проблема: feature объявлен в одном crate, но код инициализации собранного бинарника разнесён между несколькими. В итоге включение/выключение фич перестаёт быть очевидным.
Решение:
- Выберите один «источник правды» (обычно — библиотечный crate, который определяет контракт).
- Бинарный crate (
cli,server) должен лишь включать нужные features целевой библиотеки.
Например, crates/cli/Cargo.toml:
[package]
name = "cli"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core", default-features = false, features = ["std", "async-runtime"] }
tokio = { workspace = true }
Обратите внимание на default-features = false. Это часто критично: по умолчанию в зависимости может включаться код, который вам не нужен (например, std или TLS). Отключая дефолтные фичи, вы собираете «минимальный профиль», который контролируете явно.
Ещё одна типичная ошибка: “feature = std” без плана
Feature std — популярная практика в библиотеке (особенно при поддержке no_std). Но если ваш проект не разворачивает no_std, то feature std превращается в привычку, не дающую пользы, и увеличивает поверхность настроек.
Вывод: features должны решать конкретные задачи. Не делайте «фичи ради фич».
Тонкости: “feature names” и семантика default
default = [] или default = ["..."] — это тоже часть API вашего crate. Пользователь привык к дефолтам; изменение дефолтов — ломающее изменение.
Если crate развивается, заранее продумайте:
- какие сценарии действительно “most common”;
- что должно включаться автоматически при
cargo add/обычной зависимости; - что лучше оставить опциональным.
Зависимости: версии, локальные пути и единая картина graph
path-зависимости внутри workspace
Внутренние crate’ы обычно подключаются через path или через workspace dependencies с path (по факту — всегда path внутри одного репозитория).
crates/api/Cargo.toml:
[dependencies]
core = { path = "../core" }
Это просто, но часто забывают один нюанс: Cargo.lock общий для workspace, и при изменении версий в одном crate это отражается во всей сборке. Поэтому важно держать версионирование и фичи под контролем.
crate публикуется? Не смешивайте локальные настройки с “package API”
Если вы планируете публикацию части crates, следите, чтобы:
pathзависимости не попадали в опубликованный manifest (Cargo обычно не публикуетpathкак dependency, но ошибки в конфигурации встречаются);- workspace-специфичные зависимости не ломали сборку вне репозитория.
На практике это означает: crates/* должны быть в состоянии собраться отдельно (хотя бы cargo test), если вы запускаете их изнутри crate. Это дисциплина, которая экономит часы расследований.
Переопределение версий и patch
Когда в реальном проекте появляются конфликты версий или нужно временно применить фикс, Cargo предлагает [patch].
Пример: вы хотите принудительно использовать конкретную версию или локальную ветку:
[patch.crates-io]
serde = { git = "https://github.com/serde-rs/serde", rev = "..." }
Это мощно, но осторожно: patch влияет на весь workspace dependency graph. Хорошая практика — держать patch временно и документировать причину (в комментарии или отдельном issue).
Конфликт фич между версиями зависимостей
Даже если версия зависимости совпадает, набор фич может различаться между crate’ами. В ранних резолверах это иногда давало неожиданные результаты, поэтому resolver = "2" — базовый must.
Но полностью проблема не исчезает: вам всё равно нужно следить за тем, какие фичи включают ваши crate’ы у общих зависимостей.
Стабильные сборки: Cargo.lock, параметры и “что именно вы фиксируете”
Cargo.lock: когда он нужен и где он лежит
Для бинарных проектов и workspaces использование Cargo.lock — почти обязательная практика. Для библиотек cargo publish может пересоздавать lock, но для CI и для воспроизводимости в репозитории lock крайне полезен.
Правило: воспроизводимость сборок обеспечивается lock‑файлом и тем, на каких параметрах вы собираете.
В workspace Cargo.lock обычно находится в корне репозитория. Он фиксирует версии зависимостей, но не фиксирует фичи вашей сборки. То есть:
- при изменении feature-набора можно получить другой набор включённых зависимостей (или другие зависимости внутри одного и того же пакета);
- но версии тех, что попали в graph, будут соответствовать lock.
“Воспроизводимая сборка” — это не одно и то же, что “одинаковая бинарь”
Термины полезно разделять:
- Воспроизводимость на уровне графа зависимостей: одинаковые версии крейтов при одинаковых параметрах.
- Репродуцируемость на уровне артефакта: одинаковый
.wasm/bin/staticlibпобайтно.
Второе сложнее: влияет компилятор, флаги, окружение, генерация debug info, встроенные метаданные сборки и т.п. Однако уже “граф” и “параметры сборки” — большой шаг к предсказуемости.
Закрепляйте toolchain: rust-toolchain.toml
Самый частый источник “у нас не воспроизводится” — разные версии Rust и разный набор стандартной библиотеки.
Добавьте rust-toolchain.toml:
[toolchain]
channel = "1.78.0"
components = ["rustfmt", "clippy"]
targets = ["x86_64-unknown-linux-gnu"]
CI и разработчики, как правило, будут автоматически использовать нужную версию.
Стабилизация окружения сборки
Даже с одинаковым toolchain бинарь может отличаться из-за:
- переменных окружения (
SOURCE_DATE_EPOCH,RUSTFLAGS); - генерации путей (
--remap-path-prefix); - различий в файловых системах и путях к workspace.
Для практических задач, если вы хотите снизить вариативность, используйте RUSTFLAGS в CI и/или местами cargo build --locked. Пример:
cargo build --workspace --release --locked
--locked заставляет Cargo не менять Cargo.lock. Если граф требует обновления lock — команда завершится ошибкой. Это дисциплинирует.
Встроенная информация в бинарь: версии и метаданные
Если вы используете crate_version или env!, то метаданные сборки будут зависеть от того, что вы передаёте через env/manifest. Чтобы это контролировать:
- избегайте “случайного” чтения времени сборки из окружения;
- если нужны timestamps — фиксируйте
SOURCE_DATE_EPOCH.
Пример в CI (conceptually):
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
cargo build --release --locked
Это не гарантирует побайтовую идентичность, но делает результаты более устойчивыми.
Практика: как собирать workspace под конкретные цели
Матрица команд для dev/CI
Для реального проекта полезны несколько “профилей сборки”:
- обычная разработка (быстро, с отладкой),
- релиз под выпуск (строгие профили, locked),
- проверка фич (компиляция с разными наборами features).
Примеры команд:
# Быстрая сборка всего workspace
cargo build --workspace
# Релиз, строго с lock-файлом
cargo build --workspace --release --locked
# Проверка тестов
cargo test --workspace --locked
# clippy по всем крейтам
cargo clippy --workspace --all-targets --locked
Проверка “feature matrix”: не пытайтесь перебрать всё
Комбинаций features может быть очень много. Практичный подход:
- Определите 2–4 наиболее важных конфигурации.
- Прогоняйте CI по ним.
- Остальные конфигурации проверяйте точечно по мере изменения кода.
Команда для включения/выключения features — через --features и --no-default-features на уровне конкретного пакета.
Если бинарный crate cli зависит от core, а core имеет features, то чаще всего управление делается через зависимости в cli/Cargo.toml или через cargo build -p cli --features .... Но следите, чтобы семантика не расползалась: один и тот же feature должен означать одно и то же.
Подводные камни, о которых забывают до первой “боевой” проблемы
1) “Включил feature — сломал совместимость с другими crate’ами”
Если crate A включает feature X у зависимости B, а crate C делает по‑другому — вы можете получить кодовые пути, которые не тестировались вместе. Решение: тестируйте комбинации хотя бы для “важных” сценариев и держите features на уровне сценариев.
2) Default features как скрытая договорённость
Если вы меняете default в библиотеке, downstream может неожиданно получить другой код. Изменения дефолтов — это изменения API.
3) Не фиксируете toolchain
Разные версии Rust способны изменить диагностические сообщения, поведение лінтера и иногда даже кодогенерацию (особенно в крайних оптимизациях).
4) Не используете --locked в релизной ветке
Это приводит к тому, что “релиз из главной” и “релиз вчера вечером” могут отличаться зависимостями без явного коммита с обновлением lock.
5) Смешивание локальных и публикуемых настроек
Публикуемые crate’ы должны быть “самодостаточными”. Иначе вы получите ситуацию: в workspace оно работает, а как только вы публикуете — разваливается.
Репродуцируемость на практике: чеклист для команды
Ниже — набор шагов, которые реально улучшают воспроизводимость и уменьшают “не у меня”:
- Единый layout workspace: корневой
Cargo.tomlсmembersиresolver = "2". Cargo.lockкоммитится (для приложений и CI).cargo build/test --lockedв релизных и проверочных пайплайнах.- Фиксированная версия Rust через
rust-toolchain.toml. - Tooling единый: clippy/rustfmt версии через components.
- features как сценарии, а не как набор кусочков.
- **feature matrix в CI
Комментарии
Пока нет комментариев