← Назад к списку тем

10. Cargo, модули и крейты

Модульная система (mod/use/pub), workspaces, Cargo.toml, features, semver, публикация на crates.io, build.rs.

Модульная система: mod, пути и видимость

В Rust единица компиляции — крейт (crate): бинарный (main.rs) или библиотечный (lib.rs). Внутри крейта пространство имён организовано в дерево модулей через ключевое слово mod. В отличие от Python или Go, где файл автоматически становится модулем/пакетом, в Rust модуль нужно явно объявить — компилятор не сканирует директории сам.

// src/lib.rs — корень крейта
mod network {
    pub fn connect() {
        println!("connecting...");
    }

    mod tcp {
        pub fn handshake() {}
    }
}

fn main() {
    // Полный путь от корня крейта
    crate::network::connect();
}

Начиная с Rust 2018, модуль можно вынести в отдельный файл двумя способами: старый стиль mod.rs и современный стиль "имя файла = имя модуля".

// Стиль Rust 2015/2018 (устаревающий, но всё ещё встречается):
// src/network/mod.rs         — сам модуль network
// src/network/tcp.rs         — подмодуль network::tcp

// Современный стиль (Rust 2018+, рекомендуемый):
// src/network.rs             — сам модуль network
// src/network/tcp.rs         — подмодуль network::tcp

// В src/lib.rs объявление одинаковое в обоих случаях:
mod network;   // компилятор ищет network.rs ИЛИ network/mod.rs
🔑 Ключевое отличие: современный стиль избавляет от десятков одинаково названных вкладок mod.rs в IDE. Файл network.rs сразу говорит, какому модулю принадлежит, чего не скажешь про безликий mod.rs. С Rust 2018 это рекомендуемый подход, mod.rs оставлен для обратной совместимости.

Видимость: pub, pub(crate), pub(super)

По умолчанию всё приватно относительно родительского модуля — это противоположность Go, где видимость определяется регистром первой буквы идентификатора. В Rust видимость — явный модификатор.

mod storage {
    pub struct Database {
        pub name: String,        // видно снаружи модуля
        connection_pool: u32,       // приватно (по умолчанию)
    }

    pub(crate) fn internal_reset() {
        // видно во всём крейте, но не за его пределами —
        // удобно для внутренних API, не часть публичного контракта
    }

    mod engine {
        pub(super) fn flush() {
            // видно только в родительском модуле storage,
            // не дальше — узкая протечка API вглубь дерева
        }
    }
}
  • pub — видно всем, кто может увидеть содержащий модуль (полностью публичный API)
  • pub(crate) — видно в пределах текущего крейта, скрыто от внешних потребителей библиотеки
  • pub(super) — видно только родительскому модулю
  • pub(in path::to::module) — видно только в указанном поддереве
  • без модификатора — приватно для текущего модуля и его потомков

use: импорт и re-export

use создаёт короткий алиас на путь в дереве модулей — аналог import в Python или Go, но с более гибкой системой путей: self, super, crate, имя внешнего крейта.

use std::collections::HashMap;
use std::collections::{HashMap, HashSet, BTreeMap};  // группировка
use std::io::Result as IoResult;             // переименование при импорте
use std::collections::hash_map::Entry::*;         // glob-импорт (осторожно!)

mod shapes {
    pub struct Circle;
    pub struct Square;
}

// Re-export: делаем shapes::Circle доступным как crate::Circle
pub use shapes::Circle;
pub use shapes::Square;

pub use — идиоматичный способ построить плоский публичный API поверх глубоко вложенной внутренней структуры модулей. Это то, что в других экосистемах решается через __init__.py (Python) или явные ре-экспорты в index.ts (TypeScript/npm-пакеты).

// src/lib.rs крупной библиотеки — типичный prelude-паттерн
mod engine;
mod renderer;
mod physics;

// Плоский публичный API вместо myapp::engine::core::Engine
pub use engine::Engine;
pub use renderer::Renderer;
pub use physics::{RigidBody, Collider};

// Потребитель пишет:
// use myapp::{Engine, Renderer, RigidBody};
// а не use myapp::engine::core::Engine;
⚠️ Glob-импорты: use module::*; удобен в тестах (use super::*;) и прелюдиях, но в остальном коде затрудняет отслеживание происхождения идентификаторов и провоцирует конфликты имён при добавлении новых элементов в исходный модуль. Предпочитайте явный список импортов.

