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

986 阅读13分钟

这是关于使用Tower、Hyper、Axum和Tonic将Web和gRPC服务结合成一个单一服务的系列文章中的第二篇。完整的四部分是:

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

如果你还没有看过这个系列的第一篇文章,我建议你去看看。

快速回顾

  • Tower提供了一个Service 特质,它基本上是一个从请求到响应的异步函数
  • Service 是在请求类型上的参数化,并且有一个相关的类型,用于处理请求。Response
  • 它也有一个相关的Error 类型,和一个相关的Future 类型
  • Service 允许在检查服务是否准备好接受请求,以及处理请求时的异步行为。
  • 一个网络应用最终会有两套异步请求/响应行为
    • 内部:接受HTTP请求并返回HTTP响应的服务
    • 外部:一个接受传入的网络连接并返回内部服务的服务

考虑到这一点,让我们看看Hyper。

Hyper中的服务

现在我们已经有了Tower的经验,是时候深入到Hyper的具体世界了。我们上面看到的大部分内容将直接适用于Hyper。但Hyper还有一些额外的曲线球需要处理:

  • RequestResponse 类型都是通过请求/响应体的表示法来进行参数化的。
  • 在公共API中有一堆额外的特征和类型参数化,有些根本没有出现在文档中,还有很多不清楚的地方

取代我们在之前的假服务器例子中的run 函数,Hyper遵循一个构建者模式来初始化HTTP服务器。Server 在提供了配置值后,你从你的Builder ,用 serve方法创建一个活动的。只是现在要把它拿出来,这是公共文档中的serve 的类型签名。

pub fn serve<S, B>(self, new_service: S) -> Server<I, S, E>
where
    I: Accept,
    I::Error: Into<Box<dyn StdError + Send + Sync>>,
    I::Conn: AsyncRead + AsyncWrite + Unpin + Send + 'static,
    S: MakeServiceRef<I::Conn, Body, ResBody = B>,
    S::Error: Into<Box<dyn StdError + Send + Sync>>,
    B: HttpBody + 'static,
    B::Error: Into<Box<dyn StdError + Send + Sync>>,
    E: NewSvcExec<I::Conn, S::Future, S::Service, E, NoopWatcher>,
    E: ConnStreamExec<<S::Service as HttpService<Body>>::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<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);
    }
}

这与我们上面建立的模式相同:

  • handle 是一个从 到 的异步函数,它可能会因为一个 的值而失败。Request Response Infallible
    • RequestResponse 都是用Body 参数化的,这是一个默认的 HTTP 体表示。
  • handle 这句话被包裹在 中,产生一个 。这与上面的 相同。service_fn Service<Request<Body>> 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应用程序是很痛苦的。

  • 手动管理所有这些Service ,是一种痛苦
  • 很少有高水平的辅助工具,比如 "将请求体解析为JSON值"
  • 你的类型中的任何错误都可能导致非常大的、非本地的错误信息,难以诊断。

因此,我们将更乐意在稍后从Hyper转移到Axum。但现在,让我们继续探索Hyper层的事情。

绕过service_fnmake_service_fn

当我试图摸索Hyper时,我发现最有用的是实现一个没有service_fnmake_service_fn 的简单应用。所以,让我们在这里自己去做。我们将创建一个简单的计数器应用程序(如果我不能预测的话)。我们将需要两种不同的数据类型:一种用于 "应用工厂",另一种用于应用本身。让我们从应用程序本身开始:

struct DemoApp {
    counter: Arc<AtomicUsize>,
}

impl Service<Request<Body>> for DemoApp {
    type Response = Response<Body>;
    type Error = hyper::http::Error;
    type Future = Ready<Result<Self::Response, Self::Error>>;

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

    fn call(&mut self, _req: Request<Body>) -> 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 结构来创建一个立即可以使用的Future 。换句话说,我们的应用程序不执行任何异步操作。我将Error 关联类型设置为hyper::http::Error 。例如,如果你向header 方法调用提供了无效的字符串,例如非ASCII字符,就会产生这个错误。正如我们多次看到的那样,poll_ready 只是在宣传它随时准备处理另一个请求。

DemoAppFactory 的实现并没有很大的不同:

struct DemoAppFactory {
    counter: Arc<AtomicUsize>,
}

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

