使用Rust、async和hyper的所有权难题介绍

669 阅读15分钟

我在Rust中编写的大多数Web服务都使用了actix-web 。最近,我需要写一些东西,以提供一些反向代理功能。我对hyper-powered HTTP客户端库比较熟悉(尤其是reqwest )。我决定这将是一个很好的时机,在服务器端也再次尝试使用hyper。理论上说,在客户端和服务器之间有匹配的RequestResponse 类型会很好地工作。当然,它也是如此。

在这个过程中,我最终得到了一个通过闭包和异步块争夺所有权的有趣例子。这是我在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_fnservice_fn 之间的区别并不清楚。这里有两个不同的东西,我们试图创建:

  • 一个MakeService ,它接收一个&AddrStream ,并返回一个Service
  • 一个Service ,它接收一个Request ,并返回一个 。Response

这掩盖了一些细节,例如:

  • 错误处理
  • 所有的东西都是异步的(Futures无处不在)
  • 所有的东西都是用通用的traits来表达的。

为了帮助我们进行 "掩饰",hyper提供了两个方便的函数来创建MakeServiceService 值,即make_service_fnservice_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-loggerlog 箱中,以产生输出。我自己在编写代码的时候也是这样做的,切换到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;

我们接下来有三个常数。SCHEMEHOST 是非常不言自明的:硬编码的目的地。

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 替换SCHEMEHOST 。你可能想知道 "为什么是&* 而不是& 。"如果没有&* ,你会得到一个错误信息。

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。

我们想在多个不同的MakeServiceService 实例中共享我们的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 的故事会随着时间的推移而得到改善。