Cargo.toml — манифест пакета

Cargo.toml — аналог package.json в npm, go.mod в Go или pyproject.toml в Python: единая точка описания метаданных, зависимостей и конфигурации сборки.

[package]
name = "awesome-service"
version = "0.3.1"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "HTTP-сервис обработки заказов"
repository = "https://github.com/example/awesome-service"
readme = "README.md"
rust-version = "1.75"          # минимальная поддерживаемая версия (MSRV)

[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
reqwest = "0.12"
internal-utils = { path = "../internal-utils" }   # локальная зависимость

[dev-dependencies]
mockito = "1.4"                 # доступно только в cargo test
criterion = "0.5"               # бенчмарки

[build-dependencies]
cc = "1.0"                    # доступно только для build.rs

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
  • [dependencies] — зависимости, попадающие в конечный бинарник/библиотеку
  • [dev-dependencies] — только для тестов, примеров и бенчмарков; не тянутся к потребителям вашей библиотеки
  • [build-dependencies] — зависимости самого build.rs, компилируются отдельно под хост-платформу (актуально при кросс-компиляции)
  • path-зависимости — локальные крейты в монорепозитории, аналог replace в Go modules или file: в npm

Workspaces — монорепозиторий на несколько крейтов

Workspace — механизм Cargo для объединения нескольких пакетов под общим корнем с единым Cargo.lock и общей директорией target/. Близкий аналог — Go workspaces (go.work, с Go 1.18) или npm/yarn workspaces в JS-экосистеме.

# Cargo.toml в корне репозитория
[workspace]
resolver = "2"
members = [
    "crates/api-server",
    "crates/domain",
    "crates/storage",
    "crates/cli",
]
exclude = ["crates/experimental"]

# Общие метаданные, наследуемые дочерними Cargo.toml (Cargo 1.64+)
[workspace.package]
version = "1.2.0"
edition = "2021"
license = "MIT"

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
# crates/api-server/Cargo.toml — дочерний пакет
[package]
name = "api-server"
version.workspace = true       # наследуем из [workspace.package]
edition.workspace = true

[dependencies]
serde.workspace = true          # версия и features из [workspace.dependencies]
tokio.workspace = true
domain = { path = "../domain" }

Все члены workspace разделяют один Cargo.lock в корне — это гарантирует, что во всех крейтах используется одна и та же версия каждой транзитивной зависимости, и исключает дублирование в конечном бинарнике. Команда cargo build, запущенная из корня, собирает все члены с переиспользованием артефактов компиляции.

✅ Практика: секции [workspace.package] и [workspace.dependencies] (Cargo 1.64+) избавляют от рассинхронизации версий зависимостей между десятком внутренних крейтов — классической боли в растущих монорепозиториях.

Single crate vs Workspace: когда что выбирать

Решение зависит от того, как код будет развиваться, тестироваться и публиковаться.

Один крейтWorkspace
Один Cargo.toml, один Cargo.lockКорневой Cargo.toml с [workspace] + Cargo.lock общий на всех членов
Подходит для небольшой библиотеки или сервисаПодходит для монорепозитория: сервис + внутренние библиотеки + CLI-утилиты
Нет переиспользования артефактов сборки между проектамиОбщий target/ — зависимости компилируются один раз для всех членов
Публикация на crates.io — единственный вариант деления по границе публикацииКаждый member публикуется на crates.io независимо своей командой cargo publish
Простая ментальная модель, минимум конфигурацииЕдиная версия транзитивных зависимостей у всех членов — меньше конфликтов semver
Изменение затрагивает только один репозиторий/пакетАтомарные изменения сразу в нескольких взаимосвязанных крейтах в одном PR
CI собирает и тестирует всё целикомМожно точечно собирать/тестировать один member: cargo test -p domain

Типичный сигнал к переходу на workspace — когда в проекте появляется вторая переиспользуемая часть (например, доменная логика, нужная и HTTP-серверу, и CLI-утилите, и воркеру очереди), либо когда команда хочет публиковать несколько связанных крейтов на crates.io (как tokio + tokio-util + tokio-stream).

Features — опциональная компиляция

Cargo features — механизм условной компиляции кода и опциональных зависимостей. Ближайший аналог — build tags в Go или extras в Python (pip install package[extra]), но с более строгими гарантиями через unification.

# Cargo.toml
[features]
default = ["json"]
json = ["dep:serde_json"]
xml = ["dep:quick-xml"]
full = ["json", "xml", "metrics"]
metrics = []

[dependencies]
serde_json = { version = "1.0", optional = true }
quick-xml = { version = "0.31", optional = true }
#[cfg(feature = "json")]
pub fn to_json<T: serde::Serialize>(value: &T) -> String {
    serde_json::to_string(value).unwrap()
}

#[cfg(feature = "xml")]
pub fn to_xml<T>(value: &T) -> String {
    // ...
    todo!()
}

Подключение конкретных features у зависимости:

[dependencies]
awesome-lib = { version = "2.0", default-features = false, features = ["xml"] }

Feature unification

Если в дереве зависимостей несколько крейтов используют один и тот же пакет с разными наборами features, Cargo объединяет их (unification) — итоговый пакет собирается один раз с union всех запрошенных features по всему workspace/графу зависимостей.

⚠️ Подводный камень unification: нельзя "выключить" фичу, которую включил другой крейт в графе зависимостей. Если crate A подключает tokio с features = ["full"], а вы хотели минимальный набор — вы всё равно получите полный набор, потому что features глобальны для сборки. Возможные решения: mutually exclusive features избегать в публичных крейтах, использовать resolver = "2" (разделяет unification для build/dev-dependencies и target-specific зависимостей), либо feature-флаги делать строго аддитивными и без взаимоисключающей логики.

Semantic versioning в Rust

Cargo строго следует SemVer (MAJOR.MINOR.PATCH) и использует его как контракт совместимости при разрешении версий. Это отличает экосистему от npm, где semver — соглашение, часто нарушаемое, и от Go modules, где major-версии ≥ 2 кодируются прямо в пути импорта (module/v2).

[dependencies]
serde = "1.0.200"       # эквивалентно ^1.0.200 — caret-требование по умолчанию
serde = "^1.0.200"     # явная запись того же самого
serde = "~1.0.200"     # tilde: разрешён только патч, >=1.0.200, <1.1.0
serde = "=1.0.200"     # точная версия, без свободы обновления
serde = "1"             # эквивалентно ^1.0.0, >=1.0.0, <2.0.0
  • MAJOR (первая ненулевая цифра) — breaking change, ломает совместимость API
  • MINOR — обратно совместимое добавление функциональности
  • PATCH — обратно совместимое исправление багов
  • caret-требование (^) по умолчанию: допускает обновления, не меняющие первую ненулевую цифру версии — то есть ^1.2.3 разрешает 1.2.4, 1.9.0, но не 2.0.0
  • для версий 0.x.y caret трактует MINOR как breaking-границу: ^0.3.1 разрешает только 0.3.x, но не 0.4.0

Cargo.toml vs Cargo.lock

Cargo.toml описывает допустимые диапазоны версий, а Cargo.lock фиксирует точные версии, реально использованные при последней сборке — для всего графа зависимостей, включая транзитивные.

# фрагмент Cargo.lock (генерируется автоматически, не редактируется руками)
[[package]]
name = "serde"
version = "1.0.203"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7253ab4de971e72fb7be983802300c30b5..."
🔑 Правило коммита в VCS: для бинарных крейтов (приложений, сервисов) Cargo.lock коммитится в репозиторий — гарантирует воспроизводимую сборку. Для библиотечных крейтов, публикуемых на crates.io, Cargo.lock обычно не коммитится: конечный потребитель библиотеки сам разрешает версии в контексте своего графа зависимостей, и зафиксированный lock-файл библиотеки на сборку потребителя не влияет.

Публикация на crates.io

crates.io — центральный публичный реестр пакетов Rust, аналог npmjs.com или PyPI. Публикация выполняется через CLI и требует обязательных метаданных в манифесте.

# Обязательные и рекомендуемые поля для публикации
[package]
name = "my-awesome-crate"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"   # обязательно: license ИЛИ license-file
description = "Краткое, но содержательное описание крейта"
repository = "https://github.com/user/my-awesome-crate"
homepage = "https://my-awesome-crate.dev"
documentation = "https://docs.rs/my-awesome-crate"
keywords = ["async", "http", "client"]   # максимум 5
categories = ["web-programming"]
# Аутентификация — токен из crates.io/settings/tokens
cargo login

# Сухой прогон: что попадёт в архив, проверка компиляции пакета as-is
cargo publish --dry-run

# Публикация. Версии на crates.io НЕЛЬЗЯ перезаписать или удалить —
# только cargo yank помечает версию как нежелательную для новых проектов
cargo publish
  • license — обязательное поле (или license-file для нестандартных лицензий); без него публикация отклоняется
  • description — обязательное поле, отображается в поиске
  • версия, однажды опубликованная, иммутабельна — исправление багов требует новой версии (в этом Cargo строже npm, где unpublish в течение 72 часов возможен)
  • cargo yank — не удаляет версию физически, а запрещает выбирать её для новых Cargo.lock; уже существующие lock-файлы продолжают её использовать
  • docs.rs автоматически собирает и публикует документацию (cargo doc) для каждой опубликованной версии
🚫 Опасно: имя крейта на crates.io — глобальный неизменяемый ресурс "первый пришёл — первый занял". Публикация с типо-сквоттингом популярных имён или удаление важной опубликованной версии без yank может сломать сборки тысяч зависимых проектов ниже по графу.

build.rs — build scripts

build.rs — опциональный Rust-файл в корне пакета (рядом с Cargo.toml), который Cargo компилирует и запускает перед основной сборкой. Используется для генерации кода, линковки нативных библиотек и передачи информации компилятору через специальные директивы вывода в stdout.

// build.rs
fn main() {
    // Пересобирать, только если изменился нативный заголовок
    println!("cargo:rerun-if-changed=vendor/mylib.h");

    // Линковка C-библиотеки: путь и имя
    println!("cargo:rustc-link-search=native=vendor/lib");
    println!("cargo:rustc-link-lib=static=mylib");

    // Прокинуть значение как переменную окружения времени компиляции,
    // доступную в коде через env!("GIT_HASH")
    let git_hash = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_default();
    println!("cargo:rustc-env=GIT_HASH={}", git_hash);

    // Условная компиляция: включить cfg-флаг для дальнейшего кода
    println!("cargo:rustc-cfg=has_native_backend");
}

Типичные сценарии применения build scripts:

  • Генерация кода — например, компиляция .proto-файлов в Rust-структуры через tonic-build/prost-build, результат кладётся в OUT_DIR и подключается макросом include!(concat!(env!("OUT_DIR"), "/generated.rs"))
  • Линковка C/C++ библиотек — сборка через крейт cc, указание путей поиска и имён библиотек для линкера
  • Feature detection — проверка возможностей целевой платформы/компилятора и включение соответствующих cfg
  • Встраивание метаданных сборки — git hash, дата сборки, версия компилятора
⚠️ Затраты build.rs: build script выполняется при каждой сборке, если не указаны точные условия пересборки через cargo:rerun-if-changed/cargo:rerun-if-env-changed — без них Cargo перестраховывается и перезапускает скрипт слишком часто, замедляя инкрементальную сборку. Также build-dependencies компилируются под хост-платформу отдельно от основных зависимостей, что важно учитывать при кросс-компиляции.

Полезные команды Cargo для повседневной работы

# Создание проекта
cargo new my-app                  # бинарный крейт (src/main.rs)
cargo new my-lib --lib            # библиотечный крейт (src/lib.rs)

# Сборка и запуск
cargo build                       # debug-сборка
cargo build --release             # release-сборка с оптимизациями
cargo run --bin api-server        # запуск конкретного бинаря в workspace

# Работа с зависимостями и workspace
cargo add serde --features derive # добавить зависимость в Cargo.toml
cargo tree                        # дерево зависимостей — найти дубли версий
cargo test -p domain              # тесты только для одного member workspace
cargo check --workspace           # быстрая проверка типов без кодогенерации

# Аудит и качество
cargo fmt                         # форматирование по единому стилю
cargo clippy --all-targets        # линтер, ловит идиоматические ошибки
cargo audit                       # проверка известных уязвимостей (cargo-audit)

В сравнении с Go, где go.mod/go.sum и стандартный toolchain покрывают похожий набор задач более минималистично, Cargo берёт на себя больше ролей сразу: сборщик, менеджер зависимостей, тест-раннер, линтер-обвязка и точка входа для публикации — всё через единый манифест и единый CLI.