Cargo, workspaces и архитектура проекта
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Cargo, workspaces и архитектура проекта
Cargo.tomlот корки до корки, профили сборки, workspaces и архитектурная идиома «чистое ядро, тонкая оболочка». После урока ты открываешь чужой Rust-репозиторий и сразу понимаешь, где что лежит.
Идея
Открой репозиторий ripgrep, самого известного консольного поисковика на Rust. В корне нет src/. Вместо него каталог crates/ с десятком крейтов: searcher ищет по байтам, regex компилирует шаблоны, ignore понимает .gitignore, globset матчит маски, а сам бинарник rg лежит рядом тонкой прослойкой, которая всё это склеивает. Тот же рисунок в tokio, в cargo, почти в любом зрелом проекте экосистемы.
Это не привычка больших команд, а прямое продолжение прошлого урока. Модуль проводит границу приватности внутри крейта, а крейт проводит следующую: отдельная единица компиляции, отдельная версия, отдельный публичный API. Зрелый Rust-проект это workspace из нескольких крейтов: библиотеки с логикой, тонкие бинарники поверх них. Логика тестируется как библиотека, бинарь только склеивает. Инструмент, который всем этим управляет, у тебя в руках с первого урока: cargo. Пора прочитать его манифест целиком.
Cargo.toml: манифест
Минимальный Cargo.toml ты видел не раз, посмотрим на манифест с обвесом:
[package]
name = "monitoring"
version = "0.3.1"
edition = "2024"
rust-version = "1.85" # минимальная версия компилятора
[dependencies]
serde = { version = "1.0", features = ["derive"] }
chrono = "0.4"
local-utils = { path = "../local-utils" } # крейт по соседству, без реестра
[dev-dependencies]
proptest = "1" # видят только тесты, бенчи и examples
Главное, что надо понять про строку chrono = "0.4": это не точная версия, а диапазон. По умолчанию cargo трактует версию по правилам semver-совместимости: "1.0" означает «любая 1.x не ниже 1.0», "0.4" означает «любая 0.4.x». Какая ревизия реально установилась, записано в Cargo.lock: cargo генерирует его при первой сборке и дальше держит весь граф зависимостей прибитым к точным версиям. Коммить его в git: для бинарника это закон (прод собирается из тех же байт, что и CI), и для библиотек сегодня рекомендация та же. Обновление это осознанное действие cargo update, а не сюрприз при свежем клоне.
Секция [dev-dependencies] отделяет инструменты от продукта: генератор тестовых данных нужен тестам, но тот, кто подключит твой крейт, не должен тащить его себе. Пользователи получают только [dependencies].
Профили: чем dev отличается от release
Ты уже заметил, что cargo build собирает быстро, а программа бегает вяло, и наоборот с cargo build --release. Это два профиля, два набора настроек компилятора:
| Настройка | dev | release |
|---|---|---|
opt-level | 0, без оптимизаций | 3, полный набор |
debug | да, полная отладочная информация | нет |
overflow-checks | да, переполнение паникует | нет, переполнение заворачивается |
| скорость сборки | секунды | минуты на большом проекте |
Правило простое: разработка и тесты в dev, замеры производительности и прод только в release. Мерить скорость dev-сборки бессмысленно, там выключен оптимизатор, ради которого ты выбирал Rust. А строка про переполнение пусть ляжет в копилку: одна и та же арифметика в двух профилях ведёт себя по-разному, и почему это вообще возможно, разберём в уроке про целые числа и дополнительный код.
Профили можно подкручивать в манифесте:
[profile.release]
lto = "fat" # оптимизация поверх границ крейтов, медленная сборка
codegen-units = 1 # один блок кодогенерации: меньше параллелизма, лучше код
strip = "symbols" # выбросить символы, бинарник заметно худеет
panic = "abort" # паника валит процесс сразу, без раскрутки стека
LTO и соседние ручки дают типичной программе минус десятки процентов размера и единицы процентов скорости. Крутить их стоит как любую оптимизацию: сначала измерь, потом меняй, и держи в голове цену, сборка release-профиля станет ощутимо дольше.
lib плюс bin: бинарь как первый потребитель
Один пакет может содержать и библиотеку, и бинарник, и это первая архитектурная идиома Rust:
src/
├── lib.rs # вся логика: типы, разбор, отчёты
└── main.rs # тонкая оболочка: аргументы, вызов библиотеки, вывод
main.rs подключает библиотеку по имени пакета, как любой внешний код:
// src/main.rs
use monitoring::{parse_feed, render_report};
fn main() {
let path = std::env::args().nth(1).expect("нужен путь к ленте");
let feed = parse_feed(&path).expect("лента не разобралась");
println!("{}", render_report(&feed));
}
Смысл разделения вскрывается, как только заглянешь в каталог tests/. Файлы там, интеграционные тесты, компилируются как отдельные крейты и видят только публичный API библиотеки; до main.rs им не дотянуться вовсе. Всё, что лежит в бинарнике, непротестируемо интеграционно, поэтому в нём и должно оставаться только то, что не жалко: разбор аргументов и печать. Бинарь это первый потребитель собственной библиотеки, и притом самый придирчивый: если API неудобен тебе в main.rs, чужим он будет неудобен вдвойне. Тот же статус у каталога examples/: каждый файл там, мини-программа на публичном API (cargo run --example demo), одновременно документация и проверка, что библиотекой можно пользоваться.
Workspaces
Проект растёт дальше, и одного пакета становится мало: ядро хочется переиспользовать из CLI и из веб-сервиса, а полная пересборка на каждое изменение надоедает. Следующая ступень, workspace. В корне репозитория лежит манифест без [package], только список членов:
# Cargo.toml в корне
[workspace]
members = ["crates/core", "crates/cli", "crates/server"]
resolver = "3"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] } # версия объявлена один раз
monitoring/
├── Cargo.toml # манифест workspace
├── Cargo.lock # один на всех
├── target/ # один на всех: общий кеш сборки
└── crates/
├── core/ # библиотека: типы и логика, ноль I/O
├── cli/ # бинарник: clap, вызовы core, вывод
└── server/ # бинарник: HTTP поверх того же core
Члены ссылаются друг на друга по пути (core = { path = "../core" }), общие зависимости наследуют из корня (serde.workspace = true), и весь workspace живёт с одним Cargo.lock и одним target/: версии согласованы, общие зависимости компилируются один раз. Команды cargo понимают флаг -p: cargo test -p core гоняет тесты одного крейта, cargo build собирает всё.
Когда дробить пакет на крейты? Три честных причины. Граница переиспользования: core нужен и CLI, и серверу. Граница компиляции: cargo пересобирает изменившийся крейт и тех, кто от него зависит, и в большом проекте нарезка ощутимо ускоряет цикл правка-проверка. Граница API: внутри крейта pub(crate) ходит свободно, а между крейтами только честный pub, и эта жёсткость дисциплинирует. Обратная сторона тоже есть: крейт на каждый модуль это бойлерплейт манифестов и зоопарк версий без всякой выгоды. Дерево модулей из прошлого урока отлично организует код внутри крейта; новый крейт заводи, когда появилась одна из трёх причин, а не по привычке.
Feature flags
Иногда библиотеке нужна необязательная функциональность: JSON-вывод полезен трети пользователей, а зависимость от serde получат все. Для этого у cargo есть фичи:
[dependencies]
serde = { version = "1.0", optional = true } # ставится, только если попросят
[features]
default = [] # что включено из коробки
json = ["dep:serde"] # фича тянет опциональную зависимость
Код под фичей помечается атрибутом условной компиляции:
#[cfg(feature = "json")]
pub fn to_json(report: &Report) -> String { /* ... */ }
Пользователь решает сам: monitoring = { version = "0.3", features = ["json"] }. Кто не попросил, не получает ни функции, ни serde в графе зависимостей.
Одно правило проектирования фич стоит выучить до того, как наступишь на грабли: фичи аддитивны. Если крейт встречается в графе зависимостей дважды с разными наборами фич, cargo соберёт его один раз с объединением наборов. Поэтому фича обязана только добавлять: функции, реализации, зависимости. Фича, которая меняет поведение существующего кода или, хуже, выключает что-то, сломает чужую сборку в тот момент, когда два независимых пользователя твоего крейта встретятся в одном дереве.
Semver: что считается поломкой
Версии в манифесте работают, пока вся экосистема держит одно обещание: в пределах мажорной версии обновление ничего не ломает. Cargo обновляет в этих пределах автоматически, значит, цена нарушенного обещания, сломанная сборка у чужих людей в чужом CI.
Поэтому автор библиотеки обязан понимать, какое изменение куда попадает:
| Изменение | Версия |
|---|---|
| починил баг, не трогая сигнатуры | patch · 0.3.1 в 0.3.2 |
добавил новую pub fn, новый тип | minor · 0.3 в 0.4… |
удалил или переименовал pub-элемент | major |
изменил сигнатуру pub fn | major |
добавил поле в pub-структуру с публичными полями | major: чужие литералы и match перестанут компилироваться |
добавил вариант в pub enum | major, по той же причине |
Две строчки из таблицы заслуживают паузы: ломает не только удаление, но и добавление, если тип открыт для конструирования или разбора снаружи. Это прямой мост к прошлому уроку: чем меньше торчит наружу, тем больше свободы менять. Приватные поля, конструкторы, фасад через pub use: всё это не эстетика, а валюта, которой оплачиваются будущие версии без мажорного скачка.
Для версий 0.x правило сдвигается на разряд: совместимыми считаются патчи внутри 0.3.x, а скачок 0.3 в 0.4 объявляет поломку. И не держи это в голове на доверии, есть инструмент: cargo semver-checks сравнивает публичный API с предыдущей версией и сам говорит, какой разряд ты обязан поднять.
Чистое ядро, тонкая оболочка
Сложи всё в одну картину. Logic-крейт core без I/O: типы домена, разбор, расчёты, и всё это покрыто быстрыми тестами без моков, сети и файлов. Вокруг него тонкие оболочки: cli переводит аргументы в вызовы ядра, server переводит HTTP-запросы в те же вызовы. Оболочки почти не содержат логики, поэтому их не страшно не тестировать юнитами; ядро не содержит I/O, поэтому его тесты мгновенные и стабильные.
Эту дисциплину ты уже встречал как «функциональное ядро, императивная оболочка» в уроке про FP и DDD. Rust добавляет к ней то, чего нет в большинстве языков: границу проверяет компилятор. Зависимости направлены в одну сторону, оболочки знают про ядро, ядро не знает ни про кого; стоит крейту core забыть об этом и потянуть clap или axum, как это станет видно прямо в его манифесте, на ревью, а не на архитектурном комитете через год. Манифест крейта это архитектурная диаграмма, которая не умеет врать.
С этой картой чужие репозитории читаются за минуты: найди workspace-манифест, посмотри список членов, найди крейты без тяжёлых зависимостей, это ядро, и от него раскручивай остальное.
ДЗ
Дальше
Каркас проекта собран: манифест, профили, workspace, ядро и оболочки. Осталось наполнить его чужим трудом, в экосистеме Rust почти на каждую задачу есть устоявшийся крейт. В уроке 16 пройдём по главным: serde для сериализации, clap для CLI, anyhow и thiserror для ошибок, tokio и axum для сети, и, что важнее списка, научимся читать docs.rs и выбирать крейт самостоятельно.