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

799 阅读7分钟

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

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

Tonic和gRPC

Tonic是一个gRPC客户端和服务器库。gRPC是一个位于HTTP/2之上的协议,因此Tonic是建立在Hyper(和Tower)之上的。我在这个系列的开头已经提到,我的最终目标是能够通过一个单一的端口提供混合网络/gRPC服务。但现在,让我们先适应一下标准的Tonic客户/服务器应用程序。我们将创建一个回声服务器,它提供了一个端点,将重复你发送的任何信息。

这方面的完整代码可在GitHub上找到。存储库的结构是一个单一的包,有三个不同的板块:

  • 一个提供protobuf定义和Tonic生成的服务器和客户端项目的库箱
  • 一个二进制包,提供一个简单的客户端工具
  • 一个提供服务器可执行文件的二进制文件箱

我们要看的第一个文件是我们服务的protobuf定义,位于proto/echo.proto

syntax = "proto3";

package echo;

service Echo {
  rpc Echo (EchoRequest) returns (EchoReply) {}
}

message EchoRequest {
  string message = 1;
}

message EchoReply {
  string message = 1;
}

即使你不熟悉protobuf,希望上面的例子是相当不言自明的。我们需要一个build.rs 文件来使用tonic_build 来编译这个文件:

fn main() {
    tonic_build::configure()
        .compile(&["proto/echo.proto"], &["proto"])
        .unwrap();
}

最后,我们有一个巨大的src/lib.rs ,提供了我们实现客户端和服务器所需的所有项目:

tonic::include_proto!("echo");

客户端并没有什么非常有趣的地方。它是一个典型的基于clap 的CLI工具,使用Tokio和Tonic。你可以在GitHub上阅读源代码

让我们来看看重要的部分:服务器。

服务器

我们放在库中的Tonic代码会生成一个Echo 特质。我们需要在某些类型上实现该特性,以实现我们的gRPC服务。这与我们今天的主题没有直接关系。这也是相当直接的Rust代码。到目前为止,我发现用Tonic编写客户端/服务器应用程序的经验是一种真正的乐趣,特别是因为这些类型的实现是如此简单:

use tonic_example::echo_server::{Echo, EchoServer};
use tonic_example::{EchoReply, EchoRequest};

pub struct MyEcho;

#[async_trait]
impl Echo for MyEcho {
    async fn echo(
        &self,
        request: tonic::Request<EchoRequest>,
    ) -> Result<tonic::Response<EchoReply>, tonic::Status> {
        Ok(tonic::Response::new(EchoReply {
            message: format!("Echoing back: {}", request.get_ref().message),
        }))
    }
}

如果你在GitHub上查看源代码,有两种不同的实现main ,其中一种被注释了。那一个是更直接的方法,所以让我们从它开始:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let addr = ([0, 0, 0, 0], 3000).into();

    tonic::transport::Server::builder()
        .add_service(EchoServer::new(MyEcho))
        .serve(addr)
        .await?;

    Ok(())
}

它使用Tonic的Server::builder 来创建一个新的Server 值。然后它调用add_service ,它看起来像这样:

impl<L> Server<L> {
    pub fn add_service<S>(&mut self, svc: S) -> Router<S, Unimplemented, L>
    where
        S: Service<Request<Body>, Response = Response<BoxBody>>
            + NamedService
            + Clone
            + Send
            + 'static,
        S::Future: Send + 'static,
        S::Error: Into<crate::Error> + Send,
        L: Clone
}

我们已经有了另一个Router 。这和Axum的工作原理一样,但它是为了将gRPC调用路由到适当的命名服务。让我们来讨论一下这里的类型参数和特征:

  • L 代表,也就是添加到这个服务器的中间件。它将默认为 Identity,以代表没有中间件的情况。
  • S 是我们要添加的新服务,在我们的例子中是一个 。EchoServer
  • 我们的服务需要接受永远熟悉的Request<Body> 类型,并以Response<BoxBody> 。(我们将在下面单独讨论BoxBody 。)它还需要 NamedService(用于路由)。
  • 像往常一样,也有一堆CloneSend'static 的界限,以及对错误表示的要求。

