Раздел 23 · Rust

Cargo, workspaces и архитектура проекта

middle~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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:

  1. Открой rustup.rs.
  2. Запусти установщик командой:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  1. Соглашайся с настройками по умолчанию: они подойдут для учёбы и работы. Установщик поставит стабильную версию Rust (stable) и добавит нужные пути в настройки твоей оболочки.
  2. После завершения установки перезапусти терминал.
  3. Проверь, что всё установилось правильно:
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 со своим анализатором кода.

Как проверить, что всё работает:

  1. Открой любой файл с расширением .rs.
  2. Наведи курсор на переменную: через секунду появится подсказка с её типом.

Подсказка с типом пригодится каждый день: 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!

Что произошло:

  1. cargo собрал программу в папке target/debug/.
  2. Запустил получившийся исполняемый файл.
  3. На экране появилось приветствие.

Заметь: получившаяся программа самодостаточна. Скопируй её на другой компьютер с той же системой, даже если там нет 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. Это два профиля, два набора настроек компилятора:

Настройкаdevrelease
opt-level0, без оптимизаций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 fnmajor
добавил поле в pub-структуру с публичными полямиmajor: чужие литералы и match перестанут компилироваться
добавил вариант в pub enummajor, по той же причине

Две строчки из таблицы заслуживают паузы: ломает не только удаление, но и добавление, если тип открыт для конструирования или разбора снаружи. Это прямой мост к прошлому уроку: чем меньше торчит наружу, тем больше свободы менять. Приватные поля, конструкторы, фасад через 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 и выбирать крейт самостоятельно.