Раздел 23 · Rust

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

middle~35 мин

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

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. Это два профиля, два набора настроек компилятора:

Настройка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 с предыдущей версией и сам говорит, какой разряд ты обязан поднять.

Чистое ядро, тонкая оболочка

Сложи всё в одну картину. 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 и выбирать крейт самостоятельно.