Cargo, workspaces и архитектура проекта
Ирма Окунева · совет Гильдии
Движок не кричит в окно. Он отвечает тому, кто спросил. А кричит оболочка, и только если её попросили. Сделай так, чтобы мастерскую можно было запустить тихо, для проверки, и громко, для людей.
Ирма говорит об архитектуре, которую в Rust принято строить с первого дня: библиотека с логикой и тонкий бинарь вокруг неё. Этот урок про Cargo.toml от корки до корки, профили сборки, lib плюс bin, workspaces, feature flags и semver. А ещё здесь наконец пора поставить Rust на свою машину.
Cargo.tomlот корки до корки, профили сборки, workspaces и архитектурная идиома «чистое ядро, тонкая оболочка». После урока ты открываешь чужой Rust-репозиторий и сразу понимаешь, где что лежит.
Идея
Открой репозиторий ripgrep, самого известного консольного поисковика на Rust. В корне нет src/. Вместо него каталог crates/ с десятком крейтов: searcher ищет по байтам, regex компилирует шаблоны, ignore понимает .gitignore, globset матчит маски, а сам бинарник rg лежит рядом тонкой прослойкой, которая всё это склеивает. Тот же рисунок в tokio, в cargo, почти в любом зрелом проекте экосистемы.
Это не привычка больших команд, а прямое продолжение прошлого урока. Модуль проводит границу приватности внутри крейта, а крейт проводит следующую: отдельная единица компиляции, отдельная версия, отдельный публичный API. Зрелый Rust-проект это workspace из нескольких крейтов: библиотеки с логикой, тонкие бинарники поверх них. Логика тестируется как библиотека, бинарь только склеивает. Инструмент, который всем этим управляет, у тебя в руках с первой программы: cargo. Пора прочитать его манифест целиком.
Rust на своей машине
До сих пор код собирал и запускал редактор школы. Проект из нескольких крейтов в нём уже не уместить, поэтому самое время поставить Rust себе. Хорошая новость: как и в Haskell, для Rust есть единый установщик, и всё ставится одной командой.
rustup: твой помощник по установке Rust
Представь, что rustup это универсальный мастер для Rust. Он:
- установит компилятор и все нужные компоненты;
- поможет переключаться между версиями;
- будет вовремя обновлять инструменты.
rustup для Rust то же, что nvm для Node или GHCup для Haskell.
Установим Rust:
- Открой rustup.rs.
- Запусти установщик командой:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- Соглашайся с настройками по умолчанию: они подойдут для учёбы и работы. Установщик поставит стабильную версию Rust (stable) и добавит нужные пути в настройки твоей оболочки.
- После завершения установки перезапусти терминал.
- Проверь, что всё установилось правильно:
rustc --version
# rustc 1.96.0 (или новее)
cargo --version
# cargo 1.96.0
Не переживай из‑за точного номера версии: главное, чтобы всё работало. Работать будем на канале stable: он обновляется каждые шесть недель и держит обратную совместимость.
Обновить Rust потом можно одной командой: rustup update.
Что появилось после установки:
| Инструмент | Зачем он нужен |
|---|---|
rustc | компилятор: превращает код из .rs-файлов в готовую программу |
cargo | главный помощник: создаёт проекты, собирает код, запускает программы, управляет зависимостями |
clippy | линтер: подсказывает, как сделать код идиоматичнее и обойти частые ошибки |
rustfmt | форматтер: приводит код к единому стилю сообщества Rust |
rust-analyzer | языковой сервер для редактора: показывает типы переменных, подсказки, помогает с автодополнением и рефакторингом |
rustc напрямую звать почти не придётся, большинство задач возьмёт на себя cargo. А вот clippy и rustfmt подключай с первого дня: они держат код в едином стиле и ловят частые ошибки.
Настраиваем редактор
Поставь в редактор расширение rust‑analyzer:
- В VS Code найди rust‑analyzer в магазине расширений.
- У JetBrains есть отдельная IDE RustRover со своим анализатором кода.
Как проверить, что всё работает:
- Открой любой файл с расширением
.rs. - Наведи курсор на переменную: через секунду появится подсказка с её типом.
Подсказка с типом пригодится каждый день: Rust выводит типы сам, и в коде видны далеко не все. Наведённый курсор показывает, какой тип вывел компилятор.
Первый проект через cargo
Теперь создадим первый локальный проект с помощью cargo:
cargo new hello_rust
cd hello_rust
Команда создаст такую структуру:
hello_rust
├── Cargo.toml
└── src
└── main.rs
Разберём, что есть в проекте:
Cargo.tomlэто «паспорт» проекта, аналогpackage.jsonв JavaScript: имя, версия и зависимости.src/main.rs: основной файл с кодом.
В Cargo.toml ты увидишь что‑то вроде этого:
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2024"
[dependencies]
Обрати внимание на поле edition: это редакция языка, а не версия компилятора. Раз в три года выходит новая редакция с небольшими улучшениями синтаксиса. Компилятор понимает все редакции сразу, так что старый код не сломается.
В файле src/main.rs уже лежит программа:
fn main() {
println!("Hello, world!");
}
Запустим её:
cargo run
# Compiling hello_rust v0.1.0
# Finished `dev` profile
# Running `target/debug/hello_rust`
# Hello, world!
Что произошло:
cargoсобрал программу в папкеtarget/debug/.- Запустил получившийся исполняемый файл.
- На экране появилось приветствие.
Заметь: получившаяся программа самодостаточна. Скопируй её на другой компьютер с той же системой, даже если там нет Rust, и она запустится.
Цикл разработки: полезные команды
Собирать готовую программу нужно не всегда, часто хватает проверить код:
cargo check
cargo check проверяет код на ошибки с помощью компилятора, но не создаёт исполняемый файл. Благодаря этому команда работает в разы быстрее, чем cargo build.
Хорошая привычка: написал небольшой фрагмент, запусти cargo check. Ошибка найдётся, пока ты ещё помнишь, что писал. В Rust компилятор твой напарник, а не строгий контролёр.
Основные команды на каждый день:
| Команда | Что делает |
|---|---|
cargo check | быстро проверяет код на ошибки, не создавая исполняемый файл |
cargo run | собирает программу и сразу запускает её |
cargo build | собирает debug‑версию программы (для разработки) в папке target/debug |
cargo build --release | собирает оптимизированную версию программы (для релиза) в папке target/release |
cargo clippy | гоняет линтер и подсказывает, как улучшить код |
cargo fmt | форматирует код по правилам сообщества |
Про --release: debug‑версия не оптимизирована и бывает медленнее в десятки раз. Все обещания о скорости Rust относятся к оптимизированной сборке, поэтому замеры делай только с cargo build --release.
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 с предыдущей версией и сам говорит, какой разряд ты обязан поднять.
Чистое ядро, тонкая оболочка
Сложи всё в одну картину. Крейт логики core без I/O: типы домена, разбор, расчёты, и всё это покрыто быстрыми тестами без моков, сети и файлов. Вокруг него тонкие оболочки: cli переводит аргументы в вызовы ядра, server переводит HTTP-запросы в те же вызовы. Оболочки почти не содержат логики, поэтому их не страшно не тестировать юнитами; ядро не содержит I/O, поэтому его тесты мгновенные и стабильные.
Эту дисциплину ты уже встречал как «функциональное ядро, императивная оболочка» в уроке про FP и DDD. Rust добавляет к ней то, чего нет в большинстве языков: границу проверяет компилятор. Зависимости направлены в одну сторону, оболочки знают про ядро, ядро не знает ни про кого; стоит крейту core забыть об этом и потянуть clap или axum, как это станет видно прямо в его манифесте, на ревью, а не на архитектурном комитете через год. Манифест крейта это архитектурная диаграмма, которая не умеет врать.
С этой картой чужие репозитории читаются за минуты: найди workspace-манифест, посмотри список членов, найди крейты без тяжёлых зависимостей, это ядро, и от него раскручивай остальное.
Мастерская, этап 19
src/main.rs мастерской впервые твой. Библиотека больше ничего не печатает: приветствие отдаёт функция greeting, а бинарь печатает его сам и понимает флаги --quiet и --version. В Cargo.toml появляется профиль release, загляни в него.
ДЗ
Дальше
Мастерская запускается тихо и громко, движок молчит, оболочка говорит. Ферапонт задаёт следующий вопрос: а что останется от мастерской, когда ночью погаснет горн? Пора научить её сохраняться.
Каркас проекта собран: манифест, профили, workspace, ядро и оболочки. Осталось наполнить его чужим трудом, в экосистеме Rust почти на каждую задачу есть устоявшийся крейт. В уроке «Экосистема» пройдём по главным: serde для сериализации, clap для CLI, anyhow и thiserror для ошибок, tokio и axum для сети, и, что важнее списка, научимся читать docs.rs и выбирать крейт самостоятельно.