将Axum、Hyper、Tonic和Tower结合起来,实现混合网络/gRPC应用—第一部分

619 阅读14分钟

我在Rust中玩过各种网络服务器库和框架,发现了它们的各种优势和劣势。最近,我组装了一个名为Zehut的FP Complete解决方案(我下次再写博客介绍),它需要结合一个Web前端和gRPC服务器。我使用了Hyper、Tonic和一个我放在一起的叫做routetype的最小库。它起作用了,但我感到很不满意。直接用Hyper工作,即使有最小的routetype 层,也感觉太临时了。

当我最近看到Axum的发布时,它似乎说到了我的许多需求,特别是叫出了Tonic支持。我决定做一个实验,用Axum取代我所使用的直接Hyper+routetype 的用法。总的来说,这个方法是可行的,但是(就像我已经做的routetype 工作一样)涉及到一些围绕Hyper和Tower APIs的棘手的业务。

我一直想为Hyper+Tower写一些博文/教程/经验报告,现在已经有一段时间了。所以我决定利用这个机会来了解这四个库(Tower、Hyper、Axum和Tonic),其具体目标是创建混合的Web/gRPC应用程序。结果发现,这里的信息比我预期的要多。为了方便阅读,我把它分成了四个系列的博文:

  1. 今天的文章:Tower的概述
  2. 了解Hyper,以及使用Axum的初步经验
  3. 为gRPC客户端/服务器演示Tonic
  4. 如何将Axum和Tonic服务结合成一个单一的服务,9月20日即将发布

让我们潜心研究!

什么是塔?

我们旅程的第一站是塔式箱。引用文档的内容,它简洁地说明了这一点。

Tower提供了一个简单的核心抽象,即Service 特质,它代表了一个异步函数,接收一个请求并返回一个响应或一个错误。这个抽象可以用来为客户端和服务器建模。

这听起来相当直截了当。如果用Haskell语法来表达,我可能会说Request -> IO Response ,利用IO 同时处理错误处理和异步I/O的事实。但是Service 特质必然比这个简化的签名更复杂:

pub trait Service<Request> {
    type Response;
    type Error;

    // This is what it says in the generated docs
    type Future: Future;

    // But this more informative piece is in the actual source code
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn poll_ready(
        &mut self,
        cx: &mut Context<'_>
    ) -> Poll<Result<(), Self::Error>>;
    fn call(&mut self, req: Request) -> Self::Future;
}

Service 是一个特质,在它能处理的 的类型上设置了参数。在Tower中没有关于HTTP的具体内容,所以 s可能是很多不同的东西。甚至在Hyper中,一个利用Tower的HTTP库,我们将看到至少有两种不同类型的 ,我们关心的是。Request Request Request

无论如何,这里的两个相关类型是直接的:ResponseError 。将参数化的RequestResponseError 结合起来,我们基本上就有了我们所关心的Service 的所有信息。

但这并不是Rust关心的所有信息。为了提供异步调用,我们需要提供一个Future 。而编译器需要知道我们将要返回的Future 的类型。作为一个程序员,这并不是真正有用的信息,但在特质中的async已经有很多痛点了。

最后,那最后两个方法呢?它们的存在是为了让Service 本身是异步的。我花了很长时间才完全理解了这一点。我们有两个不同的异步行为的组成部分。

  • Service 可能不会立即准备好处理一个新的传入请求。例如(来自于 poll_ready 的文档),服务器目前可能是有容量的。你需要检查poll_ready ,以了解Service 是否准备好接受一个新的请求。然后,当它准备好时,你使用call 来启动对一个新的Request 的处理。
  • 对请求本身的处理是异步的,返回一个Future ,它可以被轮询/等待。

其中的一些复杂性可以被隐藏起来。例如,你可以使用一个特质对象(又称类型清除),而不是为Future ,给出一个具体类型。再次从文档中窃取,下面是一个完全有效的关联类型,用于Future

type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;

然而,这对动态调度来说会产生一些开销。

最后,这两层的异步行为往往是不必要的。很多时候,我们的服务器总是准备好处理一个新进入的Request 。在野外,你会经常看到硬编码的代码,认为服务总是准备好的。本节最后一次引用这些文档的内容:

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    Poll::Ready(Ok(()))
}

这并不是说在我们的Service ,请求处理是同步的。它是说,请求的接受总是立即成功的。

