A minimal Rust based gRPC client and server (using tonic-rs)
Background reading/references:
Some popular crates:
Some references to popular project(s) which are using Tonic (tonic-build):
I chose Tonic because it's used in Linkerd a project I really like. I couldn't find an ADOPTERS file on the project, so I'm not sure who else is using it. A simple google search wasn't really useful. So if you do find any other major projects using Tonic or if you yourself are using it, I would love to hear about how you're using it.
- Define a service in a proto file
- Generate Rust protobuf definitions and service definitions
- Create a gRPC server binary for the service
- Create a gRPC client binary for interacting with the service
Cargo is the dependency management tool for Rust. You can install it from here.
To create a new package with Cargo, use cargo new
:
cargo new rust-grpc-example
cd rust-grpc-example
Cargo will now automatically create a git repo. And populate it with bare-essentials.
Recently, I used the CLion IDE's in-built project creation workflow and it offers the same Cargo workflow but with a couple of clicks. There's a free license program available and you can get it if you qualify, check your qualification.
Let's take a look at the Cargo.toml
file. For people coming from Go, this is kind of like the go.mod
but better.
For people coming from python, well this is a really cool way to do dependency management.
[package]
name = "rust-grpc-example"
version = "0.1.0"
authors = ["swiftdiaries <adhita94@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
Add tonic related dependencies to the project.
[dependencies]
tonic = "0.7"
prost = "0.10.1"
tokio = { version = "1.18", features = ["macros", "rt-multi-thread"] }
[build-dependencies]
tonic-build = "0.7"
The tonic-build
crate is built on top of the prost-build
crate that we used in our last blog post. Check out the tonic-build
docs for more configuration options while generating service definitions.
Generally, I find it easier to grok if I have proto files in a separate directory. Especially when dealing with multiple languages as is common with gRPC-based
services.
# Create a directory for proto files and a file to hold them
mkdir -p api/protos
touch api/protos/greeter.proto
# Create the service definition
cat << EOF >> src/greeter.proto
syntax = "proto3";
package greeter;
message HelloRequest {
string name = 1;
}
message HelloResponse {
string message = 1;
}
service Greeter {
rpc SayHello (HelloRequest) returns (HelloResponse);
}
EOF
Any file named build.rs
and placed at the root of the package will act like a build-script. Cargo will compile and execute it before compiling the files inside the src/
directory.
touch build.rs
cat << EOF >> build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("api/protos/greeter.proto")?;
Ok(())
}
EOF
tonic_build::compile_protos("api/protos/greeter.proto")?;
We're using the
tonic-build
library'scompile_protos
function with the filepath to the proto file relative to the root of the project as the input.
cargo build
And boom, you have the generated stubs ! ππππ
The name of the package defined in the proto file determines the name of the output .rs
file.
By default, tonic-build stores the generated output stubs in target/debug/build/{project-name}-{hash}/out
directory.
The {project-name}
corresponds to your project name and the {hash}
refers to the current cargo build
hash.
You can also use the configure
function in tonic_build instead of compile_protos
to customize your code generation.
The configure function returns a Builder struct.
Reference: All the options to configure.
To see how it works in context, let's take a look at how a sub-project in the linkerd project uses the function.
fn main() {
let iface_files = &["opencensus/proto/agent/trace/v1/trace_service.proto"];
let dirs = &["."];
tonic_build::configure()
.build_client(true)
.compile(iface_files, dirs)
.unwrap_or_else(|e| panic!("protobuf compilation failed: {}", e));
// recompile protobufs only if any of the proto files changes.
for file in iface_files {
println!("cargo:rerun-if-changed={}", file);
}
}
let iface_files = &["opencensus/proto/agent/trace/v1/trace_service.proto"];
This defines proto files we're going to generate stubs for. If you recall, with
protoc
these are the files you'd pass with the-I
flag. \
let dirs = &["."];
The directories under which
protoc
should look for dependencies in the proto file. Then theconfigure
function in itself. \
tonic_build::configure()
.build_client(true)
.compile(iface_files, dirs)
.unwrap_or_else(|e| panic!("protobuf compilation failed: {}", e));
The
build_client
option determines whether to build the client-specific code,build-server
being another option. Thecompile
option takes in theprotos
as the first argument andincludes
as the second argument (read:-I
fromprotoc
). \
References:
Source Code
Credits: Author
Let's take a peek at the generated stubs.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct HelloRequest {
#[prost(string, tag = "1")]
pub name: std::string::String,
}
This is the generated struct for the HelloRequest message
message HelloRequest {
string name = 1;
}
``` \
```rust
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct HelloResponse {
#[prost(string, tag = "1")]
pub message: std::string::String,
}
This is the generated struct for the HelloResponse message
message HelloRequest {
string name = 1;
}
``` \
```rust
#[doc = r" Generated client implementations."]
pub mod greeter_client {
...
}
#[doc = r" Generated server implementations."]
pub mod greeter_server {
...
}
These are the client and server stubs for the Greeter service
service Greeter {
rpc SayHello (HelloRequest) returns (HelloResponse);
}
\
Now, let's see how to import this into our gRPC Server and Client code.
First, we're going to build the gRPC Server. Create a file src/server.rs
.
touch src/server.rs
Define the Tonic-based building blocks for the server.
Import the generated stubs by specifying the package name. \
use tonic::{transport::Server, Request, Response, Status};
use greeter::greeter_server::{Greeter, GreeterServer};
use greeter::{HelloResponse, HelloRequest};
// Import the generated proto-rust file into a module
pub mod greeter {
tonic::include_proto!("greeter");
}
Define the service skeleton for the Greeter Service. Implement the individual functions that the service holds.
For example,SayHello
\
// Implement the service skeleton for the "Greeter" service
// defined in the proto
#[derive(Debug, Default)]
pub struct MyGreeter {}
// Implement the service function(s) defined in the proto
// for the Greeter service (SayHello...)
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<Response<HelloResponse>, Status> {
println!("Received request from: {:?}", request);
let response = greeter::HelloResponse {
message: format!("Hello {}!", request.into_inner().name).into(),
};
Ok(Response::new(response))
}
}
Use the tokio runtime to create an instance of a gRPC Server. The tokio instance takes in the implemented service definition above as input.
And serves it over the port50051
while waiting for requests. \
// Use the tokio runtime to run our server
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:50051".parse()?;
let greeter = MyGreeter::default();
println!("Starting gRPC Server...");
Server::builder()
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
Add the server.rs
to Cargo.toml
to able to build, run it as a binary.
[[bin]] # Bin to run the HelloWorld gRPC server
name = "helloworld-server"
path = "src/server.rs"
cargo run --bin helloworld-server
And boom, you now have a running gRPC Server in Rust.
Quickly test the server with grpcurl.
grpcurl -plaintext -import-path ./api/protos \
-proto ./api/protos/greeter.proto \
-d '{"name": "Tonic"}' \
[::]:50051 \
greeter.Greeter/SayHello
Second, we build the client to send requests to the gRPC server. Create a file src/client.rs
.
touch src/client.rs
Import the generated stubs with the package name. \
use greeter::greeter_client::GreeterClient;
use greeter::HelloRequest;
// Import the generated proto-rust file into a module
pub mod greeter {
tonic::include_proto!("greeter");
}
Use generated client interface on the stubs to connect to the gRPC server at port
50051
.
Create a request with the tokio library's Request method and the generated request struct to call the functionSayHello
Wait for the request to fetch a response \
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = GreeterClient::connect("http://[::1]:50051").await?;
let request = tonic::Request::new(HelloRequest {
name: "Tonic".into(),
});
println!("Sending request to gRPC Server...");
let response = client.say_hello(request).await?;
println!("RESPONSE={:?}", response);
Ok(())
}
Add the client.rs
to the Cargo.toml
to build, run it as a binary.
[[bin]] # Bin to run the HelloWorld gRPC client
name = "helloworld-client"
path = "src/client.rs"
In a separate terminal run,
cargo run --bin helloworld-client
And voila π π π
Please feel free to open an issue in the GitHub repo if you find mistakes here.