Rust 🤝🏾 gRPC - Using Tonic
This is a continuation of the series on developing Rust based services. You can take a look at how you can generate protobuf stubs with Rust-based libraries like Prost in my previos post here.
Give me the code: GitHub repo.
Background reading/references:
Protobuf service definitions to Rust codegen
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.
Tonic
Goals for today
- 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
Setup a Rust project
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.
Add dependencies
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.3"
prost = "0.6"
tokio = { version = "0.2", features = ["macros"] }
[build-dependencies]
tonic-build = "0.3"
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.
Fun part
Create the service definition in a proto file
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
Tonic-build to compile the proto-based service definition
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.
Build the stubs
cargo build
And boom, you have the generated stubs ! 🎉🎉🎉🎉
But, where is it?
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.
Configuration options
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.
Configuration example
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
Use the stubs
Generated stubs
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.
Build a gRPC Server with Tonic
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(())
}
Run the gRPC Server
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.
Test the server
Quickly test the server with grpcurl.
grpcurl -plaintext -import-path ./api/protos \
-proto ./api/protos/greeter.proto \
-d '{"name": "Tonic"}' \
[::]:50051 \
greeter.Greeter/SayHello
Build a gRPC Client with Tonic
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(())
}
Run the gRPC Client
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.