Введение
Монорепозиторий (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
└──