    fn poll_ready(&mut self, _cx: &mut std::task::Context) -> Poll<Result<(), Self::Error>> {
        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 是一个HyperRequest<Body> 。但在DemoAppFactory 的情况下,它所接受的Request 是一个&AddrStream 。请记住,Service 实际上只是一个可失败的、从输入到输出的异步函数的概括。输入可能是一个Request<Body> ,也可能是一个&AddrStream ,或者完全是其他东西。

同样,这里的 "响应 "也不是一个HTTP响应,而是一个DemoApp 。我再次发现使用 "输入 "和 "输出 "这两个术语更容易,以避免请求和响应的名称重载。

最后,我们的main 函数看起来与 "Hello world "例子中的原始函数基本相同:

#[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 ?如果你使用一个特质对象,你到底是怎么销的?

但现在是时候深入研究一个我回避了很久的话题了。

了解特质

让我们回顾一下上面关于serve 的特征的记忆:

pub fn serve<S, B>(self, new_service: S) -> Server<I, S, E>
where
    I: Accept,
    I::Error: Into<Box<dyn StdError + Send + Sync>>,
    I::Conn: AsyncRead + AsyncWrite + Unpin + Send + 'static,
    S: MakeServiceRef<I::Conn, Body, ResBody = B>,
    S::Error: Into<Box<dyn StdError + Send + Sync>>,
    B: HttpBody + 'static,
    B::Error: Into<Box<dyn StdError + Send + Sync>>,
    E: NewSvcExec<I::Conn, S::Future, S::Service, E, NoopWatcher>,
    E: ConnStreamExec<<S::Service as HttpService<Body>>::Future, B>,

直到准备这篇博文之前,我从未尝试深入了解所有这些界线。因此,这对我们大家来说将是一次冒险!(也许最后我应该写一些文档PR...)让我们从类型变量开始。我们一共有四个:两个在impl 块本身,两个在这个方法上:

  • I 代表传入的连接流。
  • E 代表执行者。
  • S 是我们要运行的服务。使用上面的术语,这就是 "应用工厂";使用Tower/Hyper术语,这就是 "制造服务"。
  • B 是服务返回的响应体的选择(是 "应用程序",而不是 "应用程序工厂",使用上面的术语)。

I: Accept

I 需要实现 Accept特质,它表示接受来自某个源的新连接的能力。盒子里唯一的实现是为 AddrIncoming,它可以从一个SocketAddr 。而事实上,这正是Server::bind

Accept 有两个相关的类型。 ,必须是可以转换为错误对象的东西,或者 。这是我们看的每一个相关错误类型的要求(几乎是?),所以从现在开始我就跳过它们。我们需要能够将发生的任何错误转换为统一的表示。Error Into<Box<dyn StdError + Send + Sync>>

Conn 关联类型代表一个单独的连接。在AddrIncoming 的情况下,关联类型是 AddrStream.这个类型必须实现AsyncReadAsyncWrite 进行通信,Send'static ,这样它就可以被发送到不同的线程,以及Unpin 。对Unpin 的要求是从堆栈的深处冒出来的,说实话,我不知道是什么驱动了它。

S: MakeServiceRef

MakeServiceRef 是那些没有出现在公共文档中的特征之一。这似乎是故意的,阅读源代码。

只是MakeService 的一种 "特质别名",不会被任何人实现,只作为边界使用。

你是不是对我们为什么会收到一个带有&AddrStream 的引用感到困惑?这是为该转换提供动力的特质。总的来说,trait boundS: MakeServiceRef<I::Conn, Body, ResBody = B> 意味着:

  • S 必须是一个Service
  • S 将接受输入类型为&I::Conn
  • 它将反过来产生一个新的 Service 作为输出
  • 该新服务将接受Request<Body> 作为输入,并产生Response<ResBody> 作为输出

在我们讨论这个问题的时候:ResBody 有一个限制,即它必须实现 HttpBody.正如你可能猜到的,上面提到的Body 结构实现了HttpBody 。也有一些实现方式。当我们谈到Tonic和gRPC时,我们会发现事实上还有其他的响应体需要我们去处理。

NewSvcExecConnStreamExec

E 参数的默认值是Exec ,它没有出现在生成的文档中。但当然你可以在源代码中找到它。Exec 的概念是指定任务是如何被催生出来的。默认情况下,它利用了tokio::spawn

我并不完全确定所有这些是如何进行的,但我相信标题中的两个特征允许对连接服务(app factory)和请求服务(app)的产卵进行不同的处理。

使用Axum

Axum是一个新的网络框架,是这篇博文的开端。与其像上面那样直接与Hyper打交道,不如用Axum重新实现我们的计数器网络服务。我们将使用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<AtomicUsize>,
}

#[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<AppState>) -> (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 位。这是我们在应用程序中管理共享状态的方式。这与我们对Tower和Hyper的整体分析并不直接相关,所以我只提供一个文档的链接来展示它的工作原理。有趣的是,你可能会注意到,这个实现依赖于中间件,而中间件事实上是利用了Tower,所以它并不是完全独立的。

总之,回到我们手头的问题上来。在我们的main 函数中,我们现在使用这个Router 概念来建立我们的应用程序:

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

这基本上是说,"当你收到对/ 的请求时,请调用home 函数,并添加一个中间件来做整个扩展的事情。"home 函数使用一个提取器来获得AppState ,并返回一个类型为(StatusCode, HeaderMap, String) 的值来表示响应。在Axum中,任何适当命名的IntoResponse 特质的实现都可以从处理函数返回。

总之,我们的app 值现在是一个Router 。但一个Router 不能被Hyper直接运行。相反,我们需要把它转换成一个MakeService (又称应用工厂)。幸运的是,这很容易:我们调用app.into_make_service() 。让我们来看看这个方法的签名。

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

再往下看一下兔子洞:

pub struct IntoMakeService<S> { /* fields omitted */ }

impl<S: Clone, T> Service<T> for IntoMakeService<S> {
    type Response = S;
    type Error = Infallible;
    // other stuff omitted
}

类型Router<S> 是一个可以产生类型S 的服务的值。IntoMakeService<S> 将接受某种连接信息,T ,并异步地产生该服务S 。而且由于ErrorInfallible ,我们知道它不会失败。但是,尽管我们说 "异步",看一下ServiceIntoMakeService 的实现,我们看到了一个熟悉的模式:

fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    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<S> 是一个让我们添加路由和中间件层的类型
  • 你可以把一个Router<S> 转换为一个IntoMakeService<S>
  • 但是IntoMakeService<S> 实际上只是一个围绕着S 的花哨的包装,以满足应用工厂的Hyper要求。
  • 所以这里真正的主力是S

那么,S 类型是从哪里来的?它是由你所做的所有routelayer 调用建立起来的。例如,看一下get 函数的签名:

pub fn get<H, B, T>(handler: H) -> OnMethod<H, B, T, EmptyRouter>
where
    H: Handler<B, T>,

pub struct OnMethod<H, B, T, F> { /* fields omitted */ }

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

get 返回一个 值。而 是一个 ,它接收一个 ,并返回一个 。关于身体的表示,有一些有趣的事情在起作用,我们最终会更深入地研究。但随着我们对Tower和Hyper的新理解,这里的类型不再是不可捉摸的了。事实上,它们甚至可以被仔细审查OnMethod OnMethod Service Request<B> Response<BoxBody>

关于上面的例子,还有一个最后的说明。阿克苏姆直接与很多超能机械一起工作。这包括Server 型。虽然axum 箱子从Hyper中重新输出了许多东西,但如果需要,你可以直接从Hyper中使用这些类型。换句话说,Axum与底层库非常接近,只是在上面提供了一些便利。这也是我对Axum进行更深入的实验感到非常兴奋的原因之一。

所以,在这一点上要总结一下:

  • Tower为从输入到输出的异步函数提供了一个抽象,这可能会失败。这就是所谓的服务。
  • HTTP服务器有两个层次的服务。下层是一个从HTTP请求到HTTP响应的服务。上层是一个从连接信息到下层服务的服务。
  • Hyper有很多额外的特性,有些是可见的,有些是不可见的,这些特性允许更多的通用性,同时也使事情变得更复杂易懂。
  • Axum位于Hyper之上,为许多常见情况提供了一个更容易使用的接口。它通过提供Hyper期望看到的相同类型的服务来做到这一点。而且,它似乎在围绕HTTP主体表示做了一堆花哨的工作。

我们旅程的下一步:让我们看看另一个用于构建Hyper服务的库。我们将在下一篇文章中继续讨论这个问题。