随着两层异步处理的进行,同样也有两层错误处理。接受新请求可能会失败,处理新请求也可能会失败。但正如你在上面的代码中所看到的,有可能硬编码的东西总是成功的,Ok(()) ,这对poll_ready 是相当常见的。当处理请求本身也不会失败时,使用 Infallible(和最终的 never 类型)作为Error 的关联类型是一个好的选择。

假的网络服务器

这些都是比较抽象的,这也是理解Tower的部分问题(至少对我来说)。让我们通过实现一个假的Web服务器和假的Web应用程序来使它更具体。我的Cargo.toml 文件看起来像:

[package]
name = "learntower"
version = "0.1.0"
edition = "2018"

[dependencies]
tower = { version = "0.4", features = ["full"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"

我已经把完整的源代码上传到Gist,但让我们来看看这个例子。首先,我们定义一些辅助类型来表示HTTP请求和响应值:

pub struct Request {
    pub path_and_query: String,
    pub headers: HashMap<String, String>,
    pub body: Vec<u8>,
}

#[derive(Debug)]
pub struct Response {
    pub status: u32,
    pub headers: HashMap<String, String>,
    pub body: Vec<u8>,
}

接下来,我们要定义一个函数,run 它:

  • 接受一个网络应用程序作为参数
  • 无限循环
  • 产生假的Request
  • 打印出它从应用中得到的Response 值。

第一个问题是:你如何表示这个网络应用?它将是Service 的一个实现,RequestResponse 类型是我们上面定义的那些。我们不需要知道太多关于错误的信息,因为我们将简单地打印它们。这些部分是很容易的:

pub async fn run<App>(mut app: App)
where
    App: Service<crate::http::Request, Response = crate::http::Response>,
    App::Error: std::fmt::Debug,

但有一个最后的约束我们需要考虑到。我们希望我们的假网络服务器能够并发地处理请求。为了做到这一点,我们将使用tokio::spawn 来创建处理请求的新任务。因此,我们需要能够将请求处理发送到一个单独的任务,这将需要Send'static 的边界。至少有两种不同的方法来处理这个问题:

  • 在主任务中克隆App 的值,并将其发送给被催生的任务
  • 在主任务中创建Future ,并将其发送到被催生的任务中。

做出这个决定会有不同的运行时影响,比如主请求接受循环是否会被应用程序报告说它不能接受请求而阻塞。我决定采用后一种方法。所以我们在run 上又多了一个约束:

App::Future: Send + 'static,

run 的主体被包裹在loop 内,以便模拟一个无限运行的服务器。首先,我们睡一会儿,然后生成我们新的假请求:

tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;

let req = crate::http::Request {
    path_and_query: "/fake/path?page=1".to_owned(),
    headers: HashMap::new(),
    body: Vec::new(),
};

接下来,我们使用ready 方法(来自ServiceExt 扩展特性)来检查服务是否准备好接受一个新的请求:

let app = match app.ready().await {
    Err(e) => {
        eprintln!("Service not able to accept requests: {:?}", e);
        continue;
    }
    Ok(app) => app,
};

一旦我们知道我们可以提出另一个请求,我们就得到我们的Future ,生成任务,然后等待Future 完成:

let future = app.call(req);
tokio::spawn(async move {
    match future.await {
        Ok(res) => println!("Successful response: {:?}", res),
        Err(e) => eprintln!("Error occurred: {:?}", e),
    }
});

就这样,我们有了一个假的网络服务器现在是时候实现我们的假网络应用了。我把它叫做DemoApp ,并给它一个原子计数器以使事情变得稍微有趣:

#[derive(Default)]
pub struct DemoApp {
    counter: Arc<AtomicUsize>,
}

接下来是Service 的实现。前几位相对容易:

impl tower::Service<crate::http::Request> for DemoApp {
    type Response = crate::http::Response;
    type Error = anyhow::Error;
    #[allow(clippy::type_complexity)]
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    // Still need poll_ready and call
}

Request Response 被设置为我们定义的类型,我们将使用奇妙的 crate的 类型,我们将使用一个trait对象来实现 。我们将实现一个 ,它总是准备好迎接一个 。anyhow Error Future poll_ready Request

fn poll_ready(
    &mut self,
    _cx: &mut std::task::Context<'_>,
) -> Poll<Result<(), Self::Error>> {
    Poll::Ready(Ok(())) // always ready to accept a connection
}

最后,我们进入到我们的call 方法。我们将实现一些逻辑来增加计数器,在25%的时间内失败,并在其他情况下回传用户的请求,并添加一个X-Counter 响应头。让我们看看它的运行情况:

fn call(&mut self, mut req: crate::http::Request) -> Self::Future {
    let counter = self.counter.clone();
    Box::pin(async move {
        println!("Handling a request for {}", req.path_and_query);
        let counter = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        anyhow::ensure!(counter % 4 != 2, "Failing 25% of the time, just for fun");
        req.headers
            .insert("X-Counter".to_owned(), counter.to_string());
        let res = crate::http::Response {
            status: 200,
            headers: req.headers,
            body: req.body,
        };
        Ok::<_, anyhow::Error>(res)
    })
}

有了所有这些,在我们的假网络服务器上运行我们的假网络应用是很容易的:

#[tokio::main]
async fn main() {
    fakeserver::run(app::DemoApp::default()).await;
}

app_fn

上面的代码有一点让人特别不满意,那就是写一个网络应用需要多少仪式。我需要创建一个新的数据类型,为它提供一个Service 实现,并在所有的Pin<Box<Future>> 业务上做手脚,使事情保持一致。我们DemoApp 的核心逻辑被埋在call 方法中。如果能提供一个帮助器,让我们更容易地定义事物,那就更好了。

你可以在Gist上查看完整的代码。但让我们在这里讨论一下。 我们将实现一个新的辅助函数app_fn ,它需要一个闭包作为其参数。该闭包将接收一个Request 值,然后返回一个Response 。但我们要确保它异步返回Response 。所以我们需要我们的调用看起来像:

app_fn(|req| async { some_code(req).await })

这个app_fn 函数需要返回一个类型,它提供了我们的Service 实现。让我们把它叫做AppFn 。把这两件事放在一起,我们就得到了:

pub struct AppFn<F> {
    f: F,
}

pub fn app_fn<F, Ret>(f: F) -> AppFn<F>
where
    F: FnMut(crate::http::Request) -> Ret,
    Ret: Future<Output = Result<crate::http::Response, anyhow::Error>>,
{
    AppFn { f }
}

到目前为止,一切都很好。我们可以通过app_fn 上的边界看到,我们将接受一个Request ,并返回一些Ret 类型,而Ret 必须是一个Future ,并产生一个Result<Response, Error> 。为此实现Service 并不太糟糕:

impl<F, Ret> tower::Service<crate::http::Request> for AppFn<F>
where
    F: FnMut(crate::http::Request) -> Ret,
    Ret: Future<Output = Result<crate::http::Response, anyhow::Error>>,
{
    type Response = crate::http::Response;
    type Error = anyhow::Error;
    type Future = Ret;

    fn poll_ready(
        &mut self,
        _cx: &mut std::task::Context<'_>,
    ) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(())) // always ready to accept a connection
    }

    fn call(&mut self, req: crate::http::Request) -> Self::Future {
        (self.f)(req)
    }
}

