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

805 阅读10分钟

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

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

单一端口,两种协议

这个标题是个谎言。Axum网络应用程序和gRPC服务器都使用相同的协议。HTTP/2。说它们说的是不同的方言可能更公平。但重要的是,看一个请求并确定它是否想与gRPC服务器对话是很容易的。gRPC请求都会包括头信息Content-Type: application/grpc 。因此,我们今天的最后一步是写一个可以同时接受gRPCService 和普通Service ,并返回一个统一的服务。让我们开始吧!作为参考,完整的代码是在 src/bin/server-hybrid.rs.

让我们从我们的main 功能开始,演示我们希望这个东西是什么样子的:

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

    let axum_make_service = axum::Router::new()
        .route("/", axum::handler::get(|| async { "Hello world!" }))
        .into_make_service();

    let grpc_service = tonic::transport::Server::builder()
        .add_service(EchoServer::new(MyEcho))
        .into_service();

    let hybrid_make_service = hybrid(axum_make_service, grpc_service);

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

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

我们设置了简单的axum_make_servicegrpc_service 值,然后用hybrid 函数将它们合并成一个服务。注意这些名称的不同,以及我们为前者调用into_make_service ,为后者调用into_service 。信不信由你,这很快就会给我们带来很大的痛苦。

总之,有了这个尚待解释的hybrid 功能,启动一个混合服务器是小菜一碟。但是,细节决定成败!

另外:还有一些更简单的方法可以使用特征对象来完成下面的代码。我避免了任何类型清除技术,因为(1)我认为这种方式的代码更清晰,(2)在我看来,它变成了一个更漂亮的教程。唯一的例外是,我使用trait对象来表示错误,因为Hyper本身就是这样做的,而且在不同的服务中使用相同的错误表示,可以大大简化代码。

定义hybrid

我们的hybrid 函数将返回一个HybridMakeService 值:

fn hybrid<MakeWeb, Grpc>(make_web: MakeWeb, grpc: Grpc) -> HybridMakeService<MakeWeb, Grpc> {
    HybridMakeService { make_web, grpc }
}

struct HybridMakeService<MakeWeb, Grpc> {
    make_web: MakeWeb,
    grpc: Grpc,
}

在整个过程中,我将对类型变量的名称保持一致和详细的说明。这里,我们有类型变量MakeWebGrpc 。这反映了Axum和Tonic从API角度所提供的不同。我们需要向Axum的MakeWeb 提供连接信息,以便获得请求处理Service 。有了Grpc ,我们就不需要这样做了。

无论如何,我们已经准备好为HybridMakeService ,实现我们的Service

impl<ConnInfo, MakeWeb, Grpc> Service<ConnInfo> for HybridMakeService<MakeWeb, Grpc>
where
    MakeWeb: Service<ConnInfo>,
    Grpc: Clone,
{
    // ...
}

我们有两个预期的类型变量MakeWebGrpc ,以及ConnInfo ,来表示我们得到的任何连接信息。Grpc 根本不关心这些,但ConnInfo 必须与MakeWeb 接收的信息相匹配。因此,我们有一个边界MakeWeb: Service<ConnInfo>Grpc: Clone 的约束很快就会有意义。

当我们收到一个传入的连接时,我们需要做两件事:

  • MakeWeb 获取一个新的Service 。这样做可能会异步发生,而且可能会有一些错误。
    • 旁注如果你还记得Axum的实际实现,我们知道这些都不是真的。从AxumIntoMakeService 获得一个Service ,总是会成功的,而且从不做任何异步工作。但是在Axum中没有API暴露这个事实,所以我们被困在Service API后面。
  • 克隆我们已经有的Grpc

一旦我们有了新的Web Service 和克隆的Grpc ,我们就可以把这些东西打包成一个新的struct,HybridService 。我们还需要一些帮助来执行必要的异步操作,所以我们将创建一个新的帮助器Future 类型。这一切看起来像:

type Response = HybridService<MakeWeb::Response, Grpc>;
type Error = MakeWeb::Error;
type Future = HybridMakeServiceFuture<MakeWeb::Future, Grpc>;

fn poll_ready(
    &mut self,
    cx: &mut std::task::Context,
) -> std::task::Poll<Result<(), Self::Error>> {
    self.make_web.poll_ready(cx)
}

fn call(&mut self, conn_info: ConnInfo) -> Self::Future {
    HybridMakeServiceFuture {
        web_future: self.make_web.call(conn_info),
        grpc: Some(self.grpc.clone()),
    }
}

请注意,我们要遵从self.make_web ,说它已经准备好了,并传递它的错误。让我们通过查看HybridMakeServiceFuture 来结束这部分的工作:

#[pin_project]
struct HybridMakeServiceFuture<WebFuture, Grpc> {
    #[pin]
    web_future: WebFuture,
    grpc: Option<Grpc>,
}