尽管所有这些看起来都很复杂,但好在我们不需要在一个简单的Tonic应用程序中处理这些细节。相反,我们只需调用serve 方法,一切都会像魔法一样运作。

但是,我们正试图跳出常规的路径,更好地理解这与Hyper的互动。所以,让我们更深入地了解一下!

into_service

除了serve 方法外,Tonic的Router 类型还提供了一个into_service 方法。我不打算在这里详述它的全部内容,因为它对讨论没有什么帮助,但对你必须做的阅读却增加了很多。 相反,我只想说:

  • into_service 返回一个 值RouterService<S>
  • S 必须实现Service<Request<Body>, Response = Response<ResBody>>
  • ResBody 是一个Hyper可以用于响应体的类型。

好了,酷吗?现在我们可以写出我们的稍显啰嗦的main 函数。首先我们创建我们的RouterService 值:

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

但现在我们有一点问题了。Hyper期望有一个 "制造服务 "或 "应用工厂",而我们只有一个请求处理服务。所以我们需要回到Hyper并使用make_service_fn

let make_grpc_service = make_service_fn(move |_conn| {
    let grpc_service = grpc_service.clone();
    async { Ok::<_, Infallible>(grpc_service) }
});

注意,我们需要克隆一个新的grpc_service ,我们需要玩所有的游戏,把闭包和异步块分割开来,再加上Infallible ,这就是我们之前看到的。但是现在,有了这些,我们就可以启动我们的gRPC服务了:

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

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

如果你想玩这个,你可以克隆tonic-example repo,然后:

  • 在一个终端上运行cargo run --bin server
  • 在另一个终端上运行cargo run --bin client "Hello world!"

然而,试图在你的浏览器中打开http://localhost:3000,效果不会太好。这个服务器只能处理gRPC连接,而不是标准的网络浏览器请求和RESTful APIs等。我们现在还有最后一步:写一些可以处理Axum和Tonic服务并适当地路由到它们的东西。

BoxBody

让我们更详细地了解一下这个BoxBody 类型。我们正在使用tonic::body::BoxBody struct ,它被定义为:

pub type BoxBody = http_body::combinators::BoxBody<bytes::Bytes, crate::Status>;

http_body 它本身提供了自己的 ,在BoxBody数据错误上进行参数化。Tonic使用 类型来表示错误,并表示一个gRPC服务可以返回的不同状态代码。对于那些不熟悉 的人来说,这里是Status Bytes文档的一个快速摘录

Bytes 是一个高效的容器,用于存储和操作连续的内存片。它主要用于网络代码,但也可以应用于其他地方。

Bytes 值通过允许多个 对象指向相同的底层内存来促进零拷贝的网络编程。这是通过使用一个引用计数来跟踪内存何时不再需要并可以被释放来管理的。Bytes

当你看到Bytes ,你可以从语义上认为它是一个字节片或字节向量。来自http_body 板块的底层BoxBody 代表了该特性的某种实现。 http_body::Body特质的某种实现。Body 特质代表了一个流式HTTP主体,并包含:

  • DataError 的相关类型,对应的类型参数为BoxBody
  • poll_data 用于异步地从主体中读取更多的数据
  • 帮助性的map_datamap_err 方法,用于操作DataError 相关的类型
  • 一个boxed 方法用于一些类型的擦除,使我们能够取回一个BoxBody
  • 其他一些围绕大小提示和HTTP/2尾随数据的辅助方法

对于我们的目的,需要注意的是,这里的 "类型清除 "并不是真正的完全类型清除。当我们使用boxed 来获得一个代表主体的特质对象时,我们仍然有类型参数来代表DataError 。因此,如果我们最终有两个不同的DataError 的表示,它们就不会相互兼容。让我问你:你认为Axum会像Tonic那样使用相同的Status 错误类型来表示错误吗?(提示:不会。)所以,当我们下次讨论这个问题时,我们将有一些关于统一错误类型的工作要做。