本文基于 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>
where
S: 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 Handler
axum::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.html
env.add_template("base.html", include_str!("../templates/base.html"))
.expect("Failed to compile base.html");
// 预编译 index.html
env.add_template("index.html", include_str!("../templates/index.html"))
.expect("Failed to compile index.html");
// 预编译 users.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
}
说明: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);
// 叠加 Layer
let 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
)。