Aufbau modularer Webdienste mit Axum-Layern für Beobachtbarkeit und Sicherheit
James Reed
Infrastructure Engineer · Leapcell

Einleitung
In der sich rasant entwickelnden Landschaft der Webdienstentwicklung ist der Aufbau robuster, skalierbarer und wartbarer Anwendungen von größter Bedeutung. Beobachtbarkeit (Protokollierung und Tracing) und Sicherheit (Authentifizierung) sind keine bloßen nachträglichen Überlegungen, sondern grundlegende Säulen jedes produktionsreifen Systems. Wenn unsere Dienste komplexer werden, wird die effektive und nicht-invasive Integration dieser Querschnittsanliegen zu einer erheblichen Herausforderung. Hier glänzt Middleware, die ein leistungsstarkes Muster zur Kapselung solcher Anliegen getrennt von der Kerngeschäftslogik bietet. Axum, ein beliebtes Webframework im Rust-Ökosystem, bietet über sein "Layer"-System einen eleganten und flexiblen Mechanismus zur Implementierung von Middleware, der weitgehend vom Tower-Projekt inspiriert ist. Das Verständnis und die Nutzung von Axum Layers befähigen Entwickler, hochmodulare und beobachtbare Dienste zu konstruieren, die wesentliche Funktionalitäten wie Protokollierung, Authentifizierung und Tracing mit bemerkenswerter Leichtigkeit und Wiederverwendbarkeit injizieren. Dieser Artikel wird die Interna von Axum Layers untersuchen und Sie durch den Aufbau benutzerdefinierter Middleware für diese kritischen Aspekte führen, um zu veranschaulichen, wie sie die Struktur und Wartbarkeit Ihrer Rust-Webanwendungen drastisch verbessern können.
Axum Layers verstehen
Bevor wir uns mit der Implementierung befassen, lassen Sie uns einige Kernbegriffe im Zusammenhang mit Axum Layers klären.
Service: Im Kontext von Tower und Axum ist ein Service
ein Trait, das eine asynchrone Funktion definiert, die eine Anfrage entgegennimmt und eine Future einer Antwort oder eines Fehlers zurückgibt. Es ist der grundlegende Baustein für die Bearbeitung von Anfragen. Ihre Axum-Handler sind im Wesentlichen Services.
Layer: Ein Layer
ist ein Trait, der einen Service
entgegennimmt und ihn umwickelt, wodurch ein neuer Service
zurückgegeben wird. Dieser neue Dienst kann dann Operationen ausführen, bevor, nachdem oder sogar anstelle des Aufrufs des zugrunde liegenden Dienstes. Layer sind komponierbar, was bedeutet, dass Sie mehrere Layer stapeln können, um eine Pipeline von Verarbeitungsschritten zu erstellen.
Middleware: Während Layer
der spezifische Tower-Trait ist, ist "Middleware" das allgemeinere Konzept, das er implementiert. Middleware fängt Anfragen und Antworten ab und führt ergänzende Aufgaben wie Protokollierung, Authentifizierung, Caching usw. durch.
Tower: Tower ist eine Bibliothek mit modularen und wiederverwendbaren Komponenten zum Erstellen robuster Client- und Serveranwendungen. Axum nutzt die Service
- und Layer
-Traits von Tower ausgiebig und bietet eine leistungsfähige und idiomatische Möglichkeit, seine Funktionalität zu erweitern.
Das Prinzip der Axum Layers
Die Stärke von Axum Layers liegt in ihrer Komponierbarkeit. Jeder Layer fungiert als Dekorateur und fügt dem von ihm umschlossenen Dienst Funktionalität hinzu. Wenn eine Anfrage eingeht, durchläuft sie jeden Layer in der Reihenfolge ihrer Anwendung, erreicht dann den innersten Dienst (Ihren Handler), und schließlich wandert die Antwort in umgekehrter Reihenfolge durch die Layer nach außen. Dieses "Zwiebelschalen"-Modell ermöglicht eine klare Trennung der Zuständigkeiten.
Ein benutzerdefinierter Layer beinhaltet typischerweise die Definition zweier Hauptkomponenten:
- Die Layer-Struktur: Diese Struktur implementiert den
tower::Layer
-Trait. Ihrelayer
-Methode nimmt einen inneren Dienst entgegen und gibt einen neuenService
zurück, der ihn umschließt. - Die Service-Struktur: Diese Struktur implementiert den
tower::Service
-Trait. Sie hält eine Referenz auf den inneren Dienst und kapselt die Logik, die vor oder nach dem Aufruf des inneren Dienstes ausgeführt wird.
Lassen Sie uns dies mit praktischen Beispielen für Protokollierung, Authentifizierung und Tracing veranschaulichen.
Benutzerdefinierter Protokollierungs-Layer
Ein Protokollierungs-Layer ist entscheidend, um zu verstehen, wie Ihre Anwendung in der Produktion funktioniert, Fehler zu verfolgen und den Anfragefluss zu überwachen.
use axum:: body::{Body, BoxBody}, http::Request, response::Response, routing::get, Router, ; use std:: task::{Context, Poll}, time::Instant, ; use tower::{Layer, Service}; use tracing::info; // 1. Definieren Sie den Protokollierungs-Layer #[derive(Debug, Clone)] struct LogLayer; impl<S> Layer<S> for LogLayer { type Service = LogService<S>; fn layer(&self, inner: S) -> Self::Service { LogService { inner } } } // 2. Definieren Sie den Protokollierungsdienst #[derive(Debug, Clone)] struct LogService<S> { inner: S, } impl<S, B: Send + 'static> Service<Request<B>> for LogService<S> where S: Service<Request<B>, Response = Response<BoxBody>> + Send + 'static, S::Future: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, request: Request<B>) -> Self::Future { let path = request.uri().path().to_owned(); let method = request.method().to_string(); info!(%method, %path, "Incoming request"); let start = Instant::now(); let future = self.inner.call(request); Box::pin(async move { let response = future.await?; let latency = start.elapsed(); let status = response.status(); info!(%method, %path, %status, "Request finished in {}ms", latency.as_millis()); Ok(response) }) } } // Hilfsfunktion zur Erstellung des Layers (optional, aber praktisch) fn log_layer() -> LogLayer { LogLayer }
In diesem Beispiel:
LogLayer
enthält einfach keinen Zustand und implementiert denLayer
-Trait. Seinelayer
-Methode erstellt eineLogService
-Instanz, die den inneren Dienst umschließt.LogService<S>
enthält deninner
-Dienst. Seinecall
-Methode ist dort, wo die eigentliche Protokollierungslogik liegt. Sie protokolliert, bevorinner.call()
aufgerufen wird, und protokolliert dann erneut, nachdem die Antwort empfangen wurde, einschließlich der Anfrage-Methode, des Pfads, des Status und der Latenz.- Wir verwenden
tracing
für strukturierte Protokollierung, was in Rust sehr empfehlenswert ist.
Benutzerdefinierter Authentifizierungs-Layer
Ein Authentifizierungs-Layer stellt sicher, dass nur autorisierte Anfragen Ihre Kerngeschäftslogik erreichen und unbefugten Zugriff verhindern.
use axum::http::HeaderValue; use std::collections::HashMap; // Ein einfacher In-Memory-Benutzerspeicher für die Demonstration lazy_static::lazy_static! { static ref USERS: HashMap<&'static str, &'static str> = { let mut m = HashMap::new(); m.insert("admin", "password123"); m.insert("user", "mysecret"); m }; } // Definieren Sie einen Fehlertyp für Authentifizierungsfehler #[derive(Debug)] enum AuthError { InvalidCredentials, MissingCredentials, } impl IntoResponse for AuthError { fn into_response(self) -> Response<BoxBody> { let (status, msg) = match self { AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"), AuthError::MissingCredentials => (StatusCode::UNAUTHORIZED, "Missing Authorization header"), }; (status, msg).into_response() } } // 1. Definieren Sie den Authentifizierungs-Layer #[derive(Debug, Clone)] struct AuthLayer; impl<S> Layer<S> for AuthLayer { type Service = AuthService<S>; fn layer(&self, inner: S) -> Self::Service { AuthService { inner } } } // 2. Definieren Sie den Authentifizierungsdienst #[derive(Debug, Clone)] struct AuthService<S> { inner: S, } impl<S, B: Send + 'static> Service<Request<B>> for AuthService<S> where S: Service<Request<B>, Response = Response<BoxBody>, Error = AuthError> + Send + 'static, // Innerer Dienst kann AuthError zurückgeben S::Future: Send + 'static, { type Response = S::Response; type Error = AuthError; // Dieser Dienst kann auch AuthError zurückgeben type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request<B>) -> Self::Future { let auth_header = request.headers().get(axum::http::header::AUTHORIZATION); let authenticated = match auth_header { Some(header_value) => { let header_str = header_value.to_str().unwrap_or_default(); if header_str.starts_with("Basic ") { let encoded_credentials = header_str["Basic ".len()..].trim(); let decoded_bytes = base64::decode(encoded_credentials).unwrap_or_default(); let decoded_str = String::from_utf8(decoded_bytes).unwrap_or_default(); let parts: Vec<&str> = decoded_str.split(':').collect(); if parts.len() == 2 { let (username, password) = (parts[0], parts[1]); USERS.get(username) == Some(&password) } else { false } } else { false } } None => false, }; if !authenticated { return Box::pin(async { Err(AuthError::InvalidCredentials) }); } // Wenn authentifiziert, können wir möglicherweise die Benutzer-ID extrahieren und sie in die Anfrageerweiterungen einfügen // request.extensions_mut().insert(UserId("some-user-id".to_string())); let future = self.inner.call(request); Box::pin(async move { future.await // An den inneren Dienst weiterleiten }) } } // Hilfsfunktion zur Erstellung des Layers fn auth_layer() -> AuthLayer { AuthLayer }
Wichtige Punkte für die Authentifizierung:
- Wir definieren einen benutzerdefinierten
AuthError
und implementierenIntoResponse
dafür, damit Axum Authentifizierungsfehler ordnungsgemäß behandeln kann. - Der
AuthService
extrahiert denAuthorization
-Header, versucht, Basic-Authentifizierungsparameter zu parsen, und gleicht sie mit einerUSERS
-Map ab. - Wenn die Authentifizierung fehlschlägt, gibt sie sofort
Err(AuthError::InvalidCredentials)
zurück. Andernfalls leitet sie die Anfrage an den inneren Dienst weiter. - Wichtig: Beachten Sie, wie wir
Error = AuthError
sowohl für denAuthService
als auch für dieinner
-Dienstbeschränkung definieren. Das bedeutet, wenn ein innerer Dienst ebenfalls mit einemAuthError
fehlschlägt, wird er korrekt weitergegeben.
Benutzerdefinierter Tracing-Layer
Tracing hilft, den Fluss von Anfragen über verschiedene Dienste und innerhalb eines einzelnen Dienstes zu visualisieren, was für die Fehlersuche in verteilten Systemen und die Leistungsanalyse von unschätzbarem Wert ist. Axum integriert sich bereits gut mit tracing
, aber ein benutzerdefinierter Layer kann Spuren anreichern oder spezifischen Kontext hinzufügen. Oft würde man einen dedizierten Tracing-Layer wie tower_http::trace::TraceLayer
verwenden. Zu Bildungszwecken erstellen wir jedoch einen vereinfachten, um ihn zu demonstrieren.
use axum_extra::extract::Extension; use uuid::Uuid; // Zu injizierende Daten in die Anfrage für das Tracing #[derive(Clone)] struct RequestId(String); // 1. Definieren Sie den Tracing-Layer #[derive(Debug, Clone)] struct TraceLayer; impl<S> Layer<S> for TraceLayer { type Service = TraceService<S>; fn layer(&self, inner: S) -> Self::Service { TraceService { inner } } } // 2. Definieren Sie den Tracing-Dienst #[derive(Debug, Clone)] struct TraceService<S> { inner: S, } impl<S, B: Send + 'static> Service<Request<B>> for TraceService<S> where S: Service<Request<B>, Response = Response<BoxBody>> + Send + 'static, S::Future: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request<B>) -> Self::Future { // Generieren Sie eine eindeutige Anfrage-ID let request_id = Uuid::new_v4().to_string(); info!(request_id = %request_id, "Assigning request ID"); // Fügen Sie die Anfrage-ID in die Anfrageerweiterungen ein, damit Handler darauf zugreifen können request.extensions_mut().insert(RequestId(request_id.clone())); // Fügen Sie sie auch als Header für nachgelagerte Dienste hinzu request.headers_mut().insert("X-Request-ID", HeaderValue::from_str(&request_id).unwrap()); // Erstellen Sie eine Tracing-Span für die Anfrage let span = tracing::info_span!("request", request_id = %request_id, method = %request.method(), path = %request.uri().path()); let _guard = span.enter(); // Betreten Sie die Span für die Dauer der Anfrage let future = self.inner.call(request); Box::pin(async move { let response = future.await?; info!(request_id = %request_id, status = %response.status(), "Request processed"); Ok(response) }) } } // Hilfsfunktion zur Erstellung des Layers fn trace_layer() -> TraceLayer { TraceLayer }
Für das Tracing:
- Wir verwenden
uuid
, um eine eindeutigerequest_id
zu generieren. - Diese ID wird in
request.extensions_mut()
eingefügt, wodurch sie für nachfolgende Layer und Axum-Handler überExtension<RequestId>
zugänglich gemacht wird. - Es wird eine
tracing::info_span!
erstellt, die sicherstellt, dass alle Protokolle innerhalb dieses Anfragekontexts automatisch dierequest_id
enthalten. - Der Header
X-Request-ID
wird hinzugefügt, nützlich für die Weiterleitung von Tracing-IDs über Dienstgrenzen hinweg.
Komponieren von Layern in einen Axum-Router
Lassen Sie uns nun diese Layer zusammenstellen und auf eine Axum-Anwendung anwenden.
use axum:: extract::State, middleware, response::Html, routing::{get, post}, Router, ; use std::sync::Arc; use tokio::net::TcpListener; use tower_http::trace::TraceLayer as TowerTraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; // Handler, der Authentifizierung erfordert und auf die Anfrage-ID zugreifen kann #[instrument(skip(State))] async fn protected_handler( State(app_state): State<Arc<String>>, Extension(request_id): Extension<RequestId>, ) -> Html<String> { info!("Accessing protected handler with request ID: {}", request_id.0); Html(format!( "<h1>Hello from protected handler!</h1><p>App State: {}</p><p>Request ID: {}</p>", app_state, request_id.0 )) } // Handler, der keine Authentifizierung erfordert async fn public_handler() -> Html<String> { info!("Accessing public handler"); Html("<h1>Hello from public handler!</h1>".to_string()) } #[tokio::main] async fn main() { // Tracing initialisieren tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); info!("Starting server..."); let app_state = Arc::new("My Awesome App".to_string()); // Unsere benutzerdefinierten Layer erstellen let custom_layers = ServiceBuilder::new() // Unser benutzerdefinierter Protokollierungs-Layer (sollte außen liegen, um alles zu protokollieren) .layer(log_layer()) // Unser benutzerdefinierter Tracing-Layer (sollte neben den Anfragen liegen, um sie zu instrumentieren) .layer(trace_layer()); // Layer auf bestimmte Routen oder den gesamten Router anwenden let app = Router::new() .route("/public", get(public_handler)) .route("/protected", get(protected_handler)) .route_layer(middleware::from_fn(|req, next| async { // Authentifizierung nur für bestimmte Routen anwenden // Hinweis: Unser benutzerdefinierter Auth-Layer arbeitet als Tower Layer // Wenn Sie Axum-spezifische Middleware-Funktionen benötigen, verwenden Sie `middleware::from_fn` // Für dieses Beispiel verwenden wir den expliziten AuthLayer direkt. // Dies ist, wie Sie einen Tower Layer auf eine Teilmenge von Routen anwenden würden. // Ein idiomatischerer Axum-Weg für eine spezifische Routenauthentifizierung // könnte einen benutzerdefinierten Extraktor oder die Anwendung des Layers auf einen verschachtelten Router beinhalten. let auth_service = AuthService { inner: next }; // AuthService manuell für dieses Beispiel erstellen auth_service.oneshot(req).await })) .layer(TraceLayer::new()) // Verwenden Sie den Tracing-Layer von tower-http für umfassendes Tracing // .layer(TowerTraceLayer::new_for_http()) // Robusterer Trace Layer von tower-http // .layer(custom_layers) // Unsere benutzerdefinierten Layer hier anwenden // Layer werden in umgekehrter Reihenfolge der Definition angewendet. // Der *letzte* hinzugefügte Layer umschließt den *gesamten* Router/Dienst und ist somit die äußerste Logik. // Beispiel: .layer(MyOuterLayer).layer(MyInnerLayer) -> Anfrage durchläuft Outer, dann Inner, dann Handler. .layer(auth_layer()) // Unser benutzerdefinierter Authentifizierungs-Layer .layer(custom_layers) // Wenden Sie unsere benutzerdefinierten Protokollierungs- und Tracing-Layer zuerst an (äußerste) .with_state(app_state); let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); info!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
Um dieses Beispiel auszuführen, benötigen Sie die folgenden Abhängigkeiten in Ihrer Cargo.toml
:
[dependencies] axum = { version = "0.7", features = ["macros"] } tokio = { version = "1.36", features = ["full"] } tower = { version = "0.4", features = ["full"] } tower-http = { version = "0.5", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }utures = "0.3" uuid = { version = "1.7", features = ["v4", "fast-rng"] } base64 = "0.21" lazy_static = "1.4" axum-extra = { version = "0.9", features = ["extract-intervals"] } # Für Extension
In der main
-Funktion:
- Wir initialisieren
tracing_subscriber
für die Protokollausgabe. ServiceBuilder::new().layer(log_layer()).layer(trace_layer())
erstellt einen kombinierten Layer aus unseren benutzerdefinierten Protokollierungs- und Tracing-Diensten. Layer werden in derServiceBuilder
-Kette von oben nach unten angewendet, was bedeutet, dasslog_layer
außerhalb vontrace_layer
liegen wird.app.layer(auth_layer())
wendet unseren Authentifizierungs-Layer auf den gesamten Router an. Das bedeutet, dass jede Route die Authentifizierung durchläuft. Wenn Sie eine selektive Authentifizierung wünschen, können Sie Layer auf verschachtelteRouter::with_no_routes()
-Instanzen anwenden.- Der
protected_handler
zeigt, wie dieRequestId
aus den Anfrageerweiterungen extrahiert wird. - Der Server lauscht auf
127.0.0.1:3000
.
Zum Testen:
- Öffentlicher Endpunkt:
curl http://127.0.0.1:3000/public
(sollte öffentliche HTML-Daten zurückgeben). - Geschützter Endpunkt (unauthentifiziert):
curl http://127.0.0.1:3000/protected
(sollte 401 Unauthorized mit "Invalid credentials" zurückgeben). - Geschützter Endpunkt (authentifiziert):
curl -H "Authorization: Basic YWRtaW46cGFzc3dvcmQxMjM=" http://127.0.0.1:3000/protected
(sollte geschützte HTML-Daten zurückgeben.YWRtaW46cGFzc3dvcmQxMjM=
ist base64-kodiertes "admin").
Sie sehen detaillierte Protokolle in Ihrer Konsole, die Anfragedaten, Methoden, Pfade, Status und Latenzen anzeigen, was die Effektivität der benutzerdefinierten Layer demonstriert.
Anwendungsszenarien
- Protokollierung: Jede API-Anfrage muss zur Überprüfung, Fehlersuche und Überwachung protokolliert werden.
- Authentifizierung/Autorisierung: Schützen Sie bestimmte Endpunkte oder ganze Segmente Ihrer API basierend auf Benutzerrollen oder Berechtigungen.
- Tracing: Leiten Sie Anfragedaten für verteiltes Tracing weiter und ermöglichen Sie so die Ende-zu-Ende-Sichtbarkeit von Anfragen über Microservices hinweg.
- Ratenbegrenzung: Verhindern Sie Missbrauch, indem Sie die Anzahl der Anfragen begrenzen, die ein Client innerhalb eines bestimmten Zeitraums stellen kann.
- Anfragen-/Antworttransformation: Ändern Sie Header, komprimieren Sie Bodies oder fügen Sie gemeinsame Daten in Anfragen/Antworten ein.
- Metriken: Sammeln und exportieren Sie Metriken wie Anfragestatistiken, Fehlerraten und Latenz für Überwachungs-Dashboards.
- CORS: Behandeln Sie CORS-Header (Cross-Origin Resource Sharing).
Fazit
Das Axum Layer-System, das auf dem leistungsstarken Tower-Ökosystem aufbaut, bietet eine äußerst flexible und idiomatische Methode zur Implementierung benutzerdefinierter Middleware in Rust-Webanwendungen. Durch das Verständnis der Layer
- und Service
-Traits können Entwickler Querschnittsanliegen wie Protokollierung, Authentifizierung und Tracing modularisieren, was zu saubereren, wartbareren und robusteren Codebasen führt. Dieser Ansatz verbessert nicht nur die Beobachtbarkeit und Sicherheit Ihrer Dienste, sondern fördert auch die Wiederverwendbarkeit und Trennung von Zuständigkeiten, die für den Aufbau skalierbarer und angenehmer Webanwendungen von entscheidender Bedeutung sind. Die Nutzung von Axum Layers befähigt Sie, hochgradig konfigurierbare und produktionsbereite Rust-Webdienste mit Zuversicht zu erstellen.