lenec ru

← все посты

Cargo workspace для проекта с 5+ крейтами: практика и подводные камни

14K

Когда в Rust-проекте появляется больше трёх крейтов, начинать каждый раз с нуля становится скучно. На пятом проекте я выработал свой workflow для cargo workspace, который теперь переношу из репо в репо. Расскажу про устройство, которое стабильно работает, и про несколько грабель, которые встречал по дороге.

Зачем workspace

Если у тебя один бинарь и пара библиотек, можно жить с одним крейтом. Workspace становится нужен, когда:

  • Несколько бинарников, которые делят общий код.
  • Один бинарь, но логика делится на 5+ крейтов для изоляции.
  • Хочется выпускать часть как отдельные crates.io-крейты, остальное держать private.
  • Нужны разные feature-наборы для разных деплой-вариантов.

На моём текущем проекте на работе — 8 крейтов: core, storage, api, worker, cli, proto, migrations, tests-integration. Каждый имеет свою зону ответственности.

Структура каталогов

.
├── Cargo.toml             # workspace root
├── Cargo.lock
├── crates/
│   ├── core/
│   │   ├── Cargo.toml
│   │   └── src/lib.rs
│   ├── storage/
│   │   ├── Cargo.toml
│   │   └── src/lib.rs
│   ├── api/
│   │   └── ...
│   └── worker/
│       └── ...
└── examples/

Все крейты под crates/. Это не обязательно, но удобно: на одном уровне с Cargo.toml часто хочется держать docs/, scripts/, .github/ и прочее.

Корневой Cargo.toml

[workspace]
members = ["crates/*"]
resolver = "2"

[workspace.package]
edition = "2021"
version = "0.1.0"
license = "MIT"
authors = ["Igor <igor@example.com>"]
rust-version = "1.74"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.36", features = ["full"] }
anyhow = "1"
thiserror = "1"
tracing = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }

Самое важное — workspace.dependencies. Все крейты подтягивают версии оттуда, не прописывая их локально:

# crates/core/Cargo.toml
[package]
name = "myapp-core"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
serde.workspace = true
thiserror.workspace = true

Это решает проблему «в одном крейте serde 1.0.190, в другом 1.0.193». В большом проекте с десятком общих зависимостей синхронизация версий вручную превращается в ад.

Resolver = "2"

В [workspace] указываю resolver = "2". Без этого features-флаги резолвятся по старому алгоритму, и часто крейт A включает feature, которая не нужна крейту B, а в итоге собирается одна версия с лишними feature.

resolver 2 решает features изолированно для разных целей: dev-deps, build-deps, target-specific. На больших workspace это даёт заметно меньше конфликтов и более компактный итоговый бинарь.

Внутренние зависимости

# crates/api/Cargo.toml
[dependencies]
myapp-core = { path = "../core" }
myapp-storage = { path = "../storage" }

Прописываю path — так Cargo собирает крейт из локального исходника. Если когда-нибудь захочется опубликовать в crates.io, я заменю это на { path = "../core", version = "0.1" } — Cargo будет использовать локальный для билда, но указанная версия пойдёт в опубликованный пакет.

Профили сборки

[profile.dev]
opt-level = 0
debug = true

[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"

[profile.dev.package."*"]
opt-level = 1

Последняя секция — лайфхак: компилит зависимости с opt-level = 1, а свой код с 0. На дев-сборке это ускоряет финальное приложение раз в 5 (например, serde_json без оптимизаций невыносимо медленный), а свой код перекомпилируется быстро.

Для release: lto = "thin" и codegen-units = 1 дают самую тугую сборку. Время билда вырастает раза в полтора, бинарник становится меньше и быстрее. Для CI это ОК, для локальной разработки — нет.

Feature flags на уровне workspace

Вторая частая боль — feature management. Если у тебя в крейте A есть feature postgres, а ты хочешь, чтобы в одном бинарнике он был включён, а в другом нет — без правильного resolver это сломается.

# crates/storage/Cargo.toml
[features]
default = []
postgres = ["sqlx/postgres"]
sqlite = ["sqlx/sqlite"]
# crates/api/Cargo.toml
[dependencies]
myapp-storage = { path = "../storage", features = ["postgres"] }

Если в worker нужен sqlite, он подтягивает свою feature. resolver 2 не объединит их в одну сборку.

Что кладу в core, что в отдельные крейты

Эмпирическое правило: в core идёт то, что не зависит ни от чего тяжёлого. Доменные типы, общие ошибки, утилитные функции. Если мне нужен tokio или sqlx в core — значит, я неправильно нарезал крейты, и их надо вынести в отдельный модуль.

Простой пример из недавнего:

  • core: типы UserId, Order, OrderStatus, общий Error через thiserror, никаких runtime-зависимостей.
  • storage: репозитории, sqlx, миграции.
  • api: HTTP-роуты, axum, сериализация в JSON.
  • worker: фоновые задачи, чтение из Kafka.

Иерархия зависимостей: api зависит от storage и core. worker зависит от storage и core. storage зависит от core. core ни от чего не зависит. Циклов нет — Cargo всё равно их не разрешит.

Подводные камни

Слишком мелкая нарезка

Видел проекты, где пытались сделать «по крейту на каждую сущность»: user, order, payment, balance. Получался ад из 30 крейтов, каждый по 200 строк, и сборка занимала вечность из-за сериализации компиляции.

Эмпирика: крейт оправдан, если в нём минимум 1000 строк или он действительно изолирован (например, ffi-обёртка). Иначе модули внутри одного крейта работают лучше.

Cargo workspace inheritance не везде поддерживается

В IDE и инструментах долго не было поддержки version.workspace = true. Сейчас более-менее везде есть, но если ты используешь экзотический инструментарий (например, какой-нибудь не-rust-analyzer LSP) — проверяй заранее.

Билд через cargo build vs cargo build -p

На большом workspace cargo build может перекомпилить всё лишнее. Привыкай использовать cargo build -p crate-name — собирает только нужный крейт и его зависимости. Аналогично cargo test -p.

Cargo.lock в библиотеках

Если у тебя workspace из бинарников и библиотек — Cargo.lock один на всех в корне. Если позже захочешь публиковать какие-то крейты на crates.io как библиотеки, помни: библиотеки традиционно не коммитят свой Cargo.lock. Но в workspace это сложно: лок один. Решение — публикуемые библиотеки выносить в отдельный репозиторий или использовать publish = false для всего, что не идёт наружу.

CI и кэширование

Сборка большого workspace в CI занимает минут 5–10. Кэшируйте ~/.cargo/registry, ~/.cargo/git и target/ — на инкрементальной сборке это даёт ускорение в 3–5 раз. Использую Swatinem/rust-cache в GitHub Actions, проще ничего нет.

Для большого workspace ещё ставлю cargo nextest вместо cargo test — он параллельный и не пересобирает всё ради одного теста.

Что в итоге

Cargo workspace — это инструмент, который хорошо масштабируется до десятков крейтов. Главное — правильно разрезать зависимости и использовать workspace.dependencies для синхронизации версий. Профили сборки и resolver 2 — must have. Остальное вырастает по мере проекта.

Если только начинаешь — посмотри workspace в крупных open source проектах: tokio, serde, polars. На реальном коде видно, как соблюдают границы, что выносят в общий крейт, как именуют члены workspace. Это лучшая документация по теме.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.