我们有与app_fn 相同的边界,相关的类型ResponseError 是直接的,而poll_ready 与以前一样。第一个有趣的部分是type Future = Ret; 。我们之前走的是特质对象的路线,这比较啰嗦,而且性能较差。这一次,我们已经有了一个类型,Ret ,它代表了我们函数的调用者将要提供的Future 。我们可以在这里简单地使用它,这真的很好!

call 方法利用调用者提供的函数,为每个传入的请求产生一个新的Ret/Future 值,并将其交回给Web服务器进行处理。

最后,我们的main 函数现在可以将我们的应用逻辑作为一个闭包嵌入其中。这看起来像:

#[tokio::main]
async fn main() {
    let counter = Arc::new(AtomicUsize::new(0));
    fakeserver::run(util::app_fn(move |mut req| {
        // need to clone this from the closure before moving it into the async block
        let counter = counter.clone();
        async move {
            println!("Handling a request for {}", req.path_and_query);
            let counter = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            anyhow::ensure!(counter % 4 != 2, "Failing 25% of the time, just for fun");
            req.headers
                .insert("X-Counter".to_owned(), counter.to_string());
            let res = crate::http::Response {
                status: 200,
                headers: req.headers,
                body: req.body,
            };
            Ok::<_, anyhow::Error>(res)
        }
    }))
    .await;
}

