Modulares Design für robuste Rust-Webprojekte
Min-jun Kim
Dev Intern · Leapcell

Beim Aufbau groß angelegter Webanwendungen treten einzigartige Herausforderungen auf. Mit zunehmender Komplexität von Projekten kann die Codebasis schnell unüberschaubar werden, was zu einer verringerten Entwicklungsgeschwindigkeit, erhöhten Fehlerzahlen und einer frustrierenden Entwicklererfahrung führt. Dies gilt insbesondere für eine leistungskritische Sprache wie Rust, in der sorgfältige architektonische Entscheidungen tiefgreifende Auswirkungen sowohl auf die Kompilierungszeiten als auch auf die Laufzeitleistung haben können. Für Rust-Webframeworks wie Actix Web und Axum ist die Einführung eines durchdachten modularen Designs nicht nur eine Best Practice, sondern eine Notwendigkeit, um Wartbarkeit, Skalierbarkeit und kollaborative Entwicklung zu fördern. Dieser Artikel befasst sich damit, wie groß angelegte Actix Web- und Axum-Projekte effektiv strukturiert werden, und führt Sie durch die Prinzipien und praktischen Umsetzungen der Modularität, um sicherzustellen, dass Ihre Anwendung im Laufe ihrer Entwicklung robust und anpassungsfähig bleibt.
Verständnis der Modularität in Rust-Webprojekten
Bevor wir uns konkreten Beispielen widmen, klären wir einige Kernkonzepte der Modularität im Kontext der Rust-Webentwicklung.
Modularität: Im Kern ist Modularität die Praxis, ein System in kleinere, unabhängige und austauschbare Komponenten zu zerlegen, die als Module bezeichnet werden. Jedes Modul sollte ein bestimmtes Funktionsstück kapseln und eine klar definierte Schnittstelle bereitstellen, während seine internen Implementierungsdetails verborgen bleiben.
Crates: In Rust ist eine "Crate" die grundlegende Kompilierungseinheit und auch die Einheit für Versionierung und Verteilung. Ein Projekt kann aus einer einzigen "Binary Crate" oder einer "Library Crate" bestehen oder eine "Workspace" sein, die aus mehreren voneinander abhängigen Crates besteht.
Module (Dateisystem): Innerhalb einer Crate wird Code mithilfe des mod
-Schlüsselworts und der Dateisystemhierarchie in Module organisiert. Diese Module helfen, den Code logisch zu organisieren und die Sichtbarkeit zu steuern.
Domain-Driven Design (DDD): Ein Softwareentwicklungsansatz, der die Berücksichtigung und Modellierung der "Domäne" oder des Fachgebiets der Software betont. Wichtige Konzepte sind:
- Domäne: Das Fachgebiet, auf das der Benutzer das Programm anwendet.
- Bounded Context: Eine logische Grenze, innerhalb der ein bestimmtes Domänenmodell definiert und anwendbar ist. Sie hilft, die Komplexität durch Isolierung verschiedener Teile eines großen Systems zu bewältigen.
- Entities: Objekte, die durch ihre Identität definiert sind, nicht nur durch ihre Attribute (z. B. ein "Benutzer" mit einer eindeutigen ID).
- Value Objects: Objekte, die vollständig durch ihre Attribute definiert sind (z. B. ein "Geld"-Objekt mit Betrag und Währung).
- Aggregates: Eine Gruppe zugehöriger Objekte, die für Datenänderungen als eine einzige Einheit behandelt werden. Ein Aggregat hat eine Wurzel-Entity.
- Repositories: Abstraktionen zum Abrufen und Speichern von Aggregaten.
- Services: Operationen, die natürlich nicht in eine Entity oder ein Value Object passen und oft übergreifend über mehrere Aggregate orchestrieren.
Layered Architecture: Ein gängiges Architekturmuster, das eine Anwendung in verschiedene konzeptionelle Schichten unterteilt, von denen jede spezifische Verantwortlichkeiten hat. Eine typische Webanwendung kann umfassen:
- Präsentations-/API-Layer: Behandelt HTTP-Anfragen, Authentifizierung, Daten-Serialisierung/Deserialisierung (z. B. Actix Web/Axum-Handler).
- Anwendungs-/Service-Layer: Orchestriert die Geschäftslogik, ruft Domain-Services auf und manipuliert Repositories.
- Domänen-Layer: Enthält die Kern-Geschäftslogik, Entities, Value Objects und Domain-Services. Diese Schicht sollte framework-unabhängig sein.
- Infrastruktur-Layer: Behandelt externe Belange wie Datenbanken, Dateisysteme, externe APIs und Nachrichtenwarteschlangen.
Durch die Nutzung dieser Konzepte können wir hochgradig entkoppelte und wartbare Webdienste entwickeln.
Prinzipien und Implementierung
Die Kernidee hinter modularem Design ist die Erzielung von hoher Kohäsion (Elemente innerhalb eines Moduls gehören zusammen) und niedriger Kopplung (Module sind unabhängig und haben minimale Abhängigkeiten voneinander).
1. Projektstruktur mit Workspaces
Für große Projekte ist Rusts Workspace-Funktion von unschätzbarem Wert. Sie ermöglicht es Ihnen, mehrere verwandte Crates gemeinsam zu verwalten. Dies ist eine gängige Strategie, um verschiedene logische Komponenten in eigene Crates zu trennen.
Betrachten Sie eine Multi-Service-Anwendung oder eine Anwendung mit klaren logischen Grenzen:
my_big_project/
├── Cargo.toml # Workspace Cargo.toml
├── services/
│ ├── user-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # Actix Web/Axum app for users
│ ├── product-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # Actix Web/Axum app for products
│ └── order-service/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # Actix Web/Axum app for orders
└── shared_crates/
├── domain/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # Core business logic, entities, value objects
├── infrastructure/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # Database access (e.g., SQLx), external API clients
└── common_types/
├── Cargo.toml
└── src/
└── lib.rs # Common DTOs, error types
my_big_project/Cargo.toml
:
[workspace] members = [ "services/user-service", "services/product-service", "services/order-service", "shared_crates/domain", "shared_crates/infrastructure", "shared_crates/common_types", ] [workspace.dependencies] # Define common dependencies here to ensure consistent versions. # E.g., for Axum services: tokio = { version = "1.36", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } # ... other common dependencies
Jede services/*
-Crate würde dann die relevanten shared_crates/*
-Crates über ihre individuellen Cargo.toml
-Dateien referenzieren. Zum Beispiel könnte user-service/Cargo.toml
enthalten:
[dependencies] axum = "0.7.4" tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } domain = { path = "../../shared_crates/domain" } infrastructure = { path = "../../shared_crates/infrastructure" } common_types = { path = "../../shared_crates/common_types" } # ... other service-specific dependencies
Diese Struktur trennt Bedenken klar:
- Jede
service
-Crate ist eine unabhängige, deploybare Einheit. domain
enthält die Kern-Universallogik, unabhängig von Webframeworks oder Datenbanken.infrastructure
kapselt externe Abhängigkeiten.common_types
verhindert die Duplizierung gemeinsamer Datenstrukturen.
2. Layered Architecture innerhalb einer Crate
Selbst innerhalb einer einzelnen Binär-Crate (z. B. eines einfachen Webdienstes, der noch nicht in Microservices aufgeteilt wurde) ist eine geschichtete Architektur mit Rusts Modulsystem entscheidend.
Nehmen wir als Beispiel ein user-service
und wenden Sie die Prinzipien der geschichteten Architektur an:
user-service/
└── src/
├── main.rs # Entry point, initializes server, state
├── api/ # Presentation/API Layer
│ ├── mod.rs # Defines routes, state extraction
│ ├── handlers/ # HTTP request handlers
│ │ ├── mod.rs
│ │ └── user_handler.rs
│ └── dtos/ # Data Transfer Objects for API input/output
│ └── mod.rs
├── application/ # Application/Service Layer
│ ├── mod.rs
│ ├── services/ # Orchestrates domain logic, interacts with repositories
│ │ ├── mod.rs
│ │ └── user_app_service.rs
│ └── commands/ # Input to application services
│ └── mod.rs
├── domain/ # Domain Layer
│ ├── mod.rs
│ ├── entities/ # User entity, etc.
│ │ └── mod.rs
│ ├── value_objects/ # UserId, Email, Password, etc.
│ │ └── mod.rs
│ ├── services/ # Pure domain logic (e.g., password hashing)
│ │ └── mod.rs
│ └── repositories/ # Traits defining repository interfaces
│ └── mod.rs
└── infrastructure/ # Infrastructure Layer
├── mod.rs
├── persistence/ # Database implementations of repository traits
│ ├── mod.rs
│ └── user_repository_impl.rs
├── config.rs # Application configuration loading
└── error.rs # Custom error handling
Beispiel-Code-Snippets (Axum):
domain/repositories/user_repository.rs
(Trait-Definition für Repository):
use async_trait::async_trait; use crate::domain::entities::User; use crate::domain::value_objects::UserId; use crate::infrastructure::error::ServiceError; #[async_trait] pub trait UserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError>; async fn save(&self, user: &mut User) -> Result<(), ServiceError>; // ... other user-related database operations }
infrastructure/persistence/user_repository_impl.rs
(Konkrete Datenbankimplementierung):
use async_trait::async_trait; use sqlx::{PgPool, FromRow}; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::infrastructure::error::ServiceError; // This struct represents the database schema for a User #[derive(Debug, Clone, FromRow)] struct UserDb { id: String, email: String, username: String, // ... other fields } pub struct PgUserRepository { pool: PgPool, } impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } // Convert from DB model to Domain Entity impl TryFrom<UserDb> for User { type Error = ServiceError; // Or a more specific domain error fn try_from(db_user: UserDb) -> Result<Self, Self::Error> { Ok(User::new( UserId::new(&db_user.id).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, Email::new(&db_user.email).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, db_user.username, // ... )) } } // Convert from Domain Entity to DB model impl From<&User> for UserDb { fn from(user: &User) -> Self { UserDb { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), // ... } } } #[async_trait] impl UserRepository for PgUserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError> { let user_db = sqlx::query_as!(UserDb, "SELECT id, email, username FROM users WHERE id = $1", id.to_string()) .fetch_optional(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; user_db.map(|u| u.try_into()).transpose() } async fn save(&self, user: &mut User) -> Result<(), ServiceError> { let user_db: UserDb = user.into(); sqlx::query( "INSERT INTO users (id, email, username) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, username = $3", user_db.id, user_db.email, user_db.username ) .execute(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; Ok(()) } }
application/services/user_app_service.rs
(Application Service):
use std::sync::Arc; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::api::dtos::{CreateUserRequest, UserResponse}; use crate::infrastructure::error::ServiceError; pub struct UserApplicationService<R: UserRepository> { user_repository: Arc<R>, } impl<R: UserRepository> UserApplicationService<R> { pub fn new(user_repository: Arc<R>) -> Self { Self { user_repository } } pub async fn create_user(&self, request: CreateUserRequest) -> Result<UserResponse, ServiceError> { let email = Email::new(&request.email) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; // Example: Check if user already exists // if self.user_repository.find_by_email(&email).await?.is_some() { // return Err(ServiceError::Conflict("User with this email already exists".to_string())); // } let mut user = User::new_with_generated_id(email, request.username); self.user_repository.save(&mut user).await?; Ok(UserResponse { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), }) } pub async fn get_user_by_id(&self, id: &str) -> Result<Option<UserResponse>, ServiceError> { let user_id = UserId::new(id) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; let user = self.user_repository.find_by_id(&user_id).await?; Ok(user.map(|u| UserResponse { id: u.id().to_string(), email: u.email().to_string(), username: u.username().to_string(), })) } }
api/handlers/user_handler.rs
(Axum Handler):
use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use std::sync::Arc; use crate::{ api::dtos::{CreateUserRequest, UserResponse}, application::services::user_app_service::UserApplicationService, domain::repositories::UserRepository, infrastructure::error::ServiceError, }; // Define our AppState to hold shared dependencies #[derive(Clone)] pub struct AppState<R: UserRepository> { pub user_app_service: Arc<UserApplicationService<R>>, } pub async fn create_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Json(request): Json<CreateUserRequest>, ) -> Result<Json<UserResponse>, ServiceError> { let response = app_state.user_app_service.create_user(request).await?; Ok(Json(response)) } pub async fn get_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Path(user_id): Path<String>, ) -> Result<(StatusCode, Json<UserResponse>), ServiceError> { if let Some(user_response) = app_state.user_app_service.get_user_by_id(&user_id).await? { Ok((StatusCode::OK, Json(user_response))) } else { Err(ServiceError::NotFound("User not found".to_string())) } }
main.rs
(Assembles the application):
use axum::{routing::{get, post}, Router}; use std::{net::SocketAddr, sync::Arc}; use sqlx::PgPool; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod api; mod application; mod domain; mod infrastructure; use infrastructure::{ config::Config, error::ServiceError, persistence::user_repository_impl::PgUserRepository, }; use api::handlers::{ user_handler::{self, AppState}, }; use application::services::user_app_service::UserApplicationService; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "user_service=debug,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); let config = Config::load_from_env(); let pool = PgPool::connect(&config.database_url).await?; // Run database migrations sqlx::migrate!("./migrations") .run(&pool) .await .map_err(|e| ServiceError::DatabaseError(format!("Migration failed: {}", e)))?; // Instantiate repositories and services let user_repo = Arc::new(PgUserRepository::new(pool.clone())); let user_app_service = Arc::new(UserApplicationService::new(user_repo.clone())); let app_state = AppState { user_app_service, }; let app = Router::new() .route("/users", post(user_handler::create_user_handler::<PgUserRepository>)) .route("/users/:user_id", get(user_handler::get_user_handler::<PgUserRepository>)) .with_state(app_state.clone()); // Pass the state to the router let addr = SocketAddr::from(([127, 0, 0, 1], config.port)); tracing::info!("listening on {}", addr); axum::Server::bind(&addr) .serve(app.into_make_service()) .await?; Ok(()) }
Diese Struktur grenzt Verantwortlichkeiten klar ab:
- Die
domain
-Schicht ist reines Rust und konzentriert sich auf Geschäftsregeln und Datenmodelle, völlig unabhängig von Axum/Actix Web odersqlx
. - Die
infrastructure
-Schicht behandelt spezifische Technologien (z. B.sqlx::PgPool
). - Die
application
-Schicht koordiniertdomain
- undinfrastructure
-Komponenten zur Ausführung von Anwendungsfällen. - Die
api
-Schicht behandelt HTTP-Anfragen und -Antworten und übersetzt zwischen generischen Webformaten und anwendungsspezifischen Eingaben/Ausgaben. main.rs
ist für die Zusammensetzung und den Start zuständig.
3. Dependency Injection und Traits für Entkopplung
Beachten Sie, wie UserApplicationService
und user_handler
generisch über R: UserRepository
definiert sind. Dies ist ein leistungsstarkes Rust-Muster für Dependency Injection. Anstatt PgUserRepository
direkt in UserApplicationService
zu instanziieren, drücken wir eine Abhängigkeit von "jedem Typ aus, der den UserRepository
-Trait implementiert".
Dies bietet mehrere Vorteile:
- Testbarkeit: In Unit-Tests für
UserApplicationService
können Sie eine Mock- oder Fake-UserRepository
-Implementierung bereitstellen und tatsächliche Datenbankaufrufe umgehen. - Flexibilität: Sie können die Datenbankimplementierung (z. B. von PostgreSQL zu MongoDB) leicht austauschen, indem Sie eine neue
UserRepository
-Implementierung erstellen, ohne die Logik vonUserApplicationService
zu ändern. - Entkopplung: Schichten hängen nur von Traits (Abstraktionen) ab, nicht von konkreten Implementierungen, was die lose Kopplung fördert.
4. Strategie für die Fehlerbehandlung
Eine zentralisierte Strategie für die Fehlerbehandlung ist in großen Anwendungen entscheidend. Definieren Sie in Ihrer Datei infrastructure/error.rs
eine benutzerdefinierte ServiceError
-Enum, die verschiedene Arten von Fehlern, die Ihre Anwendung möglicherweise auftreten, kapselt (z. B. DatabaseError
, ValidationError
, NotFound
, Unauthorized
). Implementieren Sie From
-Konvertierungen für gängige Fehlertypen (wie sqlx::Error
oder Validierungsfehler) in Ihr ServiceError
.
Für Axum können Sie das IntoResponse
-Trait für Ihre ServiceError
-Enum implementieren, um Fehler automatisch in entsprechende HTTP-Antworten zu konvertieren.
// infrastructure/error.rs use axum::response::{IntoResponse, Response}; use axum::http::StatusCode; #[derive(Debug)] pub enum ServiceError { NotFound(String), BadRequest(String), Unauthorized(String), Conflict(String), DatabaseError(String), InternalServerError(String), // ... possibly more specific errors } impl IntoResponse for ServiceError { fn into_response(self) -> Response { let (status, error_message) = match self { ServiceError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), ServiceError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), ServiceError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), ServiceError::Conflict(msg) => (StatusCode::CONFLICT, msg), ServiceError::DatabaseError(msg) => { tracing::error!("Database error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) }, ServiceError::InternalServerError(msg) => { tracing::error!("Internal server error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string()) }, }; // You might want a structured error payload for the client let body = serde_json::json!({ "error": error_message, }); (status, axum::Json(body)).into_response() } } // Implement From conversions for convenience impl From<sqlx::Error> for ServiceError { fn from(err: sqlx::Error) -> Self { ServiceError::DatabaseError(err.to_string()) } } // ... other `From` implementations for common error types
Schlussfolgerung
Effektives modulares Design ist grundlegend für den Aufbau skalierbarer, wartbarer und kollaborativer Rust-Webanwendungen, insbesondere mit Frameworks wie Actix Web und Axum. Durch sorgfältige Anwendung von Rusts Workspace- und Modulsystem, Einhaltung von geschichteten Architekturen und Nutzung von Traits für die inversionsgesteuerte Abhängigkeitsentwicklung können Entwickler robuste Systeme erstellen, bei denen jede Komponente eine klare, isolierte Verantwortung trägt. Diese bewusste Organisation verbessert die Codeklarheit, Testbarkeit und die Fähigkeit, sich an sich entwickelnde Anforderungen anzupassen, erheblich und stellt sicher, dass Ihr Projekt in seiner Größe und Komplexität gedeiht. Modulares Design ist der Kompass, der große Rust-Webprojekte durch die komplexe Landschaft der Softwareentwicklung führt.