impl<WebFuture, Web, WebError, Grpc> Future for HybridMakeServiceFuture<WebFuture, Grpc>
where
    WebFuture: Future<Output = Result<Web, WebError>>,
{
    type Output = Result<HybridService<Web, Grpc>, WebError>;

    fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context) -> Poll<Self::Output> {
        let this = self.project();
        match this.web_future.poll(cx) {
            Poll::Pending => Poll::Pending,
            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
            Poll::Ready(Ok(web)) => Poll::Ready(Ok(HybridService {
                web,
                grpc: this.grpc.take().expect("Cannot poll twice!"),
            })),
        }
    }
}

我们需要拉入 pin_project以允许我们在我们的poll 实现中投射被钉住的网络未来。(如果你不熟悉pin_project ,不要担心,我们会在后面用HybridFuture 来描述事情。)当我们轮询web_future ,我们最终可能处于三种状态之一:

  • Pending:MakeWeb 还没有准备好,所以我们也没有准备好。
  • Ready(Err(e)):MakeWeb 失败了,所以我们传递错误
  • Ready(Ok(web)):MakeWeb 是成功的,所以把新的web 值和grpc 值打包。

this.grpc.take() 有一些有趣的事情,以使克隆的Grpc 值从OptionFuture有一个不变的原则,即一旦它们返回Ready ,它们就不能再被轮询了。因此,我们可以假设take 只会被调用一次。但是,如果Axum暴露一个into_service 方法,所有这些痛苦都可以避免。

HybridService

前面的类型最终会产生一个HybridService 。让我们看看那是什么:

struct HybridService<Web, Grpc> {
    web: Web,
    grpc: Grpc,
}

impl<Web, Grpc, WebBody, GrpcBody> Service<Request<Body>> for HybridService<Web, Grpc>
where
    Web: Service<Request<Body>, Response = Response<WebBody>>,
    Grpc: Service<Request<Body>, Response = Response<GrpcBody>>,
    Web::Error: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
    Grpc::Error: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
{
    // ...
}

这个HybridService 将接受Request<Body> 作为输入。底层的WebGrpc 也会接受Request<Body> 作为输入,但它们会产生稍微不同的输出:Response<WebBody>Response<GrpcBody> 。 我们需要以某种方式统一这些主体的表示。如上所述,我们将使用特质对象来处理错误,所以没有必要在这方面进行统一:

type Response = Response<HybridBody<WebBody, GrpcBody>>;
type Error = Box<dyn std::error::Error + Send + Sync + 'static>;
type Future = HybridFuture<Web::Future, Grpc::Future>;

相关的Response 类型也将是一个Response<...> ,但其主体将是HybridBody<WebBody, GrpcBody> 类型。我们将在后面讨论这个问题。同样地,我们有两个不同的Future,可能会被调用,这取决于请求的种类。我们需要用一个HybridFuture 类型来统一它。

接下来,让我们看一下poll_ready 。我们需要检查WebGrpc 是否准备好接受一个新的请求。而每次检查的结果都是三种情况中的一种。Pending,Ready(Err), 或Ready(Ok) 。这个函数是关于模式匹配和统一错误表示的,使用.into()

fn poll_ready(
    &mut self,
    cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
    match self.web.poll_ready(cx) {
        Poll::Ready(Ok(())) => match self.grpc.poll_ready(cx) {
            Poll::Ready(Ok(())) => Poll::Ready(Ok(())),
            Poll::Ready(Err(e)) => Poll::Ready(Err(e.into())),
            Poll::Pending => Poll::Pending,
        },
        Poll::Ready(Err(e)) => Poll::Ready(Err(e.into())),
        Poll::Pending => Poll::Pending,
    }
}

最后,我们可以看到call ,这里才是我们要完成的真正逻辑。在这里,我们可以看到请求,并决定在哪里进行路由:

fn call(&mut self, req: Request<Body>) -> Self::Future {
    if req.headers().get("content-type").map(|x| x.as_bytes()) == Some(b"application/grpc") {
        HybridFuture::Grpc(self.grpc.call(req))
    } else {
        HybridFuture::Web(self.web.call(req))
    }
}

令人惊讶。所有这些工作基本上只用了5行有意义的代码!

HybridFuture

就是这样,我们已经到了最后了在这个系列中,我们要分析的最后一个类型是HybridFuture 。(还有一个HybridBody 类型,但它与HybridFuture 足够相似,不需要自己解释)。struct'的定义是:

#[pin_project(project = HybridFutureProj)]
enum HybridFuture<WebFuture, GrpcFuture> {
    Web(#[pin] WebFuture),
    Grpc(#[pin] GrpcFuture),
}

像以前一样,我们使用pin_project 。这一次,我们来探讨一下原因。Future 特质的接口需要内存中的针状指针。具体来说,poll 的第一个参数是self: Pin<&mut Self> 。Rust本身从未对对象的持久性给出任何保证,而这对于编写异步运行系统来说是绝对关键的。

因此,HybridFuture 上的poll 方法将收到一个类型为Pin<&mut HybridFuture> 的参数。问题是我们需要在底层的WebBodyGrpcBody 上调用poll 方法。假设我们有Web 变体,我们面临的问题是在HybridFuture 上进行模式匹配会给我们一个&WebFuture&mut WebFuture 。它不会给我们一个Pin<&mut WebFuture> ,而这正是我们需要的!

pin_project 因此,我们使用了一个投影数据类型,并在原始数据上提供了一个方法 ,来代替那些被钉住的可变引用。这使我们能够正确地实现 特质的 ,就像这样。.project() Future `HybridFuture:

impl<WebFuture, GrpcFuture, WebBody, GrpcBody, WebError, GrpcError> Future
    for HybridFuture<WebFuture, GrpcFuture>
where
    WebFuture: Future<Output = Result<Response<WebBody>, WebError>>,
    GrpcFuture: Future<Output = Result<Response<GrpcBody>, GrpcError>>,
    WebError: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
    GrpcError: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
{
    type Output = Result<
        Response<HybridBody<WebBody, GrpcBody>>,
        Box<dyn std::error::Error + Send + Sync + 'static>,
    >;

    fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context) -> Poll<Self::Output> {
        match self.project() {
            HybridFutureProj::Web(a) => match a.poll(cx) {
                Poll::Ready(Ok(res)) => Poll::Ready(Ok(res.map(HybridBody::Web))),
                Poll::Ready(Err(e)) => Poll::Ready(Err(e.into())),
                Poll::Pending => Poll::Pending,
            },
            HybridFutureProj::Grpc(b) => match b.poll(cx) {
                Poll::Ready(Ok(res)) => Poll::Ready(Ok(res.map(HybridBody::Grpc))),
                Poll::Ready(Err(e)) => Poll::Ready(Err(e.into())),
                Poll::Pending => Poll::Pending,
            },
        }
    }
}

我们将成功的响应体与HybridBody enum 统一起来,并使用特质对象进行错误处理。而现在我们为这两种类型的请求呈现了一个统一的类型。欢呼吧!

结论

感谢亲爱的读者看完这些文章。我希望它是有帮助的。在这样潜心研究了这些细节之后,我肯定对Tower/Hyper生态系统感到更加舒适。让我们总结一下这个系列的一些亮点:

  • Tower提供了一个名为Service 的Rusty接口,用于从输入到输出,或从请求到响应的异步函数,这可能会失败
    • 不要忘了,在这个界面中,有两个层次的异步行为:检查Service 是否准备好,然后等待它完成处理
  • HTTP本身就需要两层的异步函数:一个是针对单个请求的type InnerService = Request -> IO Response ,另一个是针对整个连接的type OuterService = ConnectionInfo -> IO InnerService
  • Hyper提供了一个具体的服务器实现,可以接受看起来像OuterService 的东西并运行它们。
    • 它使用了大量的特征,其中一些没有公开暴露,以概括性的方式进行处理
    • 它在请求和响应体的表示上提供了很大的灵活性
    • 辅助函数service_fnmake_service_fn 是创建两级Service 的必要的通用方法。
  • Axum是一个位于Hyper之上的轻量级框架,并公开了它的很多接口
  • gRPC是一个基于HTTP/2的协议,可以通过Hyper使用Tonic库进行托管
  • 在Axum服务和gRPC之间进行调度在概念上是很容易的:只需检查content-type 头部,看是否是一个gRPC请求。
  • 但是为了实现这一点,我们需要一堆辅助的 "混合 "类型来统一Axum和Tonic之间的不同类型
  • 很多时候,你可以用特质对象来实现类型清除,但混合Either-styleenums也可以。
    • 虽然它们更啰嗦,但也可能更清晰。
    • 通过避免动态调度,也有潜在的性能增益

如果你想回顾一下,记得在GitHub上有一个完整的项目,网址是github.com/snoyberg/to…

最后,我的一些更主观的收获:

  • 我总体上很喜欢Axum,而且我已经在一个新的客户项目中使用它了。
  • 我确实希望它的水平更高一些,而且类型错误不那么令人生畏。我认为在这个领域可能有一些空间,可以让更多的人关注类型清除的框架,用一些运行时的性能来换取明显更简单的人体工程学。
  • 我也在考虑重写我们的Zehut产品以利用Axum。到目前为止,进展还算顺利,但在可预见的未来,其他的责任让我不再参与这项工作。而且有一些痛苦的编译问题需要注意。
  • 我确实怀念强类型的路由,但总的来说,我宁愿使用像Axum这样的东西,而不是用routetype 推得更远。不过在未来,我可能会考虑提供一些routetype/axum 的桥梁。