2

这段时间以来,我一直想为Hyper+Tower 写一些博客文章/教程/体验报告,所以我打算利用这个机会深入学习下面四个库(Tower,Hyper,Axum和 Tonic),目标是能创建混合 web/gRPC 应用。事实证明信息超出我的期待,为了方便阅读 ,我把博文分成了四个章节:

  • Tower 概览
  • Hyper原理和Axum初试
  • Tonic gRPC客户端和服务端示范
  • 如何把Axum和Tonic服务集成到一个应用中

让我们开始我们的旅程吧!

什么是Tower

我们旅程的第一站是Tower包(crate),引用文档,简要陈述如下:

Tower provides a simple core abstraction, the Service trait, which represents an asynchronous function taking a request and returning either a response or an error. This abstraction can be used to model both clients and servers.

Tower 提供一个简单的核心抽象,即Service trait, 它表示接受请求并返回一个响应或错误的异步函数,这个抽象可用于对客户端和服务端建模。

这听起来挺简单,用Hackell解释,我可能会说Request-> IO Response ,促使IO同时处理错误处理和异步IO的这一事实。但是Service trait 比简化的签名更复杂:

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

    type Future: Future;

    //详细信息可查看源码
    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 是一个trait, 可以处理参数化的请求类型。 Tower中关于HTTP没有什么特别之处,所以请求中可能有很多不同的东西。即使Tower 的http库嵌入到Hyper中,也有至少两种需要我们关心的不同类型请求。

总之,这里有两种关联类型是简单的:Response 和 Error。结合参数化的Request和Response以及Error. 我们基本了解了一个Serivce 我们关心的信息。

但是并不是所有信息Rust都关心。我们需要提交一个Future,才能提交一个异步请求。而且编译器需要知道Future将来会有返回值。作为程序员这不是一个有用的信息,但是在trait中围绕异步代码有很多惨痛的教训。

最后,关于最后两个方法呢?它们允许服务本身是异步化,我花了很长时间才完全明白这一点,我们这里有两个异步行为的组件:

  • Service 可能不会立即执行一个新的请求。比如(例子来源于文档中的poll_ready),服务端可能已经饱和,你需要去检查poll_ready来证明服务端是不是可以处理新的请求,如果已经可以处理,需要调用call 来处理新的请求。
  • 处理请求也是异步的,返回一个可以被polled/awaitd的Future。

一些复杂性可以隐藏。比如,与其给具体的Future类型,你可以用trait对象。再从文档中偷个例子,下面是完全有效的Future关联类型:

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

然而,这会动态调用从而带来额外的开销。

最终,这两种异步行为往往是不必要的。大多数情况,我们的服务都是在等待处理新的请求。在正常情况下,你经常看到硬编码的代码 服务总是处于等待状态。再在这个部分最后引用一次文档:

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

这并不能说明在我们服务中请求处理的同步的,这意味着请求总是被立刻成功接受。

伴随着两层异步处理,也有相似的错误处理。接受新的请求可能失败,处理新的请求也有可能失败。但是正如上面代码所看到的,可以通过使用硬编码的方式总是成功返回Ok(()),这中方式在poll_ready 非常常见,当处理请求本身时也不能失败,使用正确可靠的(比如最终的的never 类型) 作为错误的关联类型是一种好的调用。

模拟网站服务器

(起码对于我来说)理解Tower的部分问题,相对比较抽象。让我们通过实现假的服务器和假的网站应用使其更为具体。我的Cargo.toml 配置如下:

[package]
name = "learntower"
version = ""
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函数:

  • 接受一个web应用作为参数。
  • 无限循环
  • 生成假的请求参数
  • 打印出从应用获得的相应值

第一个问题是:怎样代表一个web应用?将是Service的一个实现, Request和Response类型就是我们上面定义的。我们不需要知道太多错误,因为我们只是简单的打印,这个部分非常简单:

pub async fn Run<App>(mut app:App)
    where 
        App:Service<crate::http::Requst,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);
        contine;
    }
    Ok(app) => app,
}

一旦我们知道可以发起另一个请求,我们获得我们Future,创建一个任务,然后等待我们Future处理完成:

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

就像那样,我们有了一个web服务器。现在让我们实现一个web应用。我起名叫DemoApp, 并且包含一个原子计数字段让事情变得稍微有趣一些:

#[derive(Default)]
pub struct DempApp {
    counter: Arc<AtomicUsize>, //Arc 线程安全
}

接下来让我们实现服务,前面一些相当简单:

impl tower::Service<crate::http::Request> for DempApp {
    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 按照我们定义的设置/获取。我们将使用便利的anyhow 错误类型,并且我们将为future 使用trait 对象。我们将实现一个一直等待Request的poll_ready 方法:

fn poll_ready(
        &mut self,
        _cx: &mut std::task::Context<'_>,
    ) -> Poll<Result<(),Self::Error>> {
        Poll::ready(Ok(())) //总是等待处理请求
    }

最后,我们实现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)
    })
}

所有准备就绪,运行我们假的web应用在我们假的web服务上非常简单:

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

app_fn

上面代码有一点非常不满意的是写一个web 应用太繁琐。我需要创建一个新的数据类型,为他提供服务实现,并与所有的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类型必须实现一个返回Result<Response,Error> 类型的Future。为AppFn实现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(()))
    }

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

我们对app_fn 做了同样的限制,关联类型Request和Response非常直接,poll_ready 和以前一样。第一个有趣的地方是 type Future = Ret;我们之前定义的是trait 对象,比较冗长且低性能。此时,我们已经定义了类型Ret,代表调用我们的函数可以提供一个这样的Future,这样可以是使用更为简单。

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

最后,我们的主要功能现在可以将应用程序逻辑作为闭包嵌入其中。这看起来像:

#[tokio::main]
async fn main() {
    let counter = Arc::new(AtomicUsize::new(0));
    fakeserver::run(util::app_fn(move |mut req| {
        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;
}

备注:额外的克隆

从我自己经历和我交谈过的其他人痛苦经历来看,让 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;

这样看起来是合理的。我们把couture移入闭包并且使用,但是,编译器对我们不满意:

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 鼓励使用。让我们美化格式下再解释这个问题:

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 参数有两种不同的控制结构:

  • 一个move 的闭包,获得counter所有权并返回一个future
  • 一个async move 块,取得counter 的所有权

问题是只有一个计数器,首先被移动到闭包,意味着我们不能在闭包外面使用计数器,我们不会去尝试这样做,一切都好。第二件事 是当调用闭包,计数器会从闭包移动到async 块。这样依然没问题,但是只是初次没问题。如果你尝试调用闭包第二次就会失败,因为计数器已经移动出去。因此,这个闭包事FnOnce,不是Fn/FnMut.

这就是出问题的原因。像我们上面看到的那样,我们需要至少一个FnMut 作为我们的参数传入假的web服务,这样很直观:我们将调用我们应用请求处理函数多次,而不是一次。

修改这个问题就是在闭包内部clone计数器。但是要在移动到move async 块之前,这就很简单了:

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
        ))
    }

这的确是一个非常微妙的观点。希望这个例子能帮助更好理解。

连接和请求

以上是一个简化的web服务器。真正的http 工作流是从一个新的连接开始,处理一个请求流结束。换句话说,与其一个服务,我们实际上需要两个服务:

  • 一个服务就像我们上面实现的,接受Request, 返回Response
  • 一个服务接受我们连接的请求信息,并返回上面的一个服务

再次,我们用haskell 表示如下:

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

或者,借用一些漂亮的Java术语,我们希望创建一个工厂服务,它将获取一些连接信息并返回处理请求的服务。或者,使用 Toer/Hyper 术语,我们有服务也可以制造服务,如果你曾经也像我一样被Hyper的教程搞糊涂了,这也许最终解释为什么“hello world ”既需要一个service_fn,也需要一个make_service_fn 调用。

无论如何,为了复制这个概念,上面的代码的所有必要更改都太详细了,但是我提供了一个展示AppFactoryFn的要点。

因此,我们已经玩假的服务时间够长了,让我们深入真实的Hyper编码把,庆祝!

下一篇

目前为止,我们只是使用了tower,下一篇博文即将发布,我们将尝试理解Hyper并尝试Axum(tokio正统框架😂);

阅读原文


道之
10 声望1 粉丝