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

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

Tonic是一个包括gRPC客户端和服务端库。gRPC是在HTTP/2之上的协议,所以Tonic建立在Hyper(Tower)之上。我在系列文章之初就提到我最初目标是通过一个端口能够提供hybrid web/gRPC 服务。但是目前为止,让我们欣慰的是熟悉了标准的Tonic 客户端/服务端应用。我们将创建一个Echo服务,提供一个终端响应我们发送的信息。

查看源码.本仓库结构是一个包,包括三个crates:

  • 一个crate提供protobuf定义、Tonic 自动生成客户端和服务端栏目
  • 一个crate提供简单的客户端工具
  • 一个crate提供可运行的服务端

第一个文件我们看到我们服务的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");

客户端没有什么有趣的。是使用Tokio和Tonic典型的 clap为基础的Cli工具。你可以查看源码.

让我们继续重要部分:服务端。

服务端

我们将tonic代码放进我们的库来构建我们的Echo trait。我们需要在一些类型上实现这个trait 来创建我们的gRPC 服务。这和我们今天的话题没有直接关系。而且代码非常简单。目前我觉得使用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),
        }))
    }
}

如果你查看源码.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(())
}

使用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调用服务名称的服务。让我们研究下这里的参数和traits:

  • L 代表了一个层级或或者中间件嵌入到这个服务中。默认是Identity, 没有中间件。
  • S 是我们将要添加的服务,在这个例子中是Echo服务。
  • 我们的服务需要接受以前熟悉的 Request<Body> 类型,返回 Response<BoxBody>(后面我们会单独讨论BoxBody),也要命名的服务(方便路由)。
  • 惯例,也绑定了'static、send、Clone和错误

所有这些看起来都很复杂。值得高兴的事在简单的Tonic应用中我们不需要处理的很深入。相反,我们只是简单的调用serve方法,一切魔术般的运行了。

但我们正试图摆脱常规,更好地理解它与Hyper的交互作用。所以让我们更深入一点!

into_service

除了serve 方法,Tonic路由也提供了一个into_service 方法。我们不会深入研究所有的作用,因为没有多少可讨论的,反而会增加许多阅读的时间。相反,如下足以说明:

  • Into_service 返回一个RouterService<S> 值
  • S必须实现Service<Request<Body>, Response = Response<ResBody>>
  • ResBody是一种Hyper可以作为返回值的类型

OK,是不是很酷?现在我们可以写我们比较冗长的main函数了,首先我们创建RouterService值。

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

但是现在我们有个小问题,Hyper期望一个"make service" 或者 "app factory",但是我们只有一个请求处理服务。所以我们需要用Hyper中的make_service_fn:

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

如果你想尝试,你可以克隆源码,然后执行:

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

然而,当尝试访问http://localhost:3000时候并不能运行正常。这个服务只能处理gRPC请求,正常浏览器请求并不能处理,例如Restful Api。我们还需要最后一步:写一些可以处理Axum和Tonic的服务,并路由到各自的服务。

BoxBody

让我们详细的看下BoxBody类型。我们使用的是tonic::body::BoxBody结构体,定义如下:

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

http_body通过data和error参数化为本身提供BoxBody。Tonic在gRPC服务中用Status类型代表不同类型的错误。对不了解Bytes的,可以通过这个文档让你快速熟悉起来:

Bytes is an efficient container for storing and operating on contiguous slices of memory. It is intended for use primarily in networking code, but could have applications elsewhere as well.

Bytes values facilitate zero-copy network programming by allowing multiple Bytes objects to point to the same underlying memory. This is managed by using a reference count to track when the memory is no longer needed and can be freed.

当你看到Bytes,你从语义上就会认为是字节切片或字节序列。http_body crate底层包裹的BoxBody代表了多种类型 http_body::Body trait的实现。Body trait 代表了了http 流,包括:

  • 关联类型Data和Error,对应参数BoxBody
  • poll_data异步从body中读取数据
  • 帮助函数map_data和map_err 可使用Data和Error关联类型
  • 一些类型的boxed方法移除,可让我们获的BoxBody
  • 大小提示和 HTTP/2 追加数据的其他一些辅助方法

我们这里最重要的目的"去除类型"并不是真的类型删除。当我们用boxed获取一个代表body的trait对象时,我们还有参数代表Data和Error。所以,如果我们最终得到Data和Error两种不同的表示,两者是不兼容的。留个问题:你认为Axum也会像 Tonic那样用不同的状态代表不同的错误吗?(并不是),所以,在下一节中,我们有很多围绕使错误统一的基础工作要做。

阅读原文


道之
10 声望1 粉丝