我在Rust中编写的大多数Web服务都使用了actix-web 。最近,我需要写一些东西,以提供一些反向代理功能。我对hyper-powered HTTP客户端库比较熟悉(尤其是reqwest )。我决定这将是一个很好的时机,在服务器端也再次尝试使用hyper。理论上说,在客户端和服务器之间有匹配的Request 和Response 类型会很好地工作。当然,它也是如此。
在这个过程中,我最终得到了一个通过闭包和异步块争夺所有权的有趣例子。这是我在Rust培训课程中经常提到的一个话题,是我在学习Rust时最难学的东西。因此,我想发表一篇博文,展示这些疯狂的案例之一,是值得的。
Cargo.toml
如果你想一起玩,你应该先用cargo new 。我在我的[dependencies] 。Cargo.toml
[dependencies]
hyper = "0.13"
tokio = { version = "0.2", features = ["full"] }
log = "0.4.11"
env_logger = "0.8.1"
hyper-tls = "0.4.3"
我还用Rust 1.47.0版本进行编译。如果你愿意,你可以在你的rust-toolchain 中添加1.47.0 。最后,我的完整的Cargo.lock ,以Gist的形式提供。
基本网络服务
要开始使用超能网络服务,我们可以直接使用超能主页上的例子。
use std::{convert::Infallible, net::SocketAddr};
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
async fn handle(_: Request<Body>) -> Result<Response<Body>, Infallible> {
Ok(Response::new("Hello, World!".into()))
}
#[tokio::main]
async fn main() {
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
这值得解释一下,因为至少在我看来,make_service_fn 和service_fn 之间的区别并不清楚。这里有两个不同的东西,我们试图创建:
- 一个
MakeService,它接收一个&AddrStream,并返回一个Service - 一个
Service,它接收一个Request,并返回一个 。Response
这掩盖了一些细节,例如:
- 错误处理
- 所有的东西都是异步的(
Futures无处不在) - 所有的东西都是用通用的
traits来表达的。
为了帮助我们进行 "掩饰",hyper提供了两个方便的函数来创建MakeService 和Service 值,即make_service_fn 和service_fn 。这两个函数都会将一个闭包转换为它们各自的类型。然后,MakeService 闭包可以返回一个Service 值,而MakeService 值可以提供给hyper::server::Builder::serve 。让我们从上面的代码中得到更具体的信息。
async fn handle(_: Request<Body>) -> Result<Response<Body>, Infallible> {...}
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
handle 函数接收一个Request<Body> 并返回一个Future<Output=Result<Response<Body, Infallible>>> 。Infallible 是一种很好的说法:"这里不可能发生错误。"起作用的类型签名要求我们使用一个Result ,但从道德上讲,Result<T, Infallible> 等同于T 。
service_fn 将这个 值转换为一个 值。这个新值实现了所有适当的特征,以满足 和 的要求。我们把这个新的 包在它自己的 中,忽略输入的 值,并把所有这些传递给 。 现在是一个可以传递给 的值,我们就有了 "你好,世界!"handle Service make_service_fn serve Service Result<_, Infallible> &AddrStream make_service_fn make_svc serve
如果所有这些对于 "你好,世界 "来说似乎有点复杂,你可能会理解为什么有很多框架建立在hyper之上,以使它更容易操作。不管怎么说,开始吧!
初始反向代理
接下来,我们要修改我们的handle 函数,以执行一个反向代理,而不是返回 "Hello, World!"文本。在这个例子中,我们将硬编码https://www.fpcomplete.com 作为这个反向代理的目标站点。为了实现这一点,我们需要:
- 根据传入的
Request's request headers和路径,构建一个Request,但以www.fpcomplete.com服务器为目标。 - 从支持TLS的hyper构建一个
Client值 - 执行请求
- 将
Response作为响应返回handle - 引入错误处理
我也要转移到env-logger 和log 箱中,以产生输出。我自己在编写代码的时候也是这样做的,切换到RUST_LOG=debug 是一个很好的调试方法。(当我在做这件事的时候,我忘记了我需要创建一个支持TLS的特殊Client )。
因此,从头开始!我们现在有以下use 语句。
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Client, Request, Response, Server};
use hyper_tls::HttpsConnector;
use std::net::SocketAddr;
我们接下来有三个常数。SCHEME 和HOST 是非常不言自明的:硬编码的目的地。
const SCHEME: &str = "https";
const HOST: &str = "www.fpcomplete.com";
接下来我们有一些不应该被转发到目标服务器的HTTP请求头。在反向代理中,这种对HTTP头的黑名单方法足够好用。一般来说,遵循白名单的方法可能是一个更好的主意。在任何情况下,这六个头都有可能改变传输层的行为,因此不能从客户端转发。
/// HTTP headers to strip, a whitelist is probably a better idea
const STRIPPED: [&str; 6] = [ "content-length", "transfer-encoding", "accept-encoding", "content-encoding", "host", "connection",];
而接下来我们有一个相当模板化的错误类型定义。Request在向目标服务器执行HTTP请求时,我们可以生成一个hyper::Error ,而在构建新的hyper::http::Error 。可以说,如果后一种错误发生,我们应该简单地惊慌失措,因为它表示程序员的错误。但我决定把它作为自己的错误变量。所以这里有一些模板!
#[derive(Debug)]
enum ReverseProxyError {
Hyper(hyper::Error),
HyperHttp(hyper::http::Error),
}
impl From<hyper::Error> for ReverseProxyError {
fn from(e: hyper::Error) -> Self {
ReverseProxyError::Hyper(e)
}
}
impl From<hyper::http::Error> for ReverseProxyError {
fn from(e: hyper::http::Error) -> Self {
ReverseProxyError::HyperHttp(e)
}
}
impl std::fmt::Display for ReverseProxyError {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl std::error::Error for ReverseProxyError {}
有了所有这些,我们终于可以开始写我们的handle 函数了。
async fn handle(mut req: Request<Body>) -> Result<Response<Body>, ReverseProxyError> {
}
我们将突变传入的Request ,使其具有我们的新目标,然后将其传递给目标服务器。这就是在客户端和服务器上使用hyper的好处:不需要在改变主体或头的表示上做文章。我们要做的第一件事是剥离任何STRIPPED 的请求头。
let h = req.headers_mut();
for key in &STRIPPED {
h.remove(*key);
}
接下来,我们将通过组合来构建新的请求URI。
- 硬编码的方案 (
https) - 硬编码的授权 (
www.fpcomplete.com) - 来自传入请求的路径和查询
let mut builder = hyper::Uri::builder()
.scheme(SCHEME)
.authority(HOST);
if let Some(pq) = req.uri().path_and_query() {
builder = builder.path_and_query(pq.clone());
}
*req.uri_mut() = builder.build()?;
如果req.uri().path_and_query() 是None ,那么恐慌将是适当的,但按照我的习惯,如果可能的话,我将避免恐慌。接下来,为了慎重起见,让我们加入一点调试输出。
log::debug!("request == {:?}", req);
现在我们可以构建我们的Client 值来执行HTTPS请求。
let https = HttpsConnector::new();
let client = Client::builder().build(https);
最后,让我们执行请求,记录响应,并返回响应。
let response = client.request(req).await?;
log::debug!("response == {:?}", response);
Ok(response)
我们的main 函数看起来和我们之前的很相似。我加入了初始化env-logger ,默认为info 水平的输出,如果服务器产生任何错误,则修改程序为abort 。
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, ReverseProxyError>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
log::info!("Server started, bound on {}", addr);
if let Err(e) = server.await {
log::error!("server error: {}", e);
std::process::abort();
}
}
浪费的客户端
这个程序的问题是,它在每个传入的请求上都会构建一个全新的Client 。这很昂贵。相反,我们希望能在main ,并在每个请求中重复使用它,从而产生一次Client 。这就是所有权的问题。在这个过程中,让我们不再使用const,而是将客户端、方案和主机捆绑到一个新的struct 。
struct ReverseProxy {
scheme: String,
host: String,
client: Client<HttpsConnector<hyper::client::HttpConnector>>,
}
接下来,我们要把handle 从一个独立的函数改为ReverseProxy 上的一个方法。(我们也可以为handle 传递一个对ReverseProxy 的引用,但这感觉更成文)。
impl ReverseProxy {
async fn handle(&self, mut req: Request<Body>) -> Result<Response<Body>, ReverseProxyError> {
...
}
}
然后,在handle 中,我们可以用&*self.scheme 和&*self.host 替换SCHEME 和HOST 。你可能想知道 "为什么是&* 而不是& 。"如果没有&* ,你会得到一个错误信息。
error[E0277]: the trait bound `hyper::http::uri::Scheme: std::convert::From<&std::string::String>` is not satisfied
--> src\main.rs:59:14
|
59 | .scheme(&self.scheme)
| ^^^^^^ the trait `std::convert::From<&std::string::String>` is not implemented for `hyper::http::uri::Scheme`
这是其中一个例子,deref coercion的魔力似乎失效了。就个人而言,我更喜欢使用self.scheme.as_str() ,而不是&*self.scheme ,这样更明确,但&*self.scheme 可能更符合习惯。
总之,handle 中的最后一个变化是删除了let https = ...; 和let client = ...; 语句,而用以下语句来构建我们的响应。
let response = self.client.request(req).await?;
这样,我们的handle 方法就完成了,我们可以把精力放在真正的难题上:main 函数本身。
简单的部分
简单的部分很好:构造一个ReverseProxy 值,并将make_svc 提供给serve 。
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let https = HttpsConnector::new();
let client = Client::builder().build(https);
let rp = ReverseProxy {
client,
scheme: "https".to_owned(),
host: "www.fpcomplete.com".to_owned(),
};
// here be dragons
let server = Server::bind(&addr).serve(make_svc);
log::info!("Server started, bound on {}", addr);
if let Err(e) = server.await {
log::error!("server error: {}", e);
std::process::abort();
}
}
中间的部分才是困难所在。以前,这段代码看起来像:
let make_svc = make_service_fn(|_conn| async {
Ok::<_, ReverseProxyError>(service_fn(handle))
});
我们不再有一个handle 函数。绕过这个小谜团,最初看起来并不那么糟糕。我们将创建一个闭包作为service_fn 的参数。
let make_svc = make_service_fn(|_conn| async {
Ok::<_, ReverseProxyError>(service_fn(|req| {
rp.handle(req)
}))
});
虽然这看起来很吸引人,但它却完全辜负了生命期。
error[E0597]: `rp` does not live long enough
--> src\main.rs:90:13
|
88 | let make_svc = make_service_fn(|_conn| async {
| ____________________________________-------_-
| | |
| | value captured here
89 | | Ok::<_, ReverseProxyError>(service_fn(|req| {
90 | | rp.handle(req)
| | ^^ borrowed value does not live long enough
91 | | }))
92 | | });
| |_____- returning this value requires that `rp` is borrowed for `'static`
...
101 | }
| - `rp` dropped here while still borrowed
这些值的生命期中没有任何东西告诉我们ReverseProxy 值会超过服务的生命期。我们不能简单地在我们的闭包中借用一个对ReverseProxy 的引用。相反,我们需要将ReverseProxy 的所有权转移到闭包中。
let make_svc = make_service_fn(|_conn| async {
Ok::<_, ReverseProxyError>(service_fn(move |req| {
rp.handle(req)
}))
});
注意在闭包的前面添加了move 。不幸的是,这并不奏效,而是给了我们一个混乱的错误信息。
error[E0495]: cannot infer an appropriate lifetime for autoref due to conflicting requirements
--> src\main.rs:90:16
|
90 | rp.handle(req)
| ^^^^^^
|
note: first, the lifetime cannot outlive the lifetime `'_` as defined on the body at 89:47...
--> src\main.rs:89:47
|
89 | Ok::<_, ReverseProxyError>(service_fn(move |req| {
| ^^^^^^^^^^
note: ...so that closure can access `rp`
--> src\main.rs:90:13
|
90 | rp.handle(req)
| ^^
= note: but, the lifetime must be valid for the static lifetime...
note: ...so that the type `hyper::proto::h2::server::H2Stream<impl std::future::Future, hyper::Body>` will meet its required lifetime bounds
--> src\main.rs:94:38
|
94 | let server = Server::bind(&addr).serve(make_svc);
| ^^^^^
error: aborting due to previous error
与其尝试解析这个,不如退后一步,重新评估,然后再试一次。
这么多的层次!
记得在这篇文章的开头。我曾详细介绍过这样一个过程:MakeService ,它将为每个新进入的连接运行,而Service ,它将为现有连接上的每个新请求运行。到目前为止,我们的写法是,当我们第一次处理一个请求时,该请求处理程序将消耗ReverseProxy 。这意味着,我们将为该连接上的每个后续请求提供一个使用后移动。我们也会为我们收到的每一个后续的连接有一个use-after-move。
我们想在多个不同的MakeService 和Service 实例中共享我们的ReverseProxy 。由于这将在多个系统线程中发生,最直接的处理方式是将我们的ReverseProxy 裹在一个Arc 。
let rp = std::sync::Arc::new(ReverseProxy {
client,
scheme: "https".to_owned(),
host: "www.fpcomplete.com".to_owned(),
});
现在,我们需要在适当的时候玩玩cloneing这个Arc 。特别是,我们需要克隆两次:一次在make_service_fn 闭包内,另一次在service_fn 闭包内。这将确保我们不会将ReverseProxy 的值移出闭包的环境,并且我们的闭包可以保持为FnMut 而不是FnOnce 。
为了实现这一点,我们需要通过适当地使用move 来说服编译器移动ReverseProxy 的所有权,而不是借用对一个具有不同生命周期的值的引用。这就是有趣的地方。让我们通过一系列的修改,直到我们得到最后的心灵震撼。
添加移动
回顾一下,我们将从这段代码开始。
let rp = std::sync::Arc::new(ReverseProxy {
client,
scheme: "https".to_owned(),
host: "www.fpcomplete.com".to_owned(),
});
let make_svc = make_service_fn(|_conn| async {
Ok::<_, ReverseProxyError>(service_fn(|req| {
rp.handle(req)
}))
});
我尝试的第一件事是在第一个async 块内添加一个rp.clone() 。
let make_svc = make_service_fn(|_conn| async {
let rp = rp.clone();
Ok::<_, ReverseProxyError>(service_fn(|req| {
rp.handle(req)
}))
});
这并不奏效,大概是因为我需要像这样在最初的封闭和async 块上粘贴一些moves。
let make_svc = make_service_fn(move |_conn| async move {
let rp = rp.clone();
Ok::<_, ReverseProxyError>(service_fn(|req| {
rp.handle(req)
}))
});
不幸的是,这还是不行,给了我一个错误信息。
error[E0507]: cannot move out of `rp`, a captured variable in an `FnMut` closure
--> src\main.rs:88:60
|
82 | let rp = std::sync::Arc::new(ReverseProxy {
| -- captured outer variable
...
88 | let make_svc = make_service_fn(move |_conn| async move {
| ____________________________________________________________^
89 | | let rp = rp.clone();
| | --
| | |
| | move occurs because `rp` has type `std::sync::Arc<ReverseProxy>`, which does not implement the `Copy` trait
| | move occurs due to use in generator
90 | | Ok::<_, ReverseProxyError>(service_fn(|req| {
91 | | rp.handle(req)
92 | | }))
93 | | });
| |_____^ move out of `rp` occurs here
我花了一些时间才搞清楚发生了什么。事实上,我还不能百分之百确定我已经弄明白了。但我相信正在发生的事情是:
- 闭包抓取了
rp的所有权(好)。 async块抓取了rp的所有权,这似乎是好的,但并不是- 在
async块内,我们复制了一个rp - 当
async块被丢弃时,它对原始rp的所有权也被丢弃。 - 由于
rp被移出了闭包,闭包现在是一个FnOnce,不能被第二次调用。
这可不妙啊!事实证明,解决这个问题的技巧并不难。不要在async 块中获取所有权。相反,在闭包中克隆rp ,在async 块之前。
let make_svc = make_service_fn(move |_conn| {
let rp = rp.clone();
async move {
Ok::<_, ReverseProxyError>(service_fn(|req| {
rp.handle(req)
}))
}
});
呜呼!一个clone 了。这段代码仍然不能编译,但我们已经接近了。接下来要做的改变很简单:在内部闭包上粘贴一个move 。
let make_svc = make_service_fn(move |_conn| {
let rp = rp.clone();
async move {
Ok::<_, ReverseProxyError>(service_fn(move |req| {
rp.handle(req)
}))
}
});
这也失败了,但回到我们之前的描述,很容易就能看出原因。我们仍然需要第二个clone ,以确保我们没有将ReverseProxy 的值移出封闭。做这个改动很容易,但不幸的是,并没有完全解决我们的问题。这段代码:
let make_svc = make_service_fn(move |_conn| {
let rp = rp.clone();
async move {
Ok::<_, ReverseProxyError>(service_fn(move |req| {
let rp = rp.clone();
rp.handle(req)
}))
}
});
仍然给了我们错误信息:
error[E0515]: cannot return value referencing local variable `rp`
--> src\main.rs:93:17
|
93 | rp.handle(req)
| --^^^^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `rp` is borrowed here
这里发生了什么?
你的Future是不是借用了我的参考资料?
同样,参考介绍,我提到service_fn 参数必须返回一个Future<Output...> 。这是一个关于impl Trait 方法的例子。我以前写过关于所有权和植入式特质的博客。围绕这个组合有一些痛点。而我们已经遇到了其中的一个。
我们的handle 方法的返回类型并不表明实现Future 的是什么底层类型。该底层实现可能会选择保留传递到handle 方法中的引用。这将包括对&self 的引用。这意味着如果我们在闭包之外返回那个Future ,那么引用可能会超过这个值。
我可以想到两种方法来解决这个问题,尽管可能还有更多的方法。我将向你展示的第一种方法并不是我喜欢的,但却是能更清楚地表达这个想法的方法。我们的handle 方法需要一个对ReverseProxy 的引用。但是如果它不需要引用,而是通过移动来接收ReverseProxy ,就不会有引用意外地出现在Future 。
克隆ReverseProxy 本身是很昂贵的。幸运的是,我们有另一个选择:传入Arc<ReverseProxy>!
impl ReverseProxy {
async fn handle(self: std::sync::Arc<Self>, mut req: Request<Body>) -> Result<Response<Body>, ReverseProxyError> {
...
}
}
在不改变handle 方法或main 函数内的任何代码的情况下,这就可以正确地编译和表现。但正如我所说的。我不太喜欢它。这限制了我们的handle 方法的通用性。感觉像是把复杂性放在了错误的地方。(也许你会不同意,说这是更好的解决方案。这很好,我很想听听大家的想法)。
相反,另一种可能性是在main 内引入一个async move 。这将取得Arc<ReverseProxy> 的所有权,并确保它的寿命与该async move 块本身产生的Future 一样长。这个解决方案看起来像这样。
let make_svc = make_service_fn(move |_conn| {
let rp = rp.clone();
async move {
Ok::<_, ReverseProxyError>(service_fn(move |req| {
let rp = rp.clone();
async move { rp.handle(req).await }
}))
}
});
我们需要在async 块内调用.await ,以确保我们不会返回一个未来的未来。但有了这个改变,一切都能正常工作。我对这一点并不感到非常兴奋。这感觉就像一个丑陋的黑客。我没有任何建议,但我希望将来会有对impl Trait 所有权故事的改进。
最后一项改进
最后一个调整。我们最初把async move 放在第一个rp.clone() 之后。这有助于使错误信息更容易理解。但事实证明,那个move 并没有做任何有用的事情。内部closure 上的move 已经迫使克隆的rp 的移动。所以我们可以通过删除一个move 来简化我们的代码。
let make_svc = make_service_fn(move |_conn| {
let rp = rp.clone();
async {
Ok::<_, ReverseProxyError>(service_fn(move |req| {
let rp = rp.clone();
async move { rp.handle(req).await }
}))
}
});
总结
我希望这是个有趣的所有权之旅。如果这看起来过于复杂,请牢记几件事:
- 在用Rust编写服务器端代码时,强烈建议使用更高级别的Web框架。
- 我们最终用大约100行代码实现了相当复杂的东西。
- 希望围绕所有权和
impl Trait的故事会随着时间的推移而得到改善。