Web开发(上):如何使用Axum框架进行Web后端开发?
你好,我是Mike, 今天我们来讲一讲如何用Axum框架进行Web服务器后端开发。
关于Rust是否适合做Web后端开发,很多人持怀疑态度。认为Web开发讲究的是敏捷,一种动态的、带运行时的、方便修改的语言可能更适合Web开发。
但有一些因素,其实决定了Rust非常适合Web开发。
- 时代变化:芯片摩尔定律已失效,服务器成本逐渐占据相当大的比例,无脑堆服务器的时代已经过去。当前阶段,对于规模稍微大一些的公司来说,降本增效是一个很重要的任务。而Rust的高性能和稳定可靠的表现非常适合帮助公司节省服务器成本。
- 随着业务的成型,一些核心业务会趋于稳定,模型和流程抽象已经基本不会有大的改动。这时,使用Rust重写或部分重写这些业务,会带来更强的性能和更可靠稳定的服务表现。
- Rust非常适合用来做一些通用的中间件服务,目前开源社区已经出现了一些相当优秀的中间件产品。
- Rust的表达能力其实非常强,其强大的类型系统可帮助开发者轻松地对问题进行建模。一个中等熟练的Rust开发者做原型的速度不会比Python慢多少,在复杂问题场景下,甚至更快。
- Rust的语言层面的设计使它特别适合做重构。重构Rust工程特别快,重构完后,也不用担心出错的问题。相对于动态语言,对应的测试用例都可以少准备很多。
Rust Web Backend 生态
Rust Web后端开发生态目前处于一种欣欣向荣的局面。就Web开发框架来说,能叫得上名字的就有十几种,其中按 crates.io 最新下载量排名,排列靠前的是这几位。
- Axum
- Actix-web
- Rocket
- Poem
- Rouille
- Salvo
从最近下载的绝对数量来说,Axum 的下载量是第二名 Actix-web 的 3 倍多。因此,我们选择了下载量最多的框架——Axum。
Tokio技术栈介绍
Axum有这么强劲的表现,得益于其本身优秀的设计,还有它无缝依赖的 Tokio 技术栈。Tokio从 2017 年发布 0.1 版本开始到现在,已经形成一套强大的技术栈。
这些可靠的工具链,为Rust在服务器端的发展打下了坚实的基础,越来越多的项目开始采用这一套技术栈。
Axum框架
Axum是一个专注于人体工程学和模块化的Web开发框架。——官方文档的Slogan
从上层视角来看,Axum具有以下特性:
- 写Router和Handler的时候,不需要使用宏(非标注风格)。
- 使用解包器进行声明式的请求解析。
- 简单和可预测的错误处理模型。
- 生成响应时只需用到最小的样板代码。
- 充分利用 tower 和 tower-http 的中间件、服务和工具的生态系统。
特别是最后一点,让Axum显著不同于其他框架。Axum自己不带中间件系统,而是使用 tower 系的中间件。这样就能和生态里的其他项目,如 hyper 或 tonic 共享中间件系统。 这种设计太棒了。
Tower中间层服务
Tower是一个模块化、可重用的组件系统,用于构建稳健的网络服务端和客户端应用。它对 请求/响应模型 做了一个统一的服务抽象,和具体的实现协议无关。你可以看一下Service的定义。
async fn(Request) -> Result<Response, Error>
Tower包含 Service 和 Layer 两个抽象。一个Service是一个接受请求,做出响应的异步函数;而一个 Layer 接受一个Service,处理并返回另一个Service。Service和Layer一起,就可以方便地构建中间件服务。而完全基于流(stream)的服务,并不适合使用Tower。
Tower-http介绍
Tower-http 是一个专门用于提供HTTP相关的中间件和设施的库,里面罗列了二十多个中间件。
在Axum中,最常用的中间件是下面这几个。
- TracyLayer 中间件,用于提供高层日志或跟踪。
- CorsLayer 中间件,用于处理CORS。
- CompressionLayer 中间件,用于自动压缩响应。
- RequestIdLayer 和 PropagateRequestIdLayer 中间件,用于设置和传播请求ID。
- TimeoutLayer 中间件,用于设置超时。
- HandleErrorLayer 中间件,用于转换各种错误类型到响应。
而每个中间件都可以用在全局Router、模块Router和Handler三个层次。
Router
Router用来定义URL到handler的映射,并用来构建模块化URL的层级,既能支持全局性路由,也能支持模块层级的路由。比如:
#![allow(unused)] fn main() { let user_routes = Router::new().route("/:id", get(|| async {})); let team_routes = Router::new().route("/", post(|| async {})); let api_routes = Router::new() .nest("/users", user_routes) .nest("/teams", team_routes); let app = Router::new().nest("/api", api_routes); }
Handler
在Axum里,Handler是一个异步函数,这个异步函数接受一个或多个解包器作为参数,返回一个可以转换为响应(impl IntoResponse)的类型。
最简单的 handler 类似下面这个样子:
async fn string_handler() -> String {
"Hello, World!".to_string()
}
使用Axum进行Web后端开发
现在话不多说,让我们开始Axum实战环节吧!
注:目前(2023年12月)Axum的最新版本是 v0.7 版,我们下面会基于这个版本来编写代码。
第一步:Hello world
我们先来看看Axum的Hello World是什么样子。
use axum::{response::Html, routing::get, Router}; #[tokio::main] async fn main() { // build our application with a route let app = Router::new().route("/", get(handler)); // run it let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } async fn handler() -> Html<&'static str> { Html("<h1>Hello, World!</h1>") }
这里我给出这个步骤的 原始代码地址,你可以直接下载到本地运行。
从这个例子里,我们可以一窥Axum应用的基本结构。首先,一个Axum应用其实就是一个Tokio应用,以标志性的tokio::main属性宏开头。
#[tokio::main] async fn main() {
然后,创建一个Router实例,代表App。在Router中,绑定URL与handler。
然后使用 tokio::net::TcpListener::bind()
监听服务器地址,并将生成的 listener 传入 axum::serve()
中使用,启动axum服务。而这个应用的 handler 参数里没有解包器(extractor),是因为我们暂时不解析请求参数,所以参数部分留空。
async fn handler() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
然后,这个handler的返回值是 Html<&'static str>
类型,它已经实现了 IntoResponse,所以可以作为 Response 返回类型。下面我们来测试,打开终端在代码目录下执行 cargo run
。终端下输出 listening on 127.0.0.1:3000
。
打开浏览器,输入 127.0.0.1:3000
,可以看到下面这样的页面效果。
第二步:实现静态文件服务
静态文件服务非常有用,用于对服务器上的静态资源如图片、HTML、JS、CSS等文件提供服务。下面我们来看看如何使用Axum实现一个简单的静态文件服务。我们只需要引入ServeDir服务,然后在Router实例上使用 nest_service 挂载这个服务就可以了。
#![allow(unused)] fn main() { Router::new().nest_service("/assets", ServeDir::new("assets")) }
ServeDir::new()
的参数是资源目录的路径,上面一句表示把 /assets/*
的URL映射到 assets 目录下的哪些文件。
而如果需要配置获取默认文件,可以按照下面这样,在ServeDir里配置not_found_service,传一个ServeFile实例进去,这个ServeFile实例就是默认获取的那个文件。然后在Router实例配置时,添加一个 fallback_service 挂载 serve_dir。
#![allow(unused)] fn main() { let serve_dir = ServeDir::new("assets2") .not_found_service(ServeFile::new("assets2/index.html")); let app = Router::new() .route("/foo", get(handler)) .nest_service("/assets", ServeDir::new("assets")) .nest_service("/assets2", serve_dir.clone()) .fallback_service(serve_dir); }
上述示例里,即使我们访问一些不存在的URL地址,服务器也会返回assets2目录下的index.html文件。这通常正是我们想要的。你可以根据 示例源码 自行测试,查看效果。
你可以尝试如下URL地址:
http://127.0.0.1:3000/
http://127.0.0.1:3000/foo
http://127.0.0.1:3000/bar
http://127.0.0.1:3000/assets/index.html
http://127.0.0.1:3000/assets/ferris.png
http://127.0.0.1:3000/assets/script.js
http://127.0.0.1:3000/assets2/index.html
http://127.0.0.1:3000/assets2/ferris2.png
http://127.0.0.1:3000/assets2/script.js
...
第三步:加入日志记录
日志系统是一个服务必不可少的组件,我们一起来看看如何为Axum应用添加日志。在Axum中添加日志很简单。第一步,我们需要添加 tracing 和 tracing-subscriber 两个crates。
#![allow(unused)] fn main() { cargo add tracing cargo add tracing-subscriber }
然后引入TraceLayer。
#![allow(unused)] fn main() { use tower_http::trace::TraceLayer; }
在main函数的开头添加代码:
#![allow(unused)] fn main() { tracing_subscriber::fmt::init(); }
再在Router配置的时候,添加下面这行代码配置日志中间件服务就可以了。
#![allow(unused)] fn main() { let app = Router::new() .route("/foo", get(handler)) .nest_service("/assets", serve_dir.clone()) .fallback_service(serve_dir) .layer(TraceLayer::new_for_http()); // 添加这行 }
使用时,需要按照下面这个方式来使用。
#![allow(unused)] fn main() { tracing::debug!("listening on {}", addr); }
我们用上面一句替代了之前的 println!
,在一个正经一点的应用中,我们都不会使用 println!
去打印日志的。
这里需要补充一点知识:Rust中的日志标准。Rust标准的log协议在 链接 里。这个crate中定义了5个级别的日志打印语句。
error!
warn!
info!
debug!
trace!
这5个级别从上到下警示程度为由高到低。也就是说,越往下会越详细。
但是这只是一套协议的定义,而不是具体实现。具体使用的时候,需要用另外的crate来实现。我们常用的 env_logger 就是其中一种实现,它把日志输出到标准终端里,用环境变量来控制打印的级别开关。而这里我们使用的 tracing 库也是这样一种实现。它是为tokio异步运行时专门设计的,适合在异步并发代码中使用。
你可以使用RUST_LOG来打开日志开关。比如:
#![allow(unused)] fn main() { RUST_LOG=trace cargo run }
访问 http://127.0.0.1:3000/foo ,可以看到,TRACE级别的和DEBUG级别的日志都打印出来了。
#![allow(unused)] fn main() { 2023-12-11T05:36:39.240674Z TRACE mio::poll: registering event source with poller: token=Token(0), interests=READABLE | WRITABLE 2023-12-11T05:36:39.240718Z DEBUG axumapp03: listening on 127.0.0.1:3000 2023-12-11T05:36:50.029388Z TRACE mio::poll: registering event source with poller: token=Token(1), interests=READABLE | WRITABLE 2023-12-11T05:36:50.029678Z TRACE mio::poll: registering event source with poller: token=Token(2), interests=READABLE | WRITABLE 2023-12-11T05:36:50.030011Z DEBUG request{method=GET uri=/foo version=HTTP/1.1}: tower_http::trace::on_request: started processing request 2023-12-11T05:36:50.030127Z DEBUG request{method=GET uri=/foo version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=200 }
用debug模式日志会少一些。
#![allow(unused)] fn main() { RUST_LOG=debug cargo run }
访问 http://127.0.0.1:3000/foo , 输出:
#![allow(unused)] fn main() { 2023-12-11T05:43:45.223551Z DEBUG axumapp03: listening on 127.0.0.1:3000 2023-12-11T05:43:50.558174Z DEBUG request{method=GET uri=/foo version=HTTP/1.1}: tower_http::trace::on_request: started processing request 2023-12-11T05:43:50.558303Z DEBUG request{method=GET uri=/foo version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=200 }
我们还可以指定只打印某些crate里的日志。
#![allow(unused)] fn main() { RUST_LOG=tower_http=debug,axumapp03=debug cargo run }
访问 http://127.0.0.1:3000/foo ,输出:
#![allow(unused)] fn main() { 2023-12-11T05:45:03.578294Z DEBUG axumapp03: listening on 127.0.0.1:3000 2023-12-11T05:45:08.036568Z DEBUG request{method=GET uri=/foo version=HTTP/1.1}: tower_http::trace::on_request: started processing request 2023-12-11T05:45:08.036696Z DEBUG request{method=GET uri=/foo version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=200 }
你可以下载 源码 自行测试查看效果。
第四步:解析 Get 请求参数
一般一个URL是这样的:
https://rustcc.cn/article?id=8ba98036-e2fb-41cf-9dad-7cd874c397c4
最后面一段是 query,query 的格式为键值对列表,比如 x1=yyy&x2=zzz
,键值对之间使用 & 符号隔开。
Axum采用一种解包器(extractor)的方式,直接从HTTP Request里提取出开发者关心的参数信息。这种设计有一些好处:
- 减少样板重复代码,可以让代码更紧凑易读。
- 类型化,充分利用Rust强大的类型能力。
- 配置式开发,轻松惬意。
下面我们先来看如何从GET请求的URL中,取出Query参数。
在 Axum中,我们只需要在Handler中这样配置解包器就可以。
#[derive(Debug, Deserialize)]
struct InputParams {
foo: i32,
bar: String,
third: Option<i32>,
}
async fn query(Query(params): Query<InputParams>) -> impl IntoResponse {
tracing::debug!("query params {:?}", params);
Html("<h3>Test query</h3>")
}
这里这个 params 参数就是我们想要的query请求参数。可以看到,Axum框架自动帮我们处理了解析工作,让我们直接得到了Rust 结构体对象。减轻了我们处理具体协议的繁琐度,不易出错。请注意示例第8行的参数中使用了模式匹配,你可以回顾一下 第 6 讲 的模式匹配相关知识点。
我们可以继续折腾一下,InputParams里定义了一个third字段,它是 Option<i32>
类型,请你尝试在浏览器URL地址栏或插件中变换请求参数,查看及日志输出返回情况。可能的参数组合有下面这几种。
http://127.0.0.1:3000/query?foo=1&bar=2&third=3
http://127.0.0.1:3000/query?foo=1&bar=2
http://127.0.0.1:3000/query?foo=1
http://127.0.0.1:3000/query?bar=2
http://127.0.0.1:3000/query?foo=1&bar=2&third=3&another=4
本示例完整代码在 这里。
小结
通过这节课的展示,你有没有体会到使用Rust进行Web开发的感觉?其实跟那些动态语言如Python、JavaScript等开发Web没太大区别,Axum的代码也是比较精练的。请你一定要参考我给出的代码示例,对照源代码在你的本地跑起来。Web开发一个非常方便的地方就是方便测试,易于看到渐进的效果正反馈,所以动手是非常重要而且高效的。
相比于动态语言来讲,Rust的强类型更加能够保证Web后端服务的正确性和可靠性。性能自不用说,你选择了Rust,就是选择了高起点。
同时你可以看到,这节课出现的Axum示例代码,一点都不复杂,甚至都没有用到Rust的所谓各种高级特性。其实就是这样,你用Rust做日常开发,特别是业务开发,根本不难,不要被那些所谓的高级特性吓到,先用现成的框架,快速捣鼓出自己的东西,后面再慢慢上难度就好了。
思考题
请你说一说Request/Response 模型是什么,Axum框架和其他gRPC框架(比如Tonic)有什么区别。欢迎你把自己的思考分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!