Монорепозиторий с Rust: организация кода и лучшие практики

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение

Монорепозиторий (monorepo) — это подход к управлению исходным кодом, при котором несколько проектов (библиотек, приложений, сервисов) хранятся в одном репозитории. В экосистеме Rust этот подход получил мощную поддержку через механизм Cargo Workspace. В этой статье мы разберем, как эффективно организовать монорепозиторий на Rust, рассмотрим структуру, управление зависимостями и лучшие практики.

Почему монорепозиторий полезен для Rust-проектов? Он позволяет:

  • Переиспользовать код между несколькими крейтами (библиотеками и бинарниками).
  • Упростить версионирование и CI/CD.
  • Обеспечить атомарность изменений (один коммит может обновить несколько крейтов).
  • Снизить сложность управления зависимостями.

Основы Cargo Workspace

Cargo Workspace — это встроенный механизм Rust для управления монорепозиторием. Он объединяет несколько крейтов (crates) под одним общим файлом Cargo.toml верхнего уровня.

Создание workspace

Для начала создадим директорию проекта и файл Cargo.toml в корне:

# Cargo.toml (корневой)
[workspace]
members = [
    "core",
    "cli",
    "web-api",
    "utils"
]

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.37", features = ["full"] }

Здесь мы определили четыре крейта: core, cli, web-api и utils. Ключевой момент — [workspace.dependencies]. Это позволяет задать общие зависимости для всех крейтов workspace. Каждый крейт может ссылаться на них, не дублируя версии.

Структура директорий

Типичная структура монорепозитория на Rust выглядит так:

my-monorepo/
├── Cargo.toml          # корневой workspace
├── Cargo.lock          # общий lock-файл
├── core/
│   ├── Cargo.toml
│   └── src/
│       └── lib.rs
├── cli/
│   ├── Cargo.toml
│   └── src/
│       └── main.rs
├── web-api/
│   ├── Cargo.toml
│   └── src/
│       └── main.rs
├── utils/
│   ├── Cargo.toml
│   └── src/
│       └── lib.rs
└── README.md

Каждый подкаталог — это отдельный крейт со своим Cargo.toml. Крейты могут быть как библиотеками (lib.rs), так и исполняемыми приложениями (main.rs).

Управление зависимостями и переиспользование кода

Внутренние зависимости

Крейты внутри workspace могут ссылаться друг на друга через локальные пути. Например, cli может зависеть от core и utils:

# cli/Cargo.toml
[package]
name = "cli"
version = "0.1.0"
edition = "2021"

[dependencies]
core = { path = "../core" }
utils = { path = "../utils" }
serde = { workspace = true }
tokio = { workspace = true }

Обратите внимание на синтаксис { workspace = true } — он позволяет использовать общие зависимости, объявленные в корневом Cargo.toml. Это гарантирует, что все крейты используют одну и ту же версию библиотеки.

Пример: общая библиотека core

Создадим простую библиотеку core, которая будет содержать бизнес-логику:

// core/src/lib.rs
use serde::{Serialize, Deserialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: u64,
    pub name: String,
    pub email: String,
}

pub fn create_user(name: String, email: String) -> User {
    User {
        id: rand::random(),
        name,
        email,
    }
}

pub fn greet(user: &User) -> String {
    format!("Hello, {}!", user.name)
}

Теперь крейт cli может использовать эту библиотеку:

// cli/src/main.rs
use core::{create_user, greet};

fn main() {
    let user = create_user("Alice".to_string(), "alice@example.com".to_string());
    println!("{}", greet(&user));
    println!("User ID: {}", user.id);
}

Запуск cargo run -p cli в корне workspace скомпилирует и выполнит только крейт cli, автоматически собрав все зависимости, включая core.

Работа с несколькими бинарниками

Если у вас несколько исполняемых файлов (например, CLI-утилита и веб-сервер), их можно разместить в разных крейтах:

# web-api/Cargo.toml
[package]
name = "web-api"
version = "0.1.0"
edition = "2021"

[dependencies]
core = { path = "../core" }
utils = { path = "../utils" }
actix-web = "4"
tokio = { workspace = true }
// web-api/src/main.rs
use actix_web::{web, App, HttpServer, Responder};
use core::{User, create_user};

async fn create_user_handler() -> impl Responder {
    let user = create_user("Bob".to_string(), "bob@example.com".to_string());
    web::Json(user)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/user", web::post().to(create_user_handler))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

Теперь cargo run -p web-api запустит веб-сервер, а cargo run -p cli — консольное приложение.

Продвинутая организация и best practices

Изоляция тестов

В монорепозитории важно правильно организовать тесты. Каждый крейт может иметь свои unit-тесты. Для интеграционных тестов, которые проверяют взаимодействие между крейтами, создайте отдельный крейт tests:

my-monorepo/
└── tests/
    ├── Cargo.toml
    └──                

Блоги

Рекомендаций по книгам