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-buildlibrary’scompile_protosfunction 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
protocthese are the files you’d pass with the-Iflag. \
let dirs = &["."];
The directories under which
protocshould look for dependencies in the proto file. Then theconfigurefunction in itself. \
tonic_build::configure()
.build_client(true)
.compile(iface_files, dirs)
.unwrap_or_else(|e| panic!("protobuf compilation failed: {}", e));
The
build_clientoption determines whether to build the client-specific code,build-serverbeing another option. Thecompileoption takes in theprotosas the first argument andincludesas the second argument (read:-Ifromprotoc). \
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 port50051while 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.