哎呀,我又犯了同样的错误……我创建了一个 Rust Web API,其实并不难
两年多前(哦,对了),我发布了一个由@gypsydave5编写的todo-mvp Rust 实现的演示,演示了如何在没有框架的情况下构建一个简单的 Rust API。核心功能是使用hyper (一个底层 HTTP 库)构建的,而不是使用成熟的框架。
事实证明,我写那篇文章早了大约六个月。我把它发布在 2019 年 5 月,而到了 11 月,Rust 发布了包含async/await
该语法的 1.39.0 版本。呜呜呜。
我当时的意图只是简单地升级现有应用程序,使其在适用的情况下使用新语法,然后就此打住。但是,你知道当你回过头去审查几年前写的代码时会发生什么吗?是的,就是这样。我们从头开始。
什么是新的
我们的结果功能与上一篇文章中的实现几乎完全相同,因此部分代码看起来非常相似。以下是本文中一些之前版本中没有的新内容的简要概述:
- 无论如何- 为人类处理错误。
- async/await - 表达异步计算的新语法 - 就像一个网络服务器!
- catch_unwind - 崩溃的任务不应该导致整个服务器崩溃!优雅地捕获崩溃,继续服务。
- 压缩- 每个响应都会被 DEFLATE 压缩,因为我们可以。
- Rust 2021 —— Rust 的未来。
- 状态管理——我们将使用协议扩展来访问应用程序状态,而不是全局变量。
- 追踪 -
log
板条箱早已过时了。现在所有酷酷的孩子都在用tracing
。 - 单元测试 - 上次我们什么都没做- 啧啧啧!学习如何为你的处理器编写异步单元测试。
设置
要继续学习,你需要一个稳定的 Rust 工具链。请参阅安装页面,了解针对你的平台的安装说明rustup
。你应该优先使用这种方法,而不是使用你发行版的包管理器。如果你是 NixOS 的狂热粉,我推荐使用fenix。
设置好环境后,启动一个新项目并确保可以构建它:
$ cargo new simple-todo
Created binary (application) `simple-todo` package
$ cd simple-todo
$ cargo run
Compiling simple-todo v0.1.0 (/home/deciduously/code/simple-todo)
Finished dev [unoptimized + debuginfo] target(s) in 0.19s
Running `target/debug/simple-todo
Hello, world!
$
打开你的Cargo.toml
并使其看起来像这样:
[package]
authors = ["Cool Person <cool.person@yourcoolsite.neato>"]
edition = "2021"
rust-version = "1.56"
name = "simple-todo"
version = "0.1.0"
[dependencies]
anyhow = "1"
backtrace = "0.3"
clap = {version = "3.0.0-beta.5", features = ["color"] }
flate2 = "1"
futures = "0.3"
hyper = { version = "0.14", features = ["full"] }
lazy_static = "1.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tera = "1"
tokio = { version = "1", features = ["full"] }
uuid = { version = "0.8", features = ["serde", "v4"] }
[dev-dependencies]
pretty_assertions = "0.7"
select = "0.5"
这里已经有一些新元素,位于软件包部分。Rust 2021最近发布,随之而来的是rust-version
元数据键,允许您直接在软件包中指定最低支持的 Rust 版本 (MSRV)。
这篇文章只涉及 Rust,并将使用与上一篇文章相同的资源。templates
在项目顶层创建一个名为 的文件夹,并将此 index.html放入其中。您还需要创建一个目录 ,src/resource
并在其中填充这些文件。其中包含一个样式表和一些 SVG 文件。您的结构应如下所示:
$ tree
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── main.rs
│ └── resource
│ ├── check.svg
│ ├── plus.svg
│ ├── tick.png
│ ├── todo.css
│ ├── trashcan.svg
│ └── x.svg
└── templates
└── index.html
一切顺利!
入口点
整个应用程序将存在于src/main.rs
。首先添加导入:
use anyhow::Result;
use backtrace::Backtrace;
use clap::Parser;
use flate2::{write::ZlibEncoder, Compression};
use futures::{future::FutureExt, Future};
use hyper::http;
use lazy_static::lazy_static;
use serde::Serialize;
use std::{
cell::RefCell,
convert::Infallible,
io::Write,
panic::AssertUnwindSafe,
path::PathBuf,
sync::{Arc, RwLock},
};
use tera::Tera;
use uuid::Uuid;
允许用户指定应用程序的运行位置真是太好了。该clap
库提供了一种便捷的方法,可以在结构体中指定命令行参数。此功能目前仍处于测试阶段,但很快就会稳定下来。我们可以创建一个结构体并使用该Parser
功能来生成以下选项:
#[derive(Parser)]
#[clap(version = concat!(env!("CARGO_PKG_VERSION")), about = "Serve a TODO list application.")]
struct Args {
#[clap(
short,
long,
about = "Address to bind the server to.",
env,
default_value = "0.0.0.0"
)]
address: String,
#[clap(short, long, about = "Port to listen on.", env, default_value = "3000")]
port: u16,
}
当不带任何参数运行时,服务器将绑定到0.0.0.0:3000
,这是一个合理的默认值。这允许用户使用ADDRESS
和PORT
环境变量或-a/--address
和-p/--port
命令行参数。它还提供了一个不错的--help
实现:
simple-todo 0.1.0
Serve a TODO list application.
USAGE:
todo-mvp-rust [OPTIONS]
OPTIONS:
-a, --address <ADDRESS> Address to bind the server to. [env: ADDRESS=] [default: 0.0.0.0]
-h, --help Print help information
-p, --port <PORT> Port to listen on. [env: PORT=] [default: 3000]
-V, --version Print version information
我们的main()
函数将仅解析这些参数并将它们传递给我们的应用程序例程:
fn main() -> Result<()> {
let args = Args::parse();
app(args)?;
Ok(())
}
我已将其纳入anyhow::Result
范围,使错误处理变得超级简单。我们无需指定所有Error
类型。它可以自动转换所有实现了 的错误std::error::Error
,也就是所有实现了 的错误。如果错误一直传播到main()
,我们会将其捕获的所有信息打印到标准输出。
该app()
函数是我们真正的入口点:
#[tokio::main]
async fn app(args: Args) -> Result<()> {
tracing_subscriber::fmt::init();
let addr = std::net::SocketAddr::new(args.address.parse()?, args.port);
let todos = Todos::new();
let context = Arc::new(RwLock::new(todos));
serve(addr, context, handle).await?;
Ok(())
}
此函数带有 标记#[tokio::main]
,表示它将执行异步运行时。我们现在使用的所有函数都可以标记为async
,表示我们可以得到await
结果。在底层,Rust 将这些函数转换为称为 Future 的结构,在上一次迭代中,我们手动构建了这些结构。Future 可以是Ready
或Waiting
。您可以在 Future 上调用该poll()
方法,它只会询问:“您准备好了,还是在等待?” 如果 Future 已准备好解析,它将传递响应,如果它正在等待,它将仅使用 进行响应Pending
。它可以返回一个wake()
回调,让调用者知道有一个值。当wake
执行 时,调用者将再次知道poll()
这个 Future,并将其解析为一个值。
这一切都涉及很多仪式。这种async/.await
语法为我们抽象了这些概念。我们只需像平常一样编写函数,除了将它们标记为async
,并且当控制流到达需要等待结果解析的点时,我们可以使用await
。如果在像 这样的执行器中使用这些#[tokio::main]
,运行时将覆盖所有细节。这些await
点中的每一个都不会阻塞控制流,而是会屈服于此执行器上正在运行的其他任务,直到底层从 的请求Future
中返回。这使得我们的类型更容易推理,我们的代码也更容易编写。poll()
Ready<T>
这个顶级函数读取我们的参数结构体来构建要SocketAddr
绑定的 ,用 启动日志系统tracing_subscriber
,并构建状态管理。然后,我们调用serve()
异步函数 并await
来处理结果。这个执行器将一直运行,直到用户终止进程,并且可以无缝地为我们处理多个并发连接。
先睹为快,这是的签名serve()
:
async fn serve<C, H, F>(
addr: std::net::SocketAddr,
context: Arc<C>,
handler: H,
) -> hyper::Result<()>
where
C: 'static + Send + Sync,
H: 'static + Fn(Request) -> F + Send + Sync,
F: Future<Output = Response> + Send,
{
// ...
}
它要求我们传递一个地址,然后是一个Arc<C>
。该C
类型是我们的应用程序状态,为了使其工作,它必须实现Send
和Sync
特征。Send
表示该值可以发送到另一个线程,Sync
表示它可以同时在线程之间共享。
在我们的例子中,我们不需要过多考虑这一点。我们使用一个Arc<RwLock<T>>
允许变异的类型,以便多个任务可以访问该类型,并根据需要安全地进行变异。只要每个任务都获取到正确的锁,我们就不必担心并发任务之间互相干扰。每次只有一个任务可以写入此类型,因此每个新的读取者都将始终获得正确的数据。
最后,我们需要添加一个类型为 的处理程序。这些类型开始为我们做H
一些事情。剥离掉trait bounds 之后,这个函数满足 trait bound 。因为我们处于异步环境中,所以我们可以直接这样写——这是一个从请求到响应的异步函数。听起来像个 Web 服务器!使用 Rust 的,我们可以简单地写出我们想要表达的意思。async
Send + Sync
Fn(Request) -> Future<Output = Response>
async fn handle(request: Request) -> Response
async/.await
我们很快就会回到处理程序 - 首先,我们需要进行一些设置。
模板
此应用程序仅包含一个页面,该页面将在状态更改时刷新。我们将标记放置在一个使用Jinjatemplates/index.html
风格模板的HTML 文件中。我们将使用Rust 来处理它。Tera
模板在使用前需要编译,但只需编译一次。我们可以lazy_static
确保在第一次访问模板时进行编译,然后在所有后续访问中重用编译结果:
lazy_static! {
pub static ref TERA: Tera = match Tera::new("templates/**/*") {
Ok(t) => t,
Err(e) => {
eprintln!("Unable to parse templates: {}", e);
std::process::exit(1);
}
};
}
现在我们可以使用TERA
全局变量了。如果 Tera 因任何原因无法编译模板,进程将在此退出并显示错误。只有此步骤成功完成,我们的服务器才能正常工作。
状态
接下来,我们需要定义应用程序的状态。该应用程序围绕一个Todo
类型展开,该类型包含一个名称、一个 ID 和一个布尔值,用于跟踪应用程序是否已完成:
#[derive(Debug, Serialize)]
pub struct Todo {
done: bool,
name: String,
id: Uuid,
}
impl Todo {
fn new(name: &str) -> Self {
Self {
done: false,
name: String::from(name),
id: Uuid::new_v4(),
}
}
}
我们只需要提供一个字符串名称,比如Todos::new("Task")
,这种类型就会生成一个新的唯一ID并将其设置为不完整。
存储非常简单:
#[derive(Debug, Default)]
struct Todos(Vec<Todo>);
我们需要添加新待办事项、删除现有待办事项以及切换done
布尔值的方法来:
impl Todos {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, todo: Todo) {
self.0.push(todo);
}
pub fn remove(&mut self, id: Uuid) -> Option<Todo> {
let mut idx = self.0.len();
for (i, todo) in self.0.iter().enumerate() {
if todo.id == id {
idx = i;
}
}
if idx < self.0.len() {
let ret = self.0.remove(idx);
Some(ret)
} else {
None
}
}
pub fn todos(&self) -> &[Todo] {
&self.0
}
pub fn toggle(&mut self, id: Uuid) {
for todo in &mut self.0 {
if todo.id == id {
todo.done = !todo.done;
}
}
}
}
值得注意的是,我们无需编写任何特殊代码来确保此线程安全。我们可以像在单线程同步上下文中一样编写它,并且相信 Rust 不会允许我们以不安全的方式对其进行可变访问。以下是实例化的app()
代码:
let todos = Todos::new();
let context = Arc::new(RwLock::new(todos));
将所有这些都封装在一个 中Arc
意味着任何访问此值的任务都可以获得它们自己的引用,并且RwLock
允许多个并发读取器或一次只允许一个写入器。当锁被释放时,下一个等待的任务将能够获得控制权。
处理程序
我们终于可以看看这个处理程序了。根据上面的签名,我们知道我们需要一个具有以下签名的函数:async fn handle(request: Request) -> Response
。每次我们的 Web 服务器收到 HTTP 请求时,它都会调用此函数来生成并返回一个响应。首先,为请求和响应添加类型别名:
type Request = http::Request<hyper::Body>;
type Response = http::Response<hyper::Body>;
这个hyper
crate 定义了我们需要的所有类型。在这两种情况下,请求和响应都会带有一个hyper::Body
。每个处理程序都会使用这个类型签名,因此定义这些别名可以节省我们大量的输入工作。
请求包含有关其发送方式的信息。我们可以使用 Rust 的match
构造来读取传入的 URI 和 HTTP 方法,以便正确地分发响应。以下是完整的请求主体:
async fn handle(request: Request) -> Response {
// pattern match for both the method and the path of the request
match (request.method(), request.uri().path()) {
// GET handlers
// Index page handler
(&hyper::Method::GET, "/") | (&hyper::Method::GET, "/index.html") => index(request).await,
// Style handler
(&hyper::Method::GET, "/static/todo.css") => stylesheet().await,
// Image handler
(&hyper::Method::GET, path_str) => image(path_str).await,
// POST handlers
(&hyper::Method::POST, "/done") => toggle_todo_handler(request).await,
(&hyper::Method::POST, "/not-done") => toggle_todo_handler(request).await,
(&hyper::Method::POST, "/delete") => remove_todo_handler(request).await,
(&hyper::Method::POST, "/") => add_todo_handler(request).await,
// Anything else handler
_ => four_oh_four().await,
}
}
每个匹配分支都会匹配特定的 HTTP 动词和路径组合。例如,GET /static/todo.css
会正确调度stylesheet()
处理程序,但POST /static/todo.css
不受支持,因此会直接使用four_oh_four()
。每个处理程序本身都是一个async
函数,但我们不希望在它们被轮询为 之前返回给调用者ready
,并返回一个实际的Response
。记住,Rust 会帮我们完成这些——当我们写 时async fn() -> Response
,我们实际上会得到一个Fn() -> impl Future<Output = Response>
。在 Future 解析完成之前,我们不能使用该返回类型!这就是.await
语法的含义。Future 准备就绪后,我们将使用结果Response
输出,但在此之前不会使用。
最直接的处理程序是four_oh_four()
:
async fn four_oh_four() -> Response {
html_str_handler("<h1>NOT FOUND!</h1>", http::StatusCode::NOT_FOUND).await
}
这个响应与请求无关——毕竟,请求对我们来说毫无意义!它没有输入参数,但像所有处理程序一样,它会返回一个Response
。因为我们所有的路由都需要构建响应,所以我把这个逻辑拆分成了一系列构建块函数。
响应生成器
我们的大部分Response
构建过程都有很多相同的逻辑。我们通常会返回某种形式的字符串,并希望附加内容类型和状态码。由于我们关心用户的带宽使用情况,因此我们也希望压缩响应,确保尽可能减少通过网络发送的数据量。最常见的情况是成功响应包含 HTML:
async fn ok_html_handler(html: &str) -> Response {
html_str_handler(html, http::StatusCode::OK).await
}
这反过来又调用 HTML 字符串处理程序:
async fn html_str_handler(html: &str, status_code: http::StatusCode) -> Response {
string_handler(html, "text/html", status_code).await
}
我们的four_oh_four()
处理程序直接使用它来包含不同的状态代码。但最终,它只是字符串:
async fn string_handler(body: &str, content_type: &str, status_code: http::StatusCode) -> Response {
bytes_handler(body.as_bytes(), content_type, status_code).await
}
async fn ok_string_handler(body: &str, content_type: &str) -> Response {
string_handler(body, content_type, hyper::StatusCode::OK).await
}
只要主体仍然以字符串形式传递,这些辅助函数就允许使用其他内容类型。这涵盖了我们此应用程序所需的所有主体类型。在底部,我们得到bytes_handler
:
async fn bytes_handler(body: &[u8], content_type: &str, status_code: http::StatusCode) -> Response {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(body).unwrap();
let compressed = encoder.finish().unwrap();
hyper::Response::builder()
.status(status_code)
.header(hyper::header::CONTENT_TYPE, content_type)
.header(hyper::header::CONTENT_ENCODING, "deflate")
.body(hyper::Body::from(compressed))
.unwrap()
}
此函数接受一个字节切片 ( &[u8]
),并DEFLATE
对其进行压缩。它会添加适当的Content-Encoding
标头,以便任何连接的客户端都可以在将有效负载呈现给用户之前对其进行解压缩。这是一个快速的操作,有效负载越小越好。每个包含正文的响应最终都会经过此函数,然后冒泡回溯到顶层handle()
函数,再返回到客户端。
此应用程序还有一种响应类型,它完全不使用主体。每当我们的应用状态发生变化时,我们将使用301
HTTP 状态代码触发客户端重定向回索引。每次渲染索引时,它都会读取当前的应用状态,这意味着在处理程序中执行的任何更改都将自动反映在刷新中。此函数Response::builder()
直接调用:
async fn redirect_home() -> Response {
hyper::Response::builder()
.status(hyper::StatusCode::SEE_OTHER)
.header(hyper::header::LOCATION, "/")
.body(hyper::Body::empty())
.unwrap()
}
主页
现在,我们已经准备好渲染应用所需的一切了。我们的 index 函数是第一个需要状态的处理程序:
async fn index(request: Request) -> Response {
// Set up index page template rendering context
let mut tera_ctx = tera::Context::new();
let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
{
let lock = todos_ctx.read().unwrap();
let todos = lock.todos();
let len = todos.len();
tera_ctx.insert("todos", todos);
tera_ctx.insert("todosLen", &len);
}
let html = TERA.render("index.html", &tera_ctx).unwrap().to_string();
ok_html_handler(&html).await
}
在此应用的上一个迭代中,该Todos
结构体与模板一起实例化为TERA
一个全局静态变量。更好的解决方案是使用Request
本身将其传递到我们的处理程序。我们将在下面讨论具体实现,但当我们讲到这里时,已经有一个Arc
包含上下文的全新对象可供读取。我们可以使用request.extensions().get()
和Arc::clone()
来获取我们自己的应用状态引用,用于构建此响应。请求扩展使用 的类型来存储访问内容,因此我们需要明确添加 的类型todos_ctx
来指示我们要查找的内容。
接下来,我们使用应用的当前状态构建索引页。此处理程序不会执行任何修改,因此我们可以使用todos_ctx.read()
。通过引入子作用域,我们确保读取锁在完成后会被释放,从而允许任何等待访问的写入者获取自己的锁。如果我们需要等待,没问题!我们在一个async
函数中,调用者可以随时轮询我们,我们只需返回即可,Pending
直到准备好。简洁明了。
一旦我们收到应用状态的句柄,就可以将其传递给 Tera。TERA.render()
它将返回一个 HTML 字符串,其中包含使用应用状态解析的所有模板值。然后,我们可以使用可靠的ok_html_handler()
响应构建器为其添加适当的内容类型和状态码标记,并在返回给调用者之前压缩结果。
模板index.html
从 加载时会请求样式表/static/todo.css
。这是一个非常简单的处理程序:
async fn stylesheet() -> Response {
let body = include_str!("resource/todo.css");
ok_string_handler(body, "text/css").await
}
该include_str!()
宏实际上将字符串内容直接捆绑到编译后的二进制文件中。这些文件需要在编译时存在,但不需要在生产环境中分发。编译后的二进制文件已经包含了所需的所有内容。
SVG
本应用中的所有图像资源均为 SVG,以 XML 格式表示。这意味着我们只需要读取这些字符串并传递正确的内容类型即可:
async fn image(path_str: &str) -> Response {
let path_buf = PathBuf::from(path_str);
let file_name = path_buf.file_name().unwrap().to_str().unwrap();
let ext = match path_buf.extension() {
Some(e) => e.to_str().unwrap(),
None => return four_oh_four().await,
};
match ext {
"svg" => {
// build the response
let body = match file_name {
"check.svg" => include_str!("resource/check.svg"),
"plus.svg" => include_str!("resource/plus.svg"),
"trashcan.svg" => include_str!("resource/trashcan.svg"),
"x.svg" => include_str!("resource/x.svg"),
_ => "",
};
ok_string_handler(body, "image/svg+xml").await
}
_ => four_oh_four().await,
}
}
我使用了全能型处理机制——任何非索引或样式表的 GET 请求都会被默认为图片请求。在代码顶部,有一些额外的逻辑来确保我们正在寻找图片文件。如果没有扩展名,即 ,则/nonsense
此处理程序将分派一个four_oh_four()
。否则,我们继续尝试查找实际的 SVG 文件。如果成功,我们就直接将字符串传回,如果失败,我们也将four_oh_four()
。
状态处理程序
其余处理程序与状态变更有关。它们都会传递一个包含待办事项的请求体。对于新的待办事项,它将是item=Task
;对于切换或删除,它将保存 id: item=e2104f6a-624d-498f-a553-29e559e78d33
。无论哪种情况,我们只需提取等号后的值即可:
async fn extract_payload(request: Request) -> String {
let body = request.into_body();
let bytes_buf = hyper::body::to_bytes(body).await.unwrap();
let str_body = String::from_utf8(bytes_buf.to_vec()).unwrap();
let words: Vec<&str> = str_body.split('=').collect();
words[1].to_owned()
}
主体部分可能以块的形式出现,因此我们使用hyper::body::to_bytes()
来生成一个将所有内容连接起来的单个Bytes
值。然后,我们可以将字节转换为 UTF-8 字符串,并根据 进行拆分=
以获取实际的有效负载。我们所有的状态突变处理程序都会在传入请求时调用此函数:
async fn add_todo_handler(request: Request) -> Response {
let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
let payload = extract_payload(request).await;
{
let mut lock = todos_ctx.write().unwrap();
(*lock).push(Todo::new(&payload));
}
redirect_home().await
}
async fn remove_todo_handler(request: Request) -> Response {
let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
let payload = extract_payload(request).await;
{
let mut lock = todos_ctx.write().unwrap();
(*lock).remove(Uuid::parse_str(&payload).unwrap());
}
redirect_home().await
}
async fn toggle_todo_handler(request: Request) -> Response {
let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
let payload = extract_payload(request).await;
{
let mut lock = todos_ctx.write().unwrap();
(*lock).toggle(Uuid::parse_str(&payload).unwrap());
}
redirect_home().await
}
每个处理程序都会抓取其自身对应用状态的唯一引用,然后提取有效负载。就像我们在 中所做的那样index()
,我们打开一个新的作用域来与RwLock
互斥锁交互,在本例中,我们使用todos_ctx.write()
来请求一个可变锁。这会阻止所有其他任务,直到突变完成。然后,我们只需redirect_home()
。这会提示客户端发送GET /
请求,从而导致我们的 to-level 处理程序调用index()
,后者读取新突变的应用状态来构建页面。
太棒了!这是一款功能齐全的 TODO 应用。
服务
还缺少一点。我们定义了函数,但除了类型签名之外,handle()
我们还没有讨论其他部分。这个部分相当充实:serve()
async fn serve<C, H, F>(
addr: std::net::SocketAddr,
context: Arc<C>,
handler: H,
) -> hyper::Result<()>
where
C: 'static + Send + Sync,
H: 'static + Fn(Request) -> F + Send + Sync,
F: Future<Output = Response> + Send,
{
// Create a task local that will store the panic message and backtrace if a panic occurs.
tokio::task_local! {
static PANIC_MESSAGE_AND_BACKTRACE: RefCell<Option<(String, Backtrace)>>;
}
async fn service<C, H, F>(
handler: Arc<H>,
context: Arc<C>,
mut request: http::Request<hyper::Body>,
) -> Result<http::Response<hyper::Body>, Infallible>
where
C: Send + Sync + 'static,
H: Fn(http::Request<hyper::Body>) -> F + Send + Sync + 'static,
F: Future<Output = http::Response<hyper::Body>> + Send,
{
let method = request.method().clone();
let path = request.uri().path_and_query().unwrap().path().to_owned();
tracing::info!(path = %path, method = %method, "request");
request.extensions_mut().insert(context);
let result = AssertUnwindSafe(handler(request)).catch_unwind().await;
let start = std::time::SystemTime::now();
let response = result.unwrap_or_else(|_| {
let body = PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
let panic_message_and_backtrace = panic_message_and_backtrace.borrow();
let (message, backtrace) = panic_message_and_backtrace.as_ref().unwrap();
tracing::error!(
method = %method,
path = %path,
backtrace = ?backtrace,
"500"
);
format!("{}\n{:?}", message, backtrace)
});
http::Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body(hyper::Body::from(body))
.unwrap()
});
tracing::info!(
"Response generated in {}μs",
start.elapsed().unwrap_or_default().as_micros()
);
Ok(response)
}
// Install a panic hook that will record the panic message and backtrace if a panic occurs.
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|panic_info| {
let value = (panic_info.to_string(), Backtrace::new());
PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
panic_message_and_backtrace.borrow_mut().replace(value);
})
}));
// Wrap the request handler and context with Arc to allow sharing a reference to it with each task.
let handler = Arc::new(handler);
let service = hyper::service::make_service_fn(|_| {
let handler = handler.clone();
let context = context.clone();
async move {
Ok::<_, Infallible>(hyper::service::service_fn(move |request| {
let handler = handler.clone();
let context = context.clone();
PANIC_MESSAGE_AND_BACKTRACE.scope(RefCell::new(None), async move {
service(handler, context, request).await
})
}))
}
});
let server = hyper::server::Server::try_bind(&addr)?;
tracing::info!("🚀 serving at {}", addr);
server.serve(service).await?;
std::panic::set_hook(hook);
Ok(())
}
我知道,我知道。这里面有很多东西。这个函数的核心就在最后:
let server = hyper::server::Server::try_bind(&addr)?;
tracing::info!("🚀 serving at {}", addr);
server.serve(service).await?;
我们构建一个hyper::Server
,将它绑定到我们从结构体构造的地址Args
,然后提供服务service
。 就service
构建在它的上方:
let handler = Arc::new(handler);
let service = hyper::service::make_service_fn(|_| {
let handler = handler.clone();
let context = context.clone();
async move {
Ok::<_, Infallible>(hyper::service::service_fn(move |request| {
let handler = handler.clone();
let context = context.clone();
PANIC_MESSAGE_AND_BACKTRACE.scope(RefCell::new(None), async move {
service(handler, context, request).await
})
}))
}
});
我们还将处理函数包装在一个 中Arc
。我们的上下文已经包装好了,因此我们克隆它们以获取此闭包内的本地引用。这使得执行器可以启动处理程序服务的多个并发版本,并且所有版本都可以访问相同的状态和逻辑。
最关键的部分发生在service()
上面的闭包中。在这里,我们接收传入的请求,并将其与我们的处理器和上下文进行匹配。每个传入的请求都会执行一个新的实例,所有这些步骤使得这一切在不干扰其他同时发生的请求的情况下完成。
首先,这是我们将上下文添加到请求中的地方:
request.extensions_mut().insert(context);
当我们request.extensions().get()
调用变异处理程序时,我们会提取在此阶段添加的上下文。
我们还添加了一些日志记录。我们跟踪请求的细节,并启动一个计时器来报告请求的耗时。要查看此日志记录的实际效果,请RUST_LOG=info
在执行服务器进程时设置环境变量。
捕获恐慌
最令人兴奋的部分(至少对我来说)是恐慌处理程序。我们总是希望请求能够成功。然而,在某些情况下,我们可能会遇到panic
。这会导致整个 Rust 程序崩溃,并在正常使用情况下打印出堆栈跟踪。然而,这是一个 Web 服务。一个请求处理过程中的恐慌情况不应该阻止其他请求的执行。我们不希望整个服务器崩溃;我们仍然希望优雅地处理这些情况。我们可以拦截正常的恐慌行为,只需生成一个包含详细信息的不同响应即可。
在顶部,我们创建一个任务本地存储位置:
tokio::task_local! {
static PANIC_MESSAGE_AND_BACKTRACE: RefCell<Option<(String, Backtrace)>>;
}
这仅适用于当前正在执行的Tokio任务,而不是整个程序。然后,我们用自己的逻辑替换默认的恐慌逻辑:
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|panic_info| {
let value = (panic_info.to_string(), Backtrace::new());
PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
panic_message_and_backtrace.borrow_mut().replace(value);
})
}));
// ...
std::panic::set_hook(hook);
首先,我们获取现有的钩子并将其存储到hook
变量中。然后,我们覆盖我们自己的钩子。在函数结束时,我们确保将全局恐慌钩子重置为原来的状态。如果任务在此函数内发生恐慌 - 例如,我们的某个unwrap()
语句执行失败,我们将存储恐慌消息并回溯到这个任务本地位置。但是,我们不会中止该进程。
在上面的service
位置,我们可以捕捉到这种情况:
let result = AssertUnwindSafe(handler(request)).catch_unwind().await;
我们尝试构建响应,但如果发生任何情况,此结果都不会成功。如果成功了,那就太好了,我们会将其传回去。但是,如果我们在这里发现错误值,我们可以调度不同的逻辑:
let response = result.unwrap_or_else(|_| {
let body = PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
let panic_message_and_backtrace = panic_message_and_backtrace.borrow();
let (message, backtrace) = panic_message_and_backtrace.as_ref().unwrap();
tracing::error!(
method = %method,
path = %path,
backtrace = ?backtrace,
"500"
);
format!("{}\n{:?}", message, backtrace)
});
http::Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body(hyper::Body::from(body))
.unwrap()
});
对于大多数请求来说,一切result.unwrap()
顺利,我们只需将响应存储到即可Response
。但是,如果是错误,我们可以从这个任务本地区域读取恐慌的结果。我们在服务器端发出错误跟踪,然后构建一个状态码为 的新INTERNAL_SERVER_EROR
响应。此响应包含完整的回溯作为正文。这意味着我们的服务器可以继续处理其他请求而不会中断,但导致恐慌的特定客户端会获得问题的完整日志,并且我们的服务器也记录了回溯。我们可以在不影响任何其他客户端正常运行的情况下诊断问题。
现在,无论处理请求时发生了什么,我们都存储了一个有效的hyper::Response
响应值,即使发生灾难性事件,我们也可以将其传回给调用者。我们可以安全地使用Ok::<_, Infallible>
,表示控制不可能在到达该点时失败。即使发生了可怕的事情,我们的服务器也始终会生成响应并继续运行。真是太棒了。
测试
最后,我们要确保能够实现自动化测试。我将演示一个 404 错误处理程序的测试,其中包含构建一个健壮测试套件所需的所有部分:
#[cfg(test)]
mod test {
use super::*;
use flate2::write::ZlibDecoder;
use pretty_assertions::assert_eq;
use select::{document::Document, predicate::Name};
#[tokio::test]
async fn test_four_oh_four() {
let mut request = hyper::Request::builder()
.method(http::Method::GET)
.uri("/nonsense")
.body(hyper::Body::empty())
.unwrap();
let context = Arc::new(RwLock::new(Todos::new()));
request.extensions_mut().insert(Arc::clone(&context));
let response = handle(request).await;
assert_eq!(response.status(), http::status::StatusCode::NOT_FOUND);
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
let mut decoder = ZlibDecoder::new(Vec::new());
decoder.write_all(&body).unwrap();
let uncompressed = decoder.finish().unwrap();
let result = String::from_utf8(uncompressed).unwrap();
let document = Document::from(result.as_str());
let message = document.find(Name("h1")).next().unwrap().text();
assert_eq!(message, "NOT FOUND!".to_owned());
}
}
Tokio 提供了一个#[tokio::test]
用于构建异步测试的宏。我们可以使用它hyper::Request
来构造请求并构建上下文,就像在服务器中一样。因为我们的处理程序只是一个从 aRequest
到 a 的函数Response
,所以我们可以非常简单地测试它let response = handle(request).await;
:
我们首先断言状态码与预期相符,然后解码响应体。我们使用ZlibDecoder
读取响应体并将其解压缩回字符串。
一旦我们获得了字符串响应,就可以使用该select.rs
库来确保其结构符合我们的意图。在本例中,我们断言收到了一个h1
文本主体与字符串匹配的元素NOT FOUND!
。
鳍
此实现在几个基本方面比上一个版本有所改进。新async/.await
的语法使我们能够编写与目标紧密匹配的代码,而不会陷入Box
es 和Future
s 的泥潭。我们避免了污染全局作用域,并使用Request
自身来优雅地处理对应用状态的并发访问,甚至处理灾难性的请求处理错误,而不会影响其他客户端。我们的处理程序易于测试。此应用程序为构建更复杂的应用程序提供了坚实、高性能的基础,并将依赖项、编译时间和包大小保持在最低限度。
虽然有很多不同的 Rust 框架可以用来构建 Web 应用,但你还是应该问问自己,是否真的需要这种级别的抽象。对于许多 Web 服务需求来说,自己组装模块并不会复杂太多,而且你仍然可以控制应用程序的构建方式。如果我们想要解构请求 URI,我们已经可以做到了。如果我们返回 JSON,我们只需要创建一个实现 的结构体serde::Serialize
。
这里的结论与以前相同:当最终目标足够简单时,为什么不使用简单的工具呢?
封面照片由Danny Howe在Unsplash上拍摄
鏂囩珷鏉由簮锛�https://dev.to/deciduously/oops-i-did-it-againi-made-a-rust-web-api-and-it-was-not-that-difficult-3kk8