本文基于?Axum 0.7.5(當前穩定版)、tower-http 0.5.2、MiniJinja 0.7.2?編寫,涵蓋生產環境核心場景:tower-http Layer 疊加與數據傳遞
、靜態網頁服務
、MiniJinja 動態模板渲染
,并重點解析請求 / 應答在多 Layer 中的流轉邏輯。
一、環境準備:依賴配置
首先在?Cargo.toml
?中添加最新依賴,確保組件兼容性(Axum 0.7+ 需搭配 tower-http 0.5+):
[package]
name = "axum-demo"
version = "0.1.0"
edition = "2021"[dependencies]
# Axum 核心(含路由、Handler、State 等)
axum = { version = "0.7.5", features = ["json", "macros"] }
# 異步運行時(Axum 依賴 tokio)
tokio = { version = "1.35.1", features = ["full"] }
# HTTP 中間件生態(Layer 核心)
tower-http = { version = "0.5.2", features = ["trace", # 日志追蹤"compression-br", # Brotli 壓縮"cors", # 跨域支持"serve-dir",# 靜態文件服務"request-body-limit", # 請求大小限制"fs", # 文件系統操作
] }
# 日志格式化(配合 tower-http::trace)
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
# 動態模板引擎
minijinja = "0.7.2"
# 路徑處理
std-fs = "0.1.4"
二、核心概念鋪墊
在深入 Layer 之前,需明確 Axum 生態的 3 個核心組件:
- Handler:處理 HTTP 請求的函數(如?
async fn hello() -> &'static str
),是請求處理的「終點」。 - Router:路由分發器,將請求匹配到對應的 Handler,支持嵌套和掛載。
- Layer:中間件抽象,用于攔截 / 修改請求(Request)或應答(Response),可疊加使用(如日志、壓縮、跨域)。
關鍵邏輯:Layer 會「包裝」Router 或下一層 Layer,形成一個「洋蔥模型」—— 請求從外層 Layer 流向內層 Handler,應答從內層 Handler 流回外層 Layer。
三、tower-http Layer 疊加與數據傳遞
3.1 Layer 核心規則:洋蔥模型
Layer 的執行順序遵循?「請求外→內,應答內→外」,即:
- 請求階段:先添加的 Layer 先處理請求(如先日志 → 再壓縮 → 最后到 Handler)。
- 應答階段:先添加的 Layer 后處理應答(如 Handler 生成應答 → 壓縮 → 日志 → 客戶端)。
下圖直觀展示多 Layer 數據流轉:
客戶端 → [TraceLayer(日志)] → [CompressionLayer(壓縮)] → [CorsLayer(跨域)] → Router → Handler↑ ↓
客戶端 ← [TraceLayer(日志)] ← [CompressionLayer(壓縮)] ← [CorsLayer(跨域)] ← Router ← Handler
3.2 生產環境常用 Layer 配置
以下是現實項目中必選的 Layer 組合,按「外層到內層」順序添加(優化性能和安全性):
步驟 1:初始化日志(TraceLayer)
用于記錄請求方法、路徑、狀態碼、耗時等,是調試和監控的核心。
步驟 2:跨域處理(CorsLayer)
解決瀏覽器跨域問題,需明確允許的 Origin、Method、Header。
步驟 3:請求大小限制(RequestBodyLimitLayer)
防止超大請求攻擊(如上傳惡意文件),生產環境建議限制 10MB 以內。
步驟 4:壓縮(CompressionLayer)
減少響應體積,支持 Brotli、Gzip(Brotli 壓縮率更高,優先啟用)。
代碼實現:Layer 疊加
use axum::{Router, Server};
use tower_http::{compression::CompressionLayer,cors::{Any, CorsLayer},request_body_limit::RequestBodyLimitLayer,trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
};
use tracing_subscriber::{prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt};#[tokio::main]
async fn main() {// 1. 初始化日志(必須先啟動,否則 Layer 日志不生效)tracing_subscriber::registry().with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_demo=debug,tower_http=debug".into()),).with(tracing_subscriber::fmt::layer()).init();// 2. 構建核心路由(后續添加靜態/動態路由)let app_router = Router::new();// 3. 疊加 Layer(順序:外層→內層,影響請求/應答處理順序)let app = app_router// Layer 1:日志追蹤(最外層,優先記錄完整請求).layer(TraceLayer::new_for_http().make_span_with(DefaultMakeSpan::new().include_headers(true)) // 記錄請求頭.on_response(DefaultOnResponse::new().include_headers(true)), // 記錄響應頭)// Layer 2:跨域(外層,先驗證跨域,避免后續無用處理).layer(CorsLayer::new().allow_origin(Any) // 生產環境替換為具體域名(如 "https://example.com").allow_methods(Any).allow_headers(Any),)// Layer 3:請求大小限制(10MB).layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))// Layer 4:壓縮(內層,靠近 Handler,減少數據傳輸).layer(CompressionLayer::new().br(true).gzip(true));// 啟動服務Server::bind(&([127, 0, 0, 1], 3000).into()).serve(app.into_make_service()).await.unwrap();
}
3.3 請求 / 應答在多 Layer 中的數據傳遞細節
每個 Layer 本質是一個「Service 包裝器」,通過?Service::call
?方法傳遞請求,具體流程:
請求階段(Request Flow):
- 客戶端發送 HTTP 請求 → 進入最外層 Layer(如 TraceLayer)。
- TraceLayer 記錄請求開始時間、方法、路徑 → 調用下一層 Layer(CorsLayer)的?
call
?方法,傳遞修改后的 Request(或原 Request)。 - CorsLayer 檢查請求的 Origin/Method → 若合法,調用下一層(RequestBodyLimitLayer)→ 否則直接返回 403 應答。
- RequestBodyLimitLayer 檢查請求體大小 → 若超限,返回 413 應答 → 否則傳遞給 CompressionLayer。
- CompressionLayer 不修改請求(僅處理應答)→ 傳遞給 Router → Router 匹配 Handler → Handler 處理請求并生成 Response。
應答階段(Response Flow):
- Handler 生成 Response → 回傳給 CompressionLayer。
- CompressionLayer 檢查 Response 的 Content-Type(如文本、HTML)→ 若支持壓縮,對響應體進行 Brotli/Gzip 壓縮 → 添加上?
Content-Encoding
?頭 → 回傳給 RequestBodyLimitLayer。 - RequestBodyLimitLayer 不處理應答 → 回傳給 CorsLayer。
- CorsLayer 為 Response 添加?
Access-Control-Allow-*
?頭 → 回傳給 TraceLayer。 - TraceLayer 記錄應答的狀態碼、耗時 → 將最終 Response 發送給客戶端。
3.4 自定義 Layer 示例(直觀理解流轉)
若需驗證 Layer 執行順序,可自定義一個打印日志的 Layer,觀察請求 / 應答的處理時機:
use axum::body::Body;
use http::{Request, Response};
use tower::{Layer, Service};
use std::task::{Context, Poll};// 自定義 Layer(無狀態,僅打印日志)
#[derive(Clone, Copy, Default)]
struct LogLayer;impl<S> Layer<S> for LogLayer {type Service = LogService<S>;fn layer(&self, inner: S) -> Self::Service {LogService { inner }}
}// Layer 對應的 Service(實際處理邏輯)
struct LogService<S> {inner: S,
}impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for LogService<S>
whereS: Service<Request<ReqBody>, Response = Response<ResBody>>,S::Error: std::fmt::Display,
{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, req: Request<ReqBody>) -> Self::Future {// 請求階段:打印請求信息(外層 Layer 先執行)println!("[LogLayer] 收到請求: {} {}", req.method(), req.uri().path());// 保存 inner 的引用(因 async 閉包無法捕獲 &mut self)let mut inner = self.inner.clone();Box::pin(async move {// 調用下一層 Service(傳遞請求)let resp = inner.call(req).await?;// 應答階段:打印應答信息(內層 Layer 先執行)println!("[LogLayer] 返回應答: {}", resp.status());Ok(resp)})}
}// 在 main 中添加自定義 Layer(放在 TraceLayer 之后,觀察順序)
// let app = app_router
// .layer(TraceLayer::new_for_http(...))
// .layer(LogLayer) // 自定義 Layer
// .layer(CorsLayer::new(...))
// ...;
運行后,請求?GET /
?會輸出:
[LogLayer] 收到請求: GET / # 請求階段:先 Trace → 再 Log → 再 Cors...
[LogLayer] 返回應答: 200 OK # 應答階段:先 Handler → 再 Compression → 再 Log → 再 Trace...
四、Serve 靜態網頁
使用?tower-http::serve_dir::ServeDir
?實現靜態文件服務(如 HTML、CSS、JS、圖片),支持路徑映射和 404 處理。
4.1 基礎靜態服務(映射本地目錄)
假設本地有?static
?目錄,結構如下:
static/index.html # 首頁css/style.css # 樣式文件img/logo.png # 圖片
代碼實現:掛載?/static
?路徑到本地?static
?目錄:
use axum::Router;
use tower_http::serve_dir::ServeDir;// 在 main 中構建路由
let app_router = Router::new()// 掛載靜態文件:請求 /static/xxx → 讀取 static/xxx.nest_service("/static", ServeDir::new("static"))// 根路徑(/)重定向到 /static/index.html.route("/", axum::routing::get(|| async {axum::response::Redirect::permanent("/static/index.html")}));
啟動服務后,訪問以下路徑會返回對應文件
http://127.0.0.1:3000/
?→ 重定向到?index.html
http://127.0.0.1:3000/static/css/style.css
?→ 返回樣式文件http://127.0.0.1:3000/static/img/logo.png
?→ 返回圖片
4.2 高級配置:自定義 404 頁面
當請求的靜態文件不存在時,默認返回 404 空白頁,可自定義 404 頁面:
use axum::{response::IntoResponse, http::StatusCode};
use tower_http::serve_dir::ServeDir;// 自定義 404 響應(HTML 格式)
async fn not_found() -> impl IntoResponse {(StatusCode::NOT_FOUND,axum::response::Html("<h1>404 - 頁面不存在</h1><p>請檢查路徑是否正確</p>"),)
}// 構建路由時,用 `fallback` 處理 404
let app_router = Router::new().nest_service("/static", ServeDir::new("static").not_found_service(// 靜態文件不存在時,調用 not_found Handleraxum::routing::get(not_found))).route("/", axum::routing::get(|| async {axum::response::Redirect::permanent("/static/index.html")}))// 其他路徑(非 /static)也返回 404.fallback(not_found);
4.3 生產環境優化:添加緩存頭
為靜態文件添加?Cache-Control
?頭,減少重復請求(使用?tower-http::cache_control::CacheControlLayer
):
use tower_http::cache_control::{CacheControlLayer, CacheControl};// 在 Layer 疊加中添加緩存控制(放在 CompressionLayer 之后,靠近靜態服務)
let app = app_router.layer(TraceLayer::new_for_http(...)).layer(CorsLayer::new(...)).layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)).layer(CompressionLayer::new().br(true).gzip(true))// 靜態文件緩存:設置 max-age=3600(1 小時).layer(CacheControlLayer::new().with_cache_control(CacheControl::new().max_age(std::time::Duration::from_secs(3600)))// 僅對靜態文件路徑生效.on_route(|route| route.starts_with("/static/")),);
五、MiniJinja 模板動態網頁
MiniJinja 是輕量、安全的模板引擎,支持變量、循環、條件判斷、模板繼承,適合生成動態 HTML(如用戶中心、列表頁)。
5.1 模板目錄結構
先創建?templates
?目錄,存放模板文件,推薦結構:
templates/base.html # 基礎模板(公共頭部、尾部)index.html # 首頁(繼承 base.html)users.html # 用戶列表頁(繼承 base.html)
5.2 初始化 MiniJinja 模板環境
模板環境(TemplateEnvironment
)需全局共享(通過 Axum 的?State
?傳遞),避免重復初始化:
use axum::{Router, extract::State, response::Html};
use minijinja::{Environment, Template};
use std::sync::Arc;// 定義全局狀態(包裝 MiniJinja 環境)
#[derive(Clone)]
struct AppState {template_env: Arc<Environment<'static>>,
}// 初始化模板環境
fn init_template_env() -> Environment<'static> {let mut env = Environment::new();// 添加模板目錄(加載 .html 文件)env.add_template_dir("templates").expect("Failed to add template directory");// 可選:添加自定義過濾器(如日期格式化)env.add_filter("upper", |s: &str| s.to_uppercase());env
}#[tokio::main]
async fn main() {// 初始化模板環境并包裝為全局狀態let template_env = Arc::new(init_template_env());let app_state = AppState { template_env };// 構建路由(通過 with_state 傳遞全局狀態)let app_router = Router::new().route("/", axum::routing::get(render_index)).route("/users", axum::routing::get(render_users)).with_state(app_state); // 傳遞全局狀態// 疊加 Layer 并啟動服務(同前)// ...
}
5.3 編寫模板文件
1. 基礎模板(base.html)
使用?{% block %}
?定義可替換的區塊,供子模板繼承:
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>{% block title %}默認標題{% endblock %}</title><link rel="stylesheet" href="/static/css/style.css">
</head>
<body><header><h1>Axum MiniJinja 示例</h1><nav><a href="/">首頁</a> | <a href="/users">用戶列表</a></nav></header><!-- 子模板內容區域 --><main>{% block content %}{% endblock %}</main><footer><p>? 2024 Axum 開發指南</p></footer>
</body>
</html>
2. 首頁模板(index.html)
繼承?base.html
,填充?title
?和?content
?區塊:
{% extends "base.html" %}{% block title %}首頁 - Axum 示例{% endblock %}{% block content %}<h2>歡迎訪問首頁</h2><p>當前時間:{{ current_time }}</p><p>用戶名:{{ username | upper }}</p> <!-- 使用自定義 upper 過濾器 -->
{% endblock %}
3. 用戶列表模板(users.html)
使用?{% for %}
?循環渲染列表:
{% extends "base.html" %}{% block title %}用戶列表 - Axum 示例{% endblock %}{% block content %}<h2>用戶列表</h2>{% if users.is_empty() %}<p>暫無用戶</p>{% else %}<ul>{% for user in users %}<li>{{ user.id }}: {{ user.name }} ({{ user.age }} 歲)</li>{% endfor %}</ul>{% endif %}
{% endblock %}
5.4 編寫 Handler 渲染模板
通過?State
?提取全局模板環境,傳遞上下文數據(如當前時間、用戶列表),渲染模板并返回 HTML:
use axum::{extract::State, response::Html};
use chrono::Local; // 需要添加依賴:chrono = "0.4.31"
use std::sync::Arc;// 定義用戶結構體(用于傳遞到模板)
#[derive(Debug, serde::Serialize)] // MiniJinja 需要 Serialize trait
struct User {id: u32,name: String,age: u8,
}// 渲染首頁
async fn render_index(State(state): State<AppState>) -> Html<String> {// 1. 準備上下文數據(需實現 Serialize)let context = minijinja::context! {current_time => Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),username => "alice"};// 2. 加載并渲染模板let template = state.template_env.get_template("index.html").unwrap();let html = template.render(&context).unwrap();Html(html)
}// 渲染用戶列表
async fn render_users(State(state): State<AppState>) -> Html<String> {// 1. 模擬從數據庫獲取用戶數據let users = vec![User { id: 1, name: "Alice".into(), age: 25 },User { id: 2, name: "Bob".into(), age: 30 },User { id: 3, name: "Charlie".into(), age: 28 },];// 2. 傳遞上下文let context = minijinja::context! { users => users };// 3. 渲染模板let template = state.template_env.get_template("users.html").unwrap();let html = template.render(&context).unwrap();Html(html)
}
注意:需添加?chrono
?依賴(用于時間格式化)和?serde
?依賴(serde = { version = "1.0.193", features = ["derive"] }
),因為 MiniJinja 要求上下文數據實現?serde::Serialize
。
5.5 模板預編譯(生產環境優化)
模板默認是運行時加載,生產環境可預編譯模板到二進制中,避免文件 IO 開銷:
// 預編譯模板(在 init_template_env 中)
fn init_template_env() -> Environment<'static> {let mut env = Environment::new();// 預編譯 base.htmlenv.add_template("base.html", include_str!("../templates/base.html")).expect("Failed to compile base.html");// 預編譯 index.htmlenv.add_template("index.html", include_str!("../templates/index.html")).expect("Failed to compile index.html");// 預編譯 users.htmlenv.add_template("users.html", include_str!("../templates/users.html")).expect("Failed to compile users.html");env.add_filter("upper", |s: &str| s.to_uppercase());env
}
說明:include_str!
?是 Rust 宏,編譯時將文件內容嵌入二進制,運行時無需讀取本地文件。
六、綜合示例:完整應用
將上述所有功能整合,最終的?main.rs
?如下:
use axum::{extract::State,http::StatusCode,response::{Html, IntoResponse, Redirect},Router, Server,
};
use chrono::Local;
use minijinja::{context::Context, Environment};
use serde::Serialize;
use std::sync::Arc;
use tower_http::{cache_control::{CacheControl, CacheControlLayer},compression::CompressionLayer,cors::{Any, CorsLayer},request_body_limit::RequestBodyLimitLayer,serve_dir::ServeDir,trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
};
use tracing_subscriber::{prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt};// 全局狀態
#[derive(Clone)]
struct AppState {template_env: Arc<Environment<'static>>,
}// 用戶結構體
#[derive(Debug, Serialize)]
struct User {id: u32,name: String,age: u8,
}// 初始化模板環境
fn init_template_env() -> Environment<'static> {let mut env = Environment::new();// 預編譯模板env.add_template("base.html", include_str!("../templates/base.html")).expect("Failed to compile base.html");env.add_template("index.html", include_str!("../templates/index.html")).expect("Failed to compile index.html");env.add_template("users.html", include_str!("../templates/users.html")).expect("Failed to compile users.html");// 自定義過濾器env.add_filter("upper", |s: &str| s.to_uppercase());env
}// 404 處理
async fn not_found() -> impl IntoResponse {(StatusCode::NOT_FOUND,Html("<h1>404 - 頁面不存在</h1><p>請檢查路徑是否正確</p>"),)
}// 渲染首頁
async fn render_index(State(state): State<AppState>) -> Html<String> {let context = context! {current_time => Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),username => "alice"};let template = state.template_env.get_template("index.html").unwrap();let html = template.render(&context).unwrap();Html(html)
}// 渲染用戶列表
async fn render_users(State(state): State<AppState>) -> Html<String> {let users = vec![User { id: 1, name: "Alice".into(), age: 25 },User { id: 2, name: "Bob".into(), age: 30 },User { id: 3, name: "Charlie".into(), age: 28 },];let context = context! { users => users };let template = state.template_env.get_template("users.html").unwrap();let html = template.render(&context).unwrap();Html(html)
}#[tokio::main]
async fn main() {// 初始化日志tracing_subscriber::registry().with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_demo=debug,tower_http=debug".into()),).with(tracing_subscriber::fmt::layer()).init();// 初始化模板環境和全局狀態let template_env = Arc::new(init_template_env());let app_state = AppState { template_env };// 構建路由let app_router = Router::new()// 動態路由(模板渲染).route("/", axum::routing::get(render_index)).route("/users", axum::routing::get(render_users))// 靜態路由(文件服務).nest_service("/static",ServeDir::new("static").not_found_service(axum::routing::get(not_found)),)// 404 處理.fallback(not_found)// 傳遞全局狀態.with_state(app_state);// 疊加 Layerlet app = app_router// 1. 日志追蹤.layer(TraceLayer::new_for_http().make_span_with(DefaultMakeSpan::new().include_headers(true)).on_response(DefaultOnResponse::new().include_headers(true)),)// 2. 跨域.layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any),)// 3. 請求大小限制(10MB).layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))// 4. 壓縮.layer(CompressionLayer::new().br(true).gzip(true))// 5. 靜態文件緩存(1 小時).layer(CacheControlLayer::new().with_cache_control(CacheControl::new().max_age(std::time::Duration::from_secs(3600))).on_route(|route| route.starts_with("/static/")),);// 啟動服務Server::bind(&([127, 0, 0, 1], 3000).into()).serve(app.into_make_service()).await.expect("Failed to start server");
}
七、生產環境注意事項
Layer 順序優化:
- 安全相關 Layer(CORS、請求大小限制)放外層,避免無效處理。
- 日志 Layer 放最外層,記錄完整請求 / 應答。
- 壓縮 Layer 放內層,減少數據傳輸量。
靜態文件安全:
- 禁用目錄列表(
ServeDir
?默認禁用,勿開啟)。 - 限制靜態文件類型(如僅允許?
text/*
、image/*
)。
- 禁用目錄列表(
模板安全:
- 禁用 MiniJinja 的?
eval
?和?exec
?功能(默認禁用),防止注入攻擊。 - 對用戶輸入的內容使用?
{{ user_input | escape }}
?轉義(MiniJinja 默認轉義 HTML)。
- 禁用 MiniJinja 的?
性能優化:
- 預編譯模板到二進制。
- 為靜態文件添加緩存頭。
- 使用?
tokio
?的?release
?模式編譯(cargo build --release
)。