Rust’s gRPC implementation of Tonic

Original link: https://jasonkayzk.github.io/2022/12/03/Rust%E7%9A%84GRPC%E5%AE%9E%E7%8E%B0Tonic/

Tonic is an asynchronous implementation of a GRPC client and server in rust. The bottom layer uses tokio’s prost to generate the code corresponding to Protocol Buffers;

This article explains how to use Tonic, and provides a project case that contains multiple proto files;

source code:

Rust’s gRPC implementation of Tonic

foreword

tonic is a gRPC implementation based on HTTP/2, focusing on high performance, interoperability and flexibility;

The library was created to have first-class support for async/await and to serve as a core building block for production systems written in Rust;

characteristic:

  • bidirectional streaming
  • High performance asynchronous io
  • interoperability
  • TLS encryption support via rustls
  • load balancing
  • custom metadata
  • Authentication
  • health examination

Compiling Protobuf still needs to install protoc, you can refer to the official documentation:

In addition, in addition to this implementation, PingCAP also open sourced an implementation:

I tried it, and to be honest, it is not as easy to use as Tonic, but his benchmark is slightly higher;

Let’s start writing a project case that contains multiple proto files;

create project

The final directory structure is as follows:

 $ tree . .├── Cargo.toml├── Cargo.lock ├── build.rs├── proto │ ├── basic │ │ └── basic.proto │ ├── goodbye.proto │ └── hello.proto └── src ├── bin │ ├── client.rs │ └── server.rs └── lib.rs

in:

  • Services are defined in the proto directory;
  • The script to generate the rs file through proto is declared in build.rs ;
  • The rs file generated after compiling proto in lib.rs is introduced into build.rs ;
  • The implementation of the client and server are defined in the bin directory;

First create a lib project:

 cargo new tonic-demo --lib

In this lib, we implement the service code, and implement the client and server through the client and server in the bin directory;

Modify the Cargo configuration:

Cargo.toml

 [[bin]]name="server"path="src/bin/server.rs"[[bin]]name="client"path="src/bin/client.rs"[dependencies]prost = "0.11.3"tokio = { version = "1.19.2", features = ["macros", "rt-multi-thread"] }tonic = "0.8.3"[build-dependencies]tonic-build = "0.8.4"

define service

Create a proto directory and declare the corresponding services;

Because most of the information on the Internet is a proto file, and the actual project basically has a hierarchical structure;

So here I also use multiple proto files to demonstrate;

It is defined as follows:

 // tonic-demo/proto/basic/basic.protosyntax = "proto3";package basic;message BaseResponse { string message = 1; int32 code = 2;}// tonic-demo/proto/hello.protosyntax = "proto3";import "basic/basic.proto";package hello;service Hello { rpc Hello(HelloRequest) returns (HelloResponse) {}}message HelloRequest { string name = 1;}message HelloResponse { string data = 1; basic.BaseResponse message = 2;}// tonic-demo/proto/goodbye.protosyntax = "proto3";import "basic/basic.proto";package goodbye;service Goodbye { rpc Goodbye(GoodbyeRequest) returns (GoodbyeResponse) {}}message GoodbyeRequest { string name = 1;}message GoodbyeResponse { string data = 1; basic.BaseResponse message = 2;}

Defined in the proto/basic directory: BaseResponse ;

And he was introduced in both hello.proto and goodbye.proto ;

configure compile

Let’s look at build.rs, which is also the key to compiling protobuf files!

As we all know, the code defined in build.rs will be executed before the project code is actually compiled, and it is used to do some tricks before compiling the real project;

Therefore, we can first compile the protobuf file here;

In the above Cargo configuration we introduced:

 [build-dependencies]tonic-build = "0.8.4"

Therefore it is used here:

build.rs

 use std::error::Error;use std::fs;static OUT_DIR: &str = "src/proto-gen";fn main() -> Result<(), Box<dyn Error>> { let protos = [ "proto/basic/basic.proto", "proto/hello.proto", "proto/goodbye.proto", ]; fs::create_dir_all(OUT_DIR).unwrap(); tonic_build::configure() .build_server(true) .out_dir(OUT_DIR) .compile(&protos, &["proto/"])?; rerun(&protos); Ok(())}fn rerun(proto_files: &[&str]) { for proto_file in proto_files { println!("cargo:rerun-if-changed={}", proto_file); }}

