Setting Up a gRPC Protobuf Server With Tonic
Recently I’ve been starting to work with gRPC and protobuf. Before this, I was used to using just plain JSON and HTTP.
gRPC is a system developed by Google for fast RPC-style communication between miroservices. It uses HTTP/2 as its transport protocol, and by default, it uses protobuf (analogous to JSON) as its serialization format.
I really like the idea of having a strong contract between the client and server. I’ve always been interested in type safety and proofs as code. Traditionally, with JSON, the only contract between client and server is that the reply more or less is in the JSON format and will fit the JSON specification, but you can get no guarantees regarding the structure or data that is transferred. With protobuf, the client and server agree on a contract beforehand with proto
files that define a structure and datatypes. This would allow us to create more powerful proofs that span more than one service in a miroservice architecture. And of course, gRPC promises to be a performant protocol because it sends nearly as few bytes as possible for each message, which generally translates to speed over the network.
This post documents my experience setting up an example project with rust and tonic, which is maintained by one of my coworkers, Lucio. I will set up a simple server, add some other APIs and set up a second server to do health checks.
Set up a new cargo project
$ cargo new grpc-demo
Created binary (application) `grpc-demo` package
$ cd grpc-demo
$ cargo add tonic
If you’re used to rust, this shouldn’t be difficult to follow. It’s the general way that one starts a project in the rust world. You may need to install cargo-edit in order to have cargo add
, but it’s well worth it for managing dependencies in your cargo project.
Set up a simple tonic server
I figured I should start with the helloworld example in the tonic examples directory. I started with a few more dependencies that I noticed are being used in the example.
cargo add anyhow
cargo add tokio
Later on, I realized that tokio generally wants to be added with a list of features, so I’ve adjusted the Cargo.toml
in my project, so that it looks like:
[dependencies]
anyhow = "1.0.40"
tokio = {version = "1.4.0", features = ["full"]}
tonic = "0.4.1"
Creating the protobuf files
At this point, I was creating the proto files in my project. I’ve never used protobuf before, so I expected this to be one of the most challenging sections.
My first instinct was to create the proto files in the src
folder with the main.rs
file.
It ended up looking like this:
syntax = "proto3";
package helloworld;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
Learning to include the protobuf files into our main.rs
When returning to my main.rs
, I added
pub mod hello_world {
tonic::include_proto!("helloworld");
}
But this didn’t work out. My linter, rust-analyzer, gave me an error “OUT_DIR
not set, enable “load out dirs from check” to fix”". This had recently been a bug with rust-analyzer, but even with building with cargo build
, I still got “environment variable OUT_DIR
not defined”. I knew that this had something to do with how tonic generates code for the proto files.
The original message from rust-analyzer was confusing to me because I remembered turning it on, but I couldn’t find it anymore. Rust-analyzer is always changing quickly, so my first instinct was that maybe they had changed the configuration recently. Looking at the issues for rust-analyzer, I found one that said the setting had been renamed to runBuildScripts. This one happened to be on in my configuration, so I decided it might be better to look into the other error message.
After googling the other error message, I noticed a few issues on Github where people had the wrong directory structure, and this seemed very likely to me because I had just guessed the directory structure when I put my proto files in src
.
Looking through the tonic documentation, it looked like I would need to compile the proto files.
This being my first time working with generated code and deviating from the default build process for rust, I quickly realized that I’d have to create a build.rs
file in the project root (not src
), and include
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/helloworld.proto")?;
Ok(())
}
This also required me to cargo add -B tonic-build
, create a new proto
directory for the proto files, and move helloworld.proto
into that directory. With all of those changes, my code started to build.
One more interesting thing I found is that normally you can’t make a function in a trait async. I got the error message
functions in traits cannot be declared `async`
`async` trait functions are not currently supported
but tonic gets by this with the #[tonic::async_trait]
macro on the trait.
Finally, writing out our simple server
From there on out, everything else was straightforward. I simply needed to create a struct that implements the hello function that you want, and start the server. My final result looked like this:
use hello_world::{greeter_server::{Greeter, GreeterServer}, HelloReply, HelloRequest};
use std::net::SocketAddr;
use tonic::{Request, Response, Status, transport::Server};
use anyhow::Result;
pub mod hello_world {
tonic::include_proto!("helloworld");
}
#[derive(Default)]
pub struct MyGreeter {}
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(&self, request: Request<HelloRequest>) -> std::result::Result<Response<HelloReply>, Status> {
let reply = HelloReply {
message: format!("Hello {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
}
#[tokio::main]
async fn main() -> Result<()> {
let addr: SocketAddr = "127.0.0.1:8000".parse()?;
let greeter = MyGreeter::default();
Server::builder()
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
Testing the server
After running the server with cargo run
, I needed a way to test that the server works. I had heard of an interesting tool called evans, so I decided to use this.
It took me a while to figure out the right parameters to query the server, especially because tonic doesn’t seem to support gRPC reflection right now, and there are few examples out there.
What ended up working for me was:
evans --proto proto/helloworld.proto -p 8000
This was particularly confusing because there is an argument called --path
, which doesn’t work.
However, after figuring out how evans
works, I was able to call my service.
[email protected]:8000> call SayHello
name (TYPE_STRING) => "kev"
{
"message": "Hello \"kev\"!"
}
It works!
Adding a new API
To check my understanding, I wanted to add a new API that is like Greeter but says goodbye instead.
I started with adding my new rpc and messages in the proto files.
syntax = "proto3";
package helloworld;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
rpc SayGoodbye (GoodbyeRequest) returns (GoodbyeReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
message GoodbyeRequest {
string name = 1;
}
message GoodbyeReply {
string message = 1;
}
I added a new function in main.rs
.
use hello_world::{
greeter_server::{Greeter, GreeterServer},
GoodbyeReply, GoodbyeRequest, HelloReply, HelloRequest,
};
use std::net::SocketAddr;
use tonic::{transport::Server, Request, Response, Status};
use anyhow::Result;
pub mod hello_world {
tonic::include_proto!("helloworld");
}
#[derive(Default)]
pub struct MyGreeter {}
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> std::result::Result<Response<HelloReply>, Status> {
let reply = HelloReply {
message: format!("Hello {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
async fn say_goodbye(
&self,
request: Request<GoodbyeRequest>,
) -> std::result::Result<Response<GoodbyeReply>, Status> {
let reply = GoodbyeReply {
message: format!("Goodbye {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
}
#[tokio::main]
async fn main() -> Result<()> {
let addr: SocketAddr = "127.0.0.1:8000".parse()?;
let greeter = MyGreeter::default();
Server::builder()
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
I did a sanity check with evans
to show that it worked:
[email protected]:8000> show service
+---------+------------+----------------+---------------+
| SERVICE | RPC | REQUEST TYPE | RESPONSE TYPE |
+---------+------------+----------------+---------------+
| Greeter | SayHello | HelloRequest | HelloReply |
| Greeter | SayGoodbye | GoodbyeRequest | GoodbyeReply |
+---------+------------+----------------+---------------+
[email protected]:8000> call SayGoodbye
name (TYPE_STRING) => kev
{
"message": "Goodbye kev!"
}
Create a health-check service that runs on a second server and port.
Once again, I started by creating a new proto file called health.proto
with my new HealthCheck service defined in the same proto
directory:
syntax = "proto3";
package health;
service HealthCheck {
rpc isHealthy(HealthCheckRequest) returns (HealthCheckReply);
}
message HealthCheckRequest {
}
message HealthCheckReply {
bool isHealthy = 1;
}
Then, I edited the build.rs
file to include this new proto file:
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/helloworld.proto")?;
tonic_build::compile_protos("proto/health.proto")?;
Ok(())
}
This time I had to cargo add futures
in order to import try_join
, but it was relatively straightforward to implement a new HealthChecker
struct, which just always returns true. I then set up a new server instance in main
and used try_join!
to run them both concurrently.
use healthcheck::{
health_check_server::{HealthCheck, HealthCheckServer},
HealthCheckReply, HealthCheckRequest,
};
use hello_world::{
greeter_server::{Greeter, GreeterServer},
GoodbyeReply, GoodbyeRequest, HelloReply, HelloRequest,
};
use futures::try_join;
use std::net::SocketAddr;
use tonic::{transport::Server, Request, Response, Status};
use anyhow::Result;
pub mod hello_world {
tonic::include_proto!("helloworld");
}
pub mod healthcheck {
tonic::include_proto!("health");
}
#[derive(Default)]
pub struct MyGreeter {}
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> std::result::Result<Response<HelloReply>, Status> {
let reply = HelloReply {
message: format!("Hello {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
async fn say_goodbye(
&self,
request: Request<GoodbyeRequest>,
) -> std::result::Result<Response<GoodbyeReply>, Status> {
let reply = GoodbyeReply {
message: format!("Goodbye {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
}
#[derive(Default)]
pub struct HealthChecker {}
#[tonic::async_trait]
impl HealthCheck for HealthChecker {
async fn is_healthy(
&self,
_request: Request<HealthCheckRequest>,
) -> std::result::Result<Response<HealthCheckReply>, Status> {
Ok(Response::new(HealthCheckReply {
is_healthy: true
}))
}
}
#[tokio::main]
async fn main() -> Result<()> {
let addr: SocketAddr = "127.0.0.1:8000".parse()?;
let greeter = MyGreeter::default();
let health_addr: SocketAddr = "127.0.0.1:9000".parse()?;
let greeter_server =Server::builder()
.add_service(GreeterServer::new(greeter))
.serve(addr);
let health_server = Server::builder()
.add_service(HealthCheckServer::new(HealthChecker::default()))
.serve(health_addr);
try_join!(greeter_server, health_server)?;
Ok(())
}
Finally, I tested it with evans
again, and it showed that both services were up and running:
[email protected]:8000> call SayHello
name (TYPE_STRING) => kev
{
"message": "Hello kev!"
}
[email protected]:8000>
[email protected]:9000> call isHealthy
{
"isHealthy": true
}