侧面说明:额外的克隆

根据我自己和其他人的经验,上面的let counter = counter.clone(); 可能是上述代码中最棘手的部分。写出这样的代码实在是太容易了:

let counter = Arc::new(AtomicUsize::new(0));
fakeserver::run(util::app_fn(move |_req| async move {
    let counter = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    Err(anyhow::anyhow!(
        "Just demonstrating the problem, counter is {}",
        counter
    ))
}))
.await;

这看起来非常合理。我们把counter 移到闭包中,然后使用它。然而,编译器对我们并不太满意:

error[E0507]: cannot move out of `counter`, a captured variable in an `FnMut` closure
   --> src\main.rs:96:57
    |
95  |       let counter = Arc::new(AtomicUsize::new(0));
    |           ------- captured outer variable
96  |       fakeserver::run(util::app_fn(move |_req| async move {
    |  _________________________________________________________^
97  | |         let counter = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    | |                       -------
    | |                       |
    | |                       move occurs because `counter` has type `Arc<AtomicUsize>`, which does not implement the `Copy` trait
    | |                       move occurs due to use in generator
98  | |         Err(anyhow::anyhow!(
99  | |             "Just demonstrating the problem, counter is {}",
100 | |             counter
101 | |         ))
102 | |     }))
    | |_____^ move out of `counter` occurs here

这是一个略显混乱的错误信息。在我看来,它之所以令人困惑,是因为我使用的格式化。而我使用这种格式的原因是:(1)rustfmt ,(2)Hyper docs鼓励这样做。让我重新编排一下,然后解释一下这个问题:

let counter = Arc::new(AtomicUsize::new(0));
fakeserver::run(util::app_fn(move |_req| {
    async move {
        let counter = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Err(anyhow::anyhow!(
            "Just demonstrating the problem, counter is {}",
            counter
        ))
    }
}))

问题在于,在app_fn 的参数中,我们有两个不同的控制结构:

  • 一个移动闭包,它取得了counter 的所有权并产生一个Future
  • 一个async move 块,它拥有 的所有权。counter

问题是,只有一个counter 的值。它首先被移动到闭包中。这意味着我们不能在闭包之外再次使用counter ,而我们并没有尝试这样做。一切都很好。第二件事是,当那个闭包被调用时,counter 的值将从闭包中移到async move 块中。这也很好,但它只适用于一次。如果你试图第二次调用这个闭包,就会失败,因为counter 已经被移动了。因此,这个闭包是一个FnOnce ,而不是一个FnFnMut

而这正是这里的问题所在。正如我们在上面看到的,我们至少需要一个FnMut ,作为我们对假网络服务器的参数。这是有直观意义的:我们将多次调用我们的应用程序请求处理函数,而不是只有一次。

解决这个问题的方法是在闭合体中克隆counter ,但在将其移入async move 块之前。这就很容易了:

fakeserver::run(util::app_fn(move |_req| {
    let counter = counter.clone();
    async move {
        let counter = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Err(anyhow::anyhow!(
            "Just demonstrating the problem, counter is {}",
            counter
        ))
    }
}))

这是一个非常微妙的观点,希望这个演示能让它变得更清晰。

连接和请求

在我们上面的假网络服务器中,有一个简化的过程。一个真正的HTTP工作流从一个新的连接开始,然后处理来自该连接的请求流。换句话说,我们不是只有一个服务,而是真正需要两个服务:

  1. 一个像我们上面的服务,它接受Requests,并返回Responses
  2. 一个接受连接信息并返回上述服务之一的服务

同样,靠着一些简洁的Haskell语法,我们会想要:

type InnerService = Request -> IO Response
type OuterService = ConnectionInfo -> IO InnerService

或者,借用一些漂亮的Java术语,我们想创建一个服务工厂,它将接受一些连接信息并返回一个请求处理服务。或者,用Tower/Hyper的术语来说,我们有一个服务,和一个制造服务。如果你像我一样对Hyper教程感到困惑,这可能最终解释了为什么 "Hello World "需要同时调用service_fnmake_service_fn

总之,为了复制这个概念,对上面的代码进行必要的修改太详细了,但我提供了一个Gist,显示了一个AppFactoryFn

就这样......我们终于玩够了假的东西,我们可以深入到现实生活中的Hyper代码中。万岁!