First, declare the proto file we want to compile, and then create the output location of the compiled proto file (default is in the target/build directory);

Finally, use tonic_build compile the file on the server side;

After the project is compiled, the compiled proto file will be output to our defined src/proto-gen ;

tonic-demo/src/proto-gen/basic.rs

 #[derive(Clone, PartialEq, ::prost::Message)]pub struct BaseResponse { #[prost(string, tag = "1")] pub message: ::prost::alloc::string::String, #[prost(int32, tag = "2")] pub code: i32,}

tonic-demo/src/proto-gen/hello.rs

 #[derive(Clone, PartialEq, ::prost::Message)]pub struct HelloRequest { #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String,}#[derive(Clone, PartialEq, ::prost::Message)]pub struct HelloResponse { #[prost(string, tag = "1")] pub data: ::prost::alloc::string::String, #[prost(message, optional, tag = "2")] pub message: ::core::option::Option<super::basic::BaseResponse>,}/// Generated client implementations.pub mod hello_client { #![allow(unused_variables, dead_code, missing_docs, clippy::let_unit_value)] use tonic::codegen::*; use tonic::codegen::http::Uri; #[derive(Debug, Clone)] pub struct HelloClient<T> { inner: tonic::client::Grpc<T>, } impl HelloClient<tonic::transport::Channel> { /// Attempt to create a new client by connecting to a given endpoint. pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error> where D: std::convert::TryInto<tonic::transport::Endpoint>, D::Error: Into<StdError>, { let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; Ok(Self::new(conn)) } } impl<T> HelloClient<T> where T: tonic::client::GrpcService<tonic::body::BoxBody>, T::Error: Into<StdError>, T::ResponseBody: Body<Data = Bytes> + Send + 'static, <T::ResponseBody as Body>::Error: Into<StdError> + Send, { pub fn new(inner: T) -> Self { let inner = tonic::client::Grpc::new(inner); Self { inner } } pub fn with_origin(inner: T, origin: Uri) -> Self { let inner = tonic::client::Grpc::with_origin(inner, origin); Self { inner } } pub fn with_interceptor<F>( inner: T, interceptor: F, ) -> HelloClient<InterceptedService<T, F>> where F: tonic::service::Interceptor, T::ResponseBody: Default, T: tonic::codegen::Service< http::Request<tonic::body::BoxBody>, Response = http::Response< <T as tonic::client::GrpcService<tonic::body::BoxBody>>::ResponseBody, >, >, <T as tonic::codegen::Service< http::Request<tonic::body::BoxBody>, >>::Error: Into<StdError> + Send + Sync, { HelloClient::new(InterceptedService::new(inner, interceptor)) } /// Compress requests with the given encoding. /// /// This requires the server to support it otherwise it might respond with an /// error. #[must_use] pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { self.inner = self.inner.send_compressed(encoding); self } /// Enable decompressing responses. #[must_use] pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { self.inner = self.inner.accept_compressed(encoding); self } pub async fn hello( &mut self, request: impl tonic::IntoRequest<super::HelloRequest>, ) -> Result<tonic::Response<super::HelloResponse>, tonic::Status> { self.inner .ready() .await .map_err(|e| { tonic::Status::new( tonic::Code::Unknown, format!("Service was not ready: {}", e.into()), ) })?; let codec = tonic::codec::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static("/hello.Hello/Hello"); self.inner.unary(request.into_request(), path, codec).await } }}/// Generated server implementations.pub mod hello_server { #![allow(unused_variables, dead_code, missing_docs, clippy::let_unit_value)] use tonic::codegen::*; /// Generated trait containing gRPC methods that should be implemented for use with HelloServer. #[async_trait] pub trait Hello: Send + Sync + 'static { async fn hello( &self, request: tonic::Request<super::HelloRequest>, ) -> Result<tonic::Response<super::HelloResponse>, tonic::Status>; } #[derive(Debug)] pub struct HelloServer<T: Hello> { inner: _Inner<T>, accept_compression_encodings: EnabledCompressionEncodings, send_compression_encodings: EnabledCompressionEncodings, } struct _Inner<T>(Arc<T>); impl<T: Hello> HelloServer<T> { pub fn new(inner: T) -> Self { Self::from_arc(Arc::new(inner)) } pub fn from_arc(inner: Arc<T>) -> Self { let inner = _Inner(inner); Self { inner, accept_compression_encodings: Default::default(), send_compression_encodings: Default::default(), } } pub fn with_interceptor<F>( inner: T, interceptor: F, ) -> InterceptedService<Self, F> where F: tonic::service::Interceptor, { InterceptedService::new(Self::new(inner), interceptor) } /// Enable decompressing requests with the given encoding. #[must_use] pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { self.accept_compression_encodings.enable(encoding); self } /// Compress responses with the given encoding, if the client supports it. #[must_use] pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { self.send_compression_encodings.enable(encoding); self } } impl<T, B> tonic::codegen::Service<http::Request<B>> for HelloServer<T> where T: Hello, B: Body + Send + 'static, B::Error: Into<StdError> + Send + 'static, { type Response = http::Response<tonic::body::BoxBody>; type Error = std::convert::Infallible; type Future = BoxFuture<Self::Response, Self::Error>; fn poll_ready( &mut self, _cx: &mut Context<'_>, ) -> Poll<Result<(), Self::Error>> { Poll::Ready(Ok(())) } fn call(&mut self, req: http::Request<B>) -> Self::Future { let inner = self.inner.clone(); match req.uri().path() { "/hello.Hello/Hello" => { #[allow(non_camel_case_types)] struct HelloSvc<T: Hello>(pub Arc<T>); impl<T: Hello> tonic::server::UnaryService<super::HelloRequest> for HelloSvc<T> { type Response = super::HelloResponse; type Future = BoxFuture< tonic::Response<Self::Response>, tonic::Status, >; fn call( &mut self, request: tonic::Request<super::HelloRequest>, ) -> Self::Future { let inner = self.0.clone(); let fut = async move { (*inner).hello(request).await }; Box::pin(fut) } } let accept_compression_encodings = self.accept_compression_encodings; let send_compression_encodings = self.send_compression_encodings; let inner = self.inner.clone(); let fut = async move { let inner = inner.0; let method = HelloSvc(inner); let codec = tonic::codec::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( accept_compression_encodings, send_compression_encodings, ); let res = grpc.unary(method, req).await; Ok(res) }; Box::pin(fut) } _ => { Box::pin(async move { Ok( http::Response::builder() .status(200) .header("grpc-status", "12") .header("content-type", "application/grpc") .body(empty_body()) .unwrap(), ) }) } } } } impl<T: Hello> Clone for HelloServer<T> { fn clone(&self) -> Self { let inner = self.inner.clone(); Self { inner, accept_compression_encodings: self.accept_compression_encodings, send_compression_encodings: self.send_compression_encodings, } } } impl<T: Hello> Clone for _Inner<T> { fn clone(&self) -> Self { Self(self.0.clone()) } } impl<T: std::fmt::Debug> std::fmt::Debug for _Inner<T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:?}", self.0) } } impl<T: Hello> tonic::server::NamedService for HelloServer<T> { const NAME: &'static str = "hello.Hello"; }}

