跳过框架:使用 Hyper 构建简单的 Rust API
简介
在本文中,我将介绍如何使用hyper HTTP 库创建一个小型 Web API。该应用是todo-mvp的一个实现,正如 David Wickes 在他的文章中介绍的那样:
编辑:我已经为 Rust 2021 重新实现了这一点。你或许应该阅读那篇文章。
该项目的规定之一todo-mvp
是,每个实现都应避免使用“框架”,而应仅使用库。“框架”是一个模糊的术语,而且并不总是容易界定,因此我遵循了一条经验法则:如果 crate 文档将自己称为框架,则它不适合使用。这大大缩小了可用的工具范围,但事实证明,hyper
这足以构建一个这样的应用程序,而不会带来太多额外的复杂性。
Hyper 是一个底层 HTTP 实现。它提供客户端和服务器类型,并公开了其所基于的底层Tokio异步运行时。我们还会引入一些其他的 crate,但仍然不具备完整的功能框架。
设置
您需要获取稳定的 Rust 工具链。如果需要,请参阅rustup。安装完成后,启动一个新的可执行项目:
$ cargo new simple-todo
$ cd simple-todo
$ cargo run
Compiling simple-todo v0.1.0 (/home/ben/code/simple-todo)
Finished dev [unoptimized + debuginfo] target(s) in 1.30s
Running `target/debug/simple-todo`
Hello, world!
用你喜欢的编辑器打开新simple-todo
目录。在深入代码之前,我们先定义一下依赖项。Cargo.toml
如下所示:
[package]
name = "simple-todo"
version = "0.1.0"
authors = ["You <you@yourcoolsite.com>"]
edition = "2018"
[dependencies]
futures = "0.1"
hyper = "0.12"
lazy_static = "1.3"
log = "0.4"
pretty_env_logger = "0.3"
serde = "1.0"
serde_derive = "1.0"
tera = "0.11"
[dependencies.uuid]
features = ["serde", "v4"]
version = "0.7"
除了 之外hyper
,我们还使用了几个额外的辅助 crate。简而言之,futures
提供零成本异步编程原语,lazy_static
允许我们定义static
需要运行时初始化的 s(例如Vec::new()
),log
并pretty_env_logger
提供日志记录,serde
和serde_derive
用于序列化,tera
从类似 Jinja 的模板文件执行 HTML 模板,并且Uuid
提供 uuid!这些 crate 提供了我们的基本构建块。
这是一个小程序,将完全在 中定义main.rs
。打开该文件,println!
从模板中删除该语句cargo new
,然后启动日志记录:
入口点
fn main() {
pretty_env_logger::init();
}
请注意,在 Rust 2018 中,extern crate
除非需要导入宏,否则我们可以省略声明。
在设置服务器之前,我们需要一个要绑定的地址。在本演示中,我们直接硬编码即可。在 init 函数的正下方添加以下代码:
let addr = "127.0.0.1:3000".parse().unwrap();
该parse()
方法将返回一个std::net::SocketAddr
。
接下来,我们需要在文件顶部引入一些导入内容:
use futures::{future, Future, Stream};
use hyper::{
client::HttpConnector, rt, service::service_fn, Body, Client, Request,
Response, Server
};
现在我们可以完成了main()
:
rt::run(future::lazy(move || {
// create a Client for all Services
let client = Client::new();
// define a service containing the router function
let new_service = move || {
// Move a clone of Client into the service_fn
let client = client.clone();
service_fn(move |req| router(req, &client))
};
// Define the server - this is what the future_lazy() we're building will resolve to
let server = Server::bind(&addr)
.serve(new_service)
.map_err(|e| eprintln!("Server error: {}", e));
println!("Listening on http://{}", addr);
server
}));
这不会完全进行类型检查 - 为了使其编译,您可以为router
我们在service_fn
调用中引用的函数添加上面的以下存根:
fn router(req: Request<Body>, _client: &Client<HttpConnector>) -> Box<Future<Item = Response<Body>, Error = Box<dyn std::error::Error + Send + Sync>> + Send> {
unimplemented!()
}
这部分内容比较丰富,我们来分解一下。整个代码都在一个调用中rt:run()
。这里rt
代表运行时,指的是默认的 Tokio 运行时。我们的程序会立即启动并进入这个异步环境。
在内部,我们调用future::lazy
,它接受一个闭包并返回一个Future
解析该闭包的函数。其余定义都在这个闭包中,包含几个步骤。我们构建了一个 hyper Client
,它能够发出 HTTP 请求。
下一步是创建一个Service
。这是一个 trait,代表一个异步函数,用于从请求到响应——这正是我们的 Web 服务器需要处理的!我们不需要手动实现这个 trait,而是自己定义这个函数(在本例中是router()
),然后使用service_fn
辅助函数将其转换为Service
。接下来,我们需要做的就是创建Server
本身,绑定到我们提供的地址,并让它提供这项服务。
差不多就是这样了。现在我们的工作就是定义响应,不管有没有框架,这都是你的工作!
路由器
不过,首先看一下这个router()
签名。很恶心吧?在导入语句下面添加几个类型别名:
type GenericError = Box<dyn std::error::Error + Send + Sync>;
type ResponseFuture = Box<Future<Item = Response<Body>, Error = GenericError> + Send>;
fn router(req: Request<Body>, _client: &Client<HttpConnector>) -> ResponseFuture {
unimplemented!()
}
每当我们想要将响应返回给连接时,它都必须以Response
wrapped up in a Future
wrapped up in a 的形式返回Box
——这样更容易输入绝对是个好主意!现在我们可以开始定义路由了。在开始之前,请先将Body
、Method
和添加StatusCode
到导入列表中hyper
。
我们可以利用 Rust 模式匹配来正确调度响应:
match (req.method(), req.uri().path()) {
(&Method::GET, "/") | (&Method::GET, "index.html") => unimplemented!(),
_ => four_oh_four(),
}
我们同时匹配方法和路径——对“/”的 POST 请求不会匹配此分支。我们可以在此处添加任意数量的匹配分支,任何没有对应分支的传入请求都将获得响应four_oh_four()
:
static NOTFOUND: &[u8] = b"Oops! Not Found";
fn four_oh_four() -> ResponseFuture {
let body = Body::from(NOTFOUND);
Box::new(future::ok(
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(body)
.unwrap(),
))
}
正如预期的那样,这个函数返回一个ResponseFuture
。对于 404 页面,我们只需使用这个静态值作为主体即可。future::ok
返回一个立即解析的 Future,我们使用构建器模式来构建一个Response
。为了最大程度地保证正确性,我们hyper
设置了枚举StatusCode
!
HTML
为了构建索引页,我们将使用tera,它提供了类似 Jinja2 的 HTML 模板。我们需要一个宏,并将其设置为静态,因此需要一些声明:
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate tera;
// ...
use tera::{Context, Tera};
该todo-mvp
项目要求每个实现都使用相同的模板。这篇文章与 Jinja2 或 HTML 无关,因此我建议你从这里下载并保存到simple-todo/templates/index.html
。你还需要保存todo.css
到simple-todo/src/resource/todo.css
。
Tera 使用起来非常简单。添加以下代码片段:
lazy_static! {
pub static ref TERA: Tera = compile_templates!("templates/**/*");
}
瞧,模板就完成了。现在我们可以这样写index()
:
fn index() -> ResponseFuture {
let mut ctx = Context::new();
let body = Body::from(TERA.render("index.html", &ctx).unwrap().to_string());
Box::new(future::ok(Response::new(body)))
}
要将数据注入 Tera 模板,请将其放入 中tera::Context
,并将模板路径和此上下文传递给render()
。然后,我们只需将结果字符串包装在 中即可ResponseFuture
!别忘了更新 中的匹配分支,router()
以调用此函数而不是unimplemented!()
。
状态
但是有一个问题——我们实际上并没有在上下文中放入任何数据!如果你运行这个程序,它会在加载这个模板时崩溃,并抱怨todos
在todosLen
上下文中找不到 和 。这是一个非常合理的抱怨,它们不在那里。
在像这样的异步应用程序中跟踪状态可能是一个复杂的问题,但这就是 Rust。我们必须std::sync
尝试一下!具体来说,我们将使用Arc
和的组合RwLock
来安全地跨线程存储待办事项,而无需真正思考它。
首先,导入附加内容:
#[macro_use]
extern crate serde_derive;
// ...
use std::sync::{Arc, RwLock};
use uuid::Uuid;
现在,Todo 类型:
#[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(),
}
}
}
该new_v4()
方法将为任何新的 随机生成一个唯一标识符Todo
。同时为所有待办事项列表添加一个新的类型别名:
type Todos = Arc<RwLock<Vec<Todo>>>;
现在我们可以在lazy_static!
块中实例化它:
lazy_static! {
pub static ref TERA: Tera = compile_templates!("templates/**/*");
pub static ref TODOS: Todos = Arc::new(RwLock::new(Vec::new()));
}
我们需要一些辅助函数来操作列表:
fn add_todo(t: Todo) {
let todos = Arc::clone(&TODOS);
let mut lock = todos.write().unwrap();
lock.push(t);
}
fn remove_todo(id: Uuid) {
let todos = Arc::clone(&TODOS);
let mut lock = todos.write().unwrap();
// find the index
let mut idx = lock.len();
for (i, todo) in lock.iter().enumerate() {
if todo.id == id {
idx = i;
}
}
// remove that element if found
if idx < lock.len() {
lock.remove(idx);
}
}
fn toggle_todo(id: Uuid) {
let todos = Arc::clone(&TODOS);
let mut lock = todos.write().unwrap();
for todo in &mut *lock {
if todo.id == id {
todo.done = !todo.done;
}
}
}
当你调用 时Arc::clone()
,它会创建一个指向相同数据的新指针,并增加其引用计数。然后,我们获取底层 的写锁RwLock
,之后就可以安全地操作Vec
内部内容了。使用这些辅助函数,我们的路由处理程序可以以保证线程安全的方式安全地操作状态。最后,我们可以index()
在定义 之后立即构建上下文ctx
:
let todos = Arc::clone(&TODOS);
let lock = todos.read().unwrap();
ctx.insert("todos", &*lock);
ctx.insert("todosLen", &(*lock).len());
处理程序
现在运行应用程序并指向您的浏览器localhost:3000
应该显示给定的 HTML(无样式表)。
应用程序的其余部分很简单。我们只需要填写其余的处理程序。例如,要加载缺少的样式表,你需要一个新的 match 分支:
(&Method::GET, "/static/todo.css") => stylesheet(),
以及构建响应的函数:
fn stylesheet() -> ResponseFuture {
let body = Body::from(include_str!("resource/todo.css"));
Box::new(future::ok(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/css")
.body(body)
.unwrap(),
))
}
没什么奇怪的!每个待办事项列表操作也都有一个端点:
(&Method::POST, "/done") => toggle_todo_handler(req),
(&Method::POST, "/not-done") => toggle_todo_handler(req),
(&Method::POST, "/delete") => remove_todo_handler(req),
(&Method::POST, "/") => add_todo_handler(req),
这些处理程序都采用相同的格式:
fn add_todo_handler(req: Request<Body>) -> ResponseFuture {
Box::new(
req.into_body()
.concat2() // concatenate all the chunks in the body
.from_err() // like try! for Result, but for Futures
.and_then(|whole_body| {
let str_body = String::from_utf8(whole_body.to_vec()).unwrap();
let words: Vec<&str> = str_body.split('=').collect();
add_todo(Todo::new(words[1]));
redirect_home()
}),
)
}
这有点复杂。我们需要读取请求,然后对其进行处理。在本例中,存储在 中的请求主体str_body
类似于item=TodoName
。有更强大的解决方案,但我只是拆分了 ,=
并在结果上调用add_todo
函数将其添加到列表中。然后我们重定向到主页,每次返回主页时index()
都会调用 ,它会根据当前应用程序状态重建 HTML!toggle
和remove
处理程序几乎相同,只是调用了相应的函数。
重定向也并不奇怪:
fn redirect_home() -> ResponseFuture {
Box::new(future::ok(
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.body(Body::from(""))
.unwrap(),
))
}
这看起来就像你在任何工具包中写的那样。该应用还包含一些 SVG:
(&Method::GET, path_str) => image(path_str),
fn image(path_str: &str) -> ResponseFuture {
let path_buf = PathBuf::from(path_str);
let file_name = path_buf.file_name().unwrap().to_str().unwrap();
let ext = path_buf.extension().unwrap().to_str().unwrap();
match ext {
"svg" => {
// build the response
let body = {
let xml = 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"),
_ => "",
};
Body::from(xml)
};
Box::new(future::ok(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml")
.body(body)
.unwrap(),
))
}
_ => four_oh_four(),
}
}
这就是全部内容。要添加更多路由,只需添加一个新的匹配分支,router()
并编写一个返回 的函数即可ResponseFuture
。这是一个可靠、高性能的基础,并且可以轻松以各种方式扩展,因为您不受任何特定预定模式的约束。总而言之,hyper
对于简单的用例而言,使用简单的框架而不是更高级的框架编写服务器并不会降低其人机工程学,并且可以大大减少应用程序的开销。我目前最喜欢的框架是,actix-web
但它的二进制文件太大,冷编译所需的时间也太长,这几乎是荒谬的。当最终目标足够简单时,为什么不使用简单的工具呢?
完整的实现可以在这里找到。
文章来源:https://dev.to/decidously/skip-the-framework-build-a-simple-rust-api-with-hyper-4jf5