<译>集成Axum, Hyper, Tonic, and Tower 做web/gRPC混合应用-02

这是系列的第二篇,总的四个部分如下:

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

如果您还没有准备好,我建议您先阅读文章的第一部分。

快速回顾
  • Tower 提供了一个Serivce trait,是一个基本的从请求到响应的异步函数。
  • Service 是参数化的请求类型,并且有一个Response的关联类型。
  • 并且还有Error和Future两个关联类型。
  • Serivce 允许在检查服务是否接受新的请求和处理请求都可以是异步。
  • 一个web应用最终会有两种异步请求和响应行为:

    • 内部:一个服务接受HTTP请求并返回响应。
    • 外部:一个服务接受新的网络连接并返回内部服务。

记住上面实现,让我看看Hyper实现方式。

Hyper中服务

既然我们对Tower有些了解,是时候让我们投入到Hyper奇妙世界。上面我们看到的我们直接用Hyper实现一次,但是Hyper有一些额外的麻烦需要处理:

  • Request 和 Response 类型的参数化是通过request/response主体表示的。
  • 有很多的特性(traits)和参数化的公共API,很多参考文档中并没有提及而且很多表述不明确。

Hyper是遵从创建者模式初始化Http服务,来替代我们以前假的服务示例中run函数。提供配置参数后,你就创建了一个活跃的服务通过构建器提供的serve方法。不必深究太多,让我门看看文档中函数签名:

pub fn serve(self, new_service: S) -> Server
where
    I: Accept,
    I::Error: Into>,
    I::Conn: AsyncRead + AsyncWrite + Unpin + Send + 'static,
    S: MakeServiceRef,
    S::Error: Into>,
    B: HttpBody + 'static,
    B::Error: Into>,
    E: NewSvcExec,
    E: ConnStreamExec<>::Future, B>,

有很多参数限制,并且很多文档表述不清晰。希望我们能搞清楚这些。但是目前,让我们从简单的开始:Hyper主页的"Hello world"示例:

use std::{convert::Infallible, net::SocketAddr};
use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};

async fn handle(_: Request) -> Result, 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);
    }
}

以下遵从我们以上创建的相同模式:

  • handle 是一个从Http请求到响应的异步函数,并且如果失败返回Infallible错误值。

    • Request和Response都是一Body作为参数,Body是默认的Http请求体表示。
  • handle 被service_fn 包装并返回Service>类型,这就是上面提及的app_fn。
  • 我们调用make_service_fn,类似上文app_factory_fn,返回需要的Service<&AddrStream>(我们简要说下&AddrStream):

    • 我们并不关心&AddrStream值,所以可以忽略。
    • 从内部函数 make_service_fn 返回的值必须是Future,所以我们要用async包起来。
    • Future的返回值是一个Result 类型,所以包装返回Ok
    • 我们需要帮助编译器一个小忙给Infallible做下类型标注,否则编译器不知道Ok(service_fn(handle))类型表达式。

至少有三个理由可说明用这种层次的抽象写一个web应用是一件痛苦的事情:

  • 手动管理这些碎片化的服务是一种煎熬。
  • 高层次辅助函数的方式是非常少的,比如,请求体Json化。
  • 任何种类错误在我们类型中可能导致非常大的非本地错误信息,致使调试困难。

所以,我们稍后很高兴从Hyper转到Axum,但是现在,让我们继续探索Hyper。

规避service_fnmake_service_fn

我觉得最有帮助的是当尝试Hyper实现简单的app时候,不使用service_fn和make_service_fn。所以现在让我们来实现它。我们将创建一个简单的计数器app(如果不如预料,那就失败了) 。我们需要两种不同的数据类型:一个是是"app factory",一个是app自身。让我们从app自身开始:

struct DemoApp {
    counter: Arc,
}

impl Service> for DemoApp {
    type Response = Response;
    type Error = hyper::http::Error;
    type Future = Ready>;

    fn poll_ready(&mut self, _cx: &mut std::task::Context) -> Poll> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, _req: Request) -> Self::Future {
        let counter = self.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        let res = Response::builder()
            .status(200)
            .header("Content-Type", "text/plain; charset=utf-8")
            .body(format!("Counter is at: {}", counter).into());
        std::future::ready(res)
    }
}

这个实现用了标准库的std::future::Ready结构体创建了一个理解Ready的Future。换句话说,我们的应用没有异步行为。我设置了一个Error 关联类型hyper::http::Error。例如:你向header提交了一个非有效的字符串,比如非ASCII字符,便会导致错误。因为我们阅读了很多次,poll_ready仅仅是等待处理下一个请求。

DemoAppFactory的实现并没有多少不同:

struct DemoAppFactory {
    counter: Arc,
}

impl Service<&AddrStream> for DemoAppFactory {
    type Response = DemoApp;
    type Error = Infallible;
    type Future = Ready>;

    fn poll_ready(&mut self, _cx: &mut std::task::Context) -> Poll> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, conn: &AddrStream) -> Self::Future {
        println!("Accepting a new connection from {:?}", conn);
        std::future::ready(Ok(DemoApp {
            counter: self.counter.clone()
        }))
    }
}

我们给Service传了不同的参数,这次是&AddrStream。我确实最初发现命名很迷糊。在Tower中,Service以Request作为参数。DemoApp中,是Request。DemoAppFactory中,参数是&AddrStream,记住,一个Service仅仅是生成一个从输入到输出的可失败的异步函数。如参数可能是Request或者是&AddrStream,也或者是全部。

相似的,除了DemoApp之外,"response"这里也不是HTTP响应。我又发现是用术语"输入"和"输出"来避免请求和响应的名称重载更容易一些。

最后,我们的main函数和以前"hello word"的例子一样:

#[tokio::main]
async fn main() {
    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));

    let factory = DemoAppFactory {
        counter: Arc::new(AtomicUsize::new(0)),
    };

    let server = Server::bind(&addr).serve(factory);

    if let Err(e) = server.await {
        eprintln!("server error: {}", e);
    }
}

如果你想更深入理解,我推荐你在这个应用上面添加一些异步行为。你如何修改Future?如果你用trait 对象,如何精确定住这个对象?

现在是时候深入我一致避免的话题了。

traits深入理解

然我们重新回到上面serve函数签名:

pub fn serve(self, new_service: S) -> Server
where
    I: Accept,
    I::Error: Into>,
    I::Conn: AsyncRead + AsyncWrite + Unpin + Send + 'static,
    S: MakeServiceRef,
    S::Error: Into>,
    B: HttpBody + 'static,
    B::Error: Into>,
    E: NewSvcExec,
    E: ConnStreamExec<>::Future, B>,

写这篇文章之前,我从没有尝试深入理解这些绑定。所以这对我们来说是一个冒险!!!(或许最后以我的一些文件PRs结束)然我们以类型变量开始。总的来说,我们有四个类型变量:两个在impl块,两个在方法上:

  • I 表示新的链接流
  • E 代表执行器
  • S 表示我们将运行的service。使用我们上面的术语就是"app factory"。用Tower/Hyper术语,便是"make service"
  • B 是service 返回的response体(是app 不是 "app factory")
I:Accept

I 需要实现Accept trait,代表可以从一些资源中接受新的链接的能力。唯一实现这个的是装箱的AddrIncoming,可以从SocketAddr创建。实际上,这就是 Server::bind功能。

Accept有两个关联类型,Error 必须可以转化成错误对象或者Into>.这是每一个(几乎?)我们看到的错误关联类型的需求,所以此后我们略过。我们需要能够将任何发生错误转换成统一的表达式。

Conn关联类型代表的是私人链接。鉴于AddrIncoming,Conn的关联类型是addrStream. 为了通信必须实现AsyncRead和AsyncWrite traits,为了不同线程间传递,必须实现send trait和‘static 以及 unpin。Unpin 的需求需要深入到栈存储,我确实不知道这么驱动的。

S: MakeServiceRef

很多traits 没有出现在公共文档中,MakeServiceRef 便是其中之一。这似乎是故意的。下面资料:

Just a sort-of "trait alias" of MakeService, not to be implemented by anyone, only used as bounds.

你是否困惑我们为什么得到AddrStream的引用?这个trait 具有转变的能力。总的来看,绑定S: MakeServiceRef意味着:

  • S 必须是Service
  • S 必须接受输入类型&I::Conn
  • 并且转换为新的Service为输出
  • 新的Service接受Request为输入,Response为输出

当我们讨论时:ResBody必须实现HttpBody.正如你所想的,上面提到的Body结构体要实现了HttpBody.还有很多实现的。实际上,当我们使用Tonic和gRPC时,其他响应体也需要我们去处理。

NewSvcExec and ConnStreamExec

E参数的默认值是Exec,在生成的文档中并没有出现。但是你可以在这些资料中看到。Exec的主要思想是指定如何派生任务,默认使用的是tokio::spawn;

我不太确定所有这些是如何实现的。但是我相信标题中两个trait允许对链接service 和 请求service 使用不同的任务处理。

Axum初试

Axum是一个新的web框架,它是撰写这完整的博客文章初衷。我们不再像上面那样直接使用Hyper处理,而是使用Axum重新实现我们的计数器web服务。我们用 axum = "0.2",crate文档提供了Axum很好的概述,我不打算在这里复制信息。相反,这里是我重写代码,我们将分析以下几个主要部分:

use axum::extract::Extension;
use axum::handler::get;
use axum::{AddExtensionLayer, Router};
use hyper::{HeaderMap, Server, StatusCode};
use std::net::SocketAddr;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;

#[derive(Clone, Default)]
struct AppState {
    counter: Arc,
}

#[tokio::main]
async fn main() {
    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));

    let app = Router::new()
        .route("/", get(home))
        .layer(AddExtensionLayer::new(AppState::default()));

    let server = Server::bind(&addr).serve(app.into_make_service());

    if let Err(e) = server.await {
        eprintln!("server error: {}", e);
    }
}

async fn home(state: Extension) -> (StatusCode, HeaderMap, String) {
    let counter = state
        .counter
        .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    let mut headers = HeaderMap::new();
    headers.insert("Content-Type", "text/plain; charset=utf-8".parse().unwrap());
    let body = format!("Counter is at: {}", counter);
    (StatusCode::OK, headers, body)
}

首先,我不讨论AddExtensionLayer/Extension,这是在我们应用中分享共享状态的方式。这和我们分析Hyper和Tower不相关,所以我提供了个链接 link to the docs demonstrating how this works。有趣的是,你会发现这个实现依赖的是Tower提供的中间件,所以,两者并没有安全分离开。

总之,回到我们讨论的内容。在main函数方法内,我们现在用路由的概念去构建我们的应用。

let app = Router::new()
    .route("/", get(home))
    .layer(AddExtensionLayer::new(AppState::default()));

本质上说就是:"当请求路径'/'时,调用home函数,并添加中间件处理拓展的事情"。home函数使用提取器得到AppState,并返回(StatusCode, HeaderMap, String) 元祖作为响应。在Axum中,任何实现了IntoResponse trait的可作为处理函数的返回值。

无论如何,我们app的值是路由。但是路由不能直接被Hyper运行。相反,我们需要转换为MakeService,幸运的是,这比较简单:我们可以调用 app.into_make_service().转换。让我们看看方法签名:

impl Router {
    pub fn into_make_service(self) -> IntoMakeService
    where
        S: Clone;
}

让我走的更远一些:

pub struct IntoMakeService { /* fields omitted */ }

impl Service for IntoMakeService {
    type Response = S;
    type Error = Infallible;
    // other stuff omitted
}

Router 是一个可以生成S服务的值,ntoMakeService将获取某种类型的连接信息T,并异步生成该服务S。因为Error是Infallible 类型,我们知道是不可能失败的。正如我们所说的异步,阅读IntoMakeService 的service实现,我们看到了一种熟悉的模式:

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

fn call(&mut self, _target: T) -> Self::Future {
    future::MakeRouteServiceFuture {
        future: ready(Ok(self.service.clone())),
    }
}

并且,可以注意到作为链接信息T的值并没有任何绑定和信息。IntoMakeService 仅仅扔掉了链接信息(如果你想进一步了解,请查看 into_make_service_with_connect_info.)换句话说:

  • Router 是一个可以让我们添加路由和中间件的类型
  • 可以转换 Router 成IntoMakeService
  • 但是IntoMakeService只是对S的包装以符合Hyper的需求
  • 因此,真正的重点是S

所以,S的类型来自哪里?这取决于router和layer 你的调用。比如 get 方法的签名如下:

pub fn get(handler: H) -> OnMethod
where
    H: Handler,

pub struct OnMethod { /* fields omitted */ }

impl Service> for OnMethod
where
    H: Handler,
    F: Service, Response = Response, Error = Infallible> + Clone,
    B: Send + 'static,
{
    type Response = Response;
    type Error = Infallible;
    // and more stuff
}

get方法返回OnMethod值,OnMethod是一个Service,接受Request作为参数并且返回Response.由于方法体表述有很多有意思的逻辑,我们最终会深入讨论。但是基于我们对Hyper和Tower新的理解,这里的类型也变的不那么晦涩难懂,实际上,反而更容易理解。

关于上面例子的最后一点需要说明的是,Axum 可以直接和Hyper协同合作,包括Server 类型。Axum可以从Hyper重新导出许多内容,如果需要,您可以直接从Hyper使用这些类型。换句话说,Axum非常接近底层库,只是在上面提供了一些便利。这也是为什么我对深入研究Axum感到非常兴奋的原因之一。

综上所述:

  • Tower提供了从输入到输出的抽象可异步的函数,可能会失败,被称之为service.
  • HTTP 服务有两个层面的服务,低层次的服务是从HTTP请求到HTTP响应,高层次的服务是根据链接信息返回低层次的服务
  • Hyper有很多额外的特性,有些是可见的,有些是不可见的,这允许更多的通用性,也让事情变得更复杂。
  • Axum位于Hyper的上层,为许多常见情况提供了一个更易于使用的接口。它通过提供Hyper期望看到的相同类型的service 来实现这一点。围绕HTTP主体表示进行了一系列改变。

下一段旅程:让我们来看下一个构建Hyper 服务的库,我们将在下一篇进行介绍。

阅读原文

你可能感兴趣的:(rust后端)