have to be aware of is:

The generated HelloClient type for the client:

  • Implemented Clone, Sync and Send, so it can be used across threads;

The HelloServer type generated for the server:

  • Contains impl<T: Hello> , so it is required to implement the Hello Trait we defined;

Import the files generated by proto

Below we introduce the files generated by lib.rs in lib.rs:

lib.rs

 pub mod basic { include!("./proto-gen/basic.rs");}pub mod hello { include!("./proto-gen/hello.rs");}pub mod goodbye { include!("./proto-gen/goodbye.rs");}

The include! provided by the standard library is used here to import the file;

If you do not define the output location of the compiled proto file, it will be in the target/build directory by default;

At this time, you can also use the include_proto!("hello") macro provided by tonic to directly import the corresponding file without specifying an additional path;

Refer to the official documentation:

Server implementation

The following implements the server side;

The implementation of the server is basically similar to other languages, just create a corresponding Service implementation for the Service defined by the corresponding proto:

tonic-demo/src/bin/server.rs

 #[derive(Default)]pub struct HelloService {}#[tonic::async_trait]impl Hello for HelloService { async fn hello(&self, req: Request<HelloRequest>) -> Result<Response<HelloResponse>, Status> { println!("hello receive request: {:?}", req); let response = HelloResponse { data: format!("Hello, {}", req.into_inner().name), message: Some(BaseResponse { message: "Ok".to_string(), code: 200, }), }; Ok(Response::new(response)) }}#[derive(Default)]pub struct GoodbyeService {}#[tonic::async_trait]impl Goodbye for GoodbyeService { async fn goodbye( &self, req: Request<GoodbyeRequest>, ) -> Result<Response<GoodbyeResponse>, Status> { println!("goodbye receive request: {:?}", req); let response = GoodbyeResponse { data: format!("Goodbye, {}", req.into_inner().name), message: Some(BaseResponse { message: "Ok".to_string(), code: 200, }), }; Ok(Response::new(response)) }}#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let addr = "0.0.0.0:50051".parse()?; println!("server starting at: {}", addr); Server::builder() .add_service(HelloServer::new(HelloService::default())) .add_service(GoodbyeServer::new(GoodbyeService::default())) .serve(addr) .await?; Ok(())}

Implement the corresponding logic of the interface in the corresponding Trait, and finally register the Service in the main, the logic is very clear;

client implementation

The implementation of the client is even simpler. First, create an Endpoint connection through the address, and then directly call the corresponding function:

tonic-demo/src/bin/client.rs

 #[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let addr = Endpoint::from_static("https://127.0.0.1:50051"); let mut hello_cli = HelloClient::connect(addr.clone()).await?; let request = Request::new(HelloRequest { name: "tonic".to_string(), }); let response = hello_cli.hello(request).await?; println!("hello response: {:?}", response.into_inner()); let mut goodbye_cli = GoodbyeClient::connect(addr).await?; let request = Request::new(GoodbyeRequest { name: "tonic".to_string(), }); let response = goodbye_cli.goodbye(request).await?; println!("goodbye response: {:?}", response.into_inner()); Ok(())}

Is it very simple;

test

Let’s test it out, first start the server:

 $ cargo run --bin server server starting at: 0.0.0.0:50051

Restart the client:

 $ cargo run --bin client hello response: HelloResponse { data: "Hello, tonic", message: Some(BaseResponse { message: "Ok", code: 200 }) }goodbye response: GoodbyeResponse { data: "Goodbye, tonic", message: Some(BaseResponse { message: "Ok", code: 200 }) }

The client receives the response, and the server logs:

 hello receive request: Request { metadata: MetadataMap { headers: {"te": "trailers", "content-type": "application/grpc", "user-agent": "tonic/0.8.3"} }, message: HelloRequest { name: "tonic" }, extensions: Extensions }goodbye receive request: Request { metadata: MetadataMap { headers: {"te": "trailers", "content-type": "application/grpc", "user-agent": "tonic/0.8.3"} }, message: GoodbyeRequest { name: "tonic" }, extensions: Extensions }

Steps need to be added in Github Action:

 - name: Install protoc run: sudo apt-get install -y protobuf-compiler

install protoc;

Reference Code:

Summarize

It can be seen that compared to other languages, using grpc in Rust is simpler, and there is no need to write additional shell scripts generated by protoc, but it is more elegantly implemented through build.rs!

More ways to use tonic:

appendix

source code:

Open source library:

Reference article:

This article is reproduced from: https://jasonkayzk.github.io/2022/12/03/Rust%E7%9A%84GRPC%E5%AE%9E%E7%8E%B0Tonic/
This site is only for collection, and the copyright belongs to the original author.