Setting Up a gRPC Protobuf Server With Tonic

Tags: rust
Published: 2021-04-12 10:18:26

Recently I've been starting to work with gRPC and protobuf. Before this, I was used to using just plain JSON and HTTP. 

[gRPC](https://grpc.io/) 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](https://developers.google.com/protocol-buffers) (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](https://github.com/hyperium/tonic), which is maintained by one of my coworkers, [Lucio](https://luciofran.co/). 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
```bash
$ 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](https://crates.io/crates/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](https://github.com/hyperium/tonic/tree/master/examples/src/helloworld) in the tonic [examples directory](https://github.com/hyperium/tonic/tree/master/examples). I started with a few more dependencies that I noticed are being used in the example.
```bash
cargo add anyhow
cargo add tokio
```

Later on, I realized that [tokio generally wants to be added with a list of features](https://docs.rs/tokio/1.4.0/tokio/#example), so I've adjusted the `Cargo.toml` in my project, so that it looks like:
```toml
[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:
```proto3
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
```rust
pub mod hello_world {
    tonic::include_proto!("helloworld");
}
```

But this didn't work out. My linter, [rust-analyzer](https://rust-analyzer.github.io/), 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](https://github.com/rust-analyzer/rust-analyzer/issues/7897) 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](https://docs.rs/tonic-build/0.4.1/tonic_build/), 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
```rust
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:
```rust
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](https://github.com/ktr0731/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.
```
helloworld.Greeter@127.0.0.1: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.
```proto
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`.
```rust
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:
```
helloworld.Greeter@127.0.0.1:8000> show service
+---------+------------+----------------+---------------+
| SERVICE |    RPC     |  REQUEST TYPE  | RESPONSE TYPE |
+---------+------------+----------------+---------------+
| Greeter | SayHello   | HelloRequest   | HelloReply    |
| Greeter | SayGoodbye | GoodbyeRequest | GoodbyeReply  |
+---------+------------+----------------+---------------+

helloworld.Greeter@127.0.0.1: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:
```proto
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:
```rust
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.
```rust
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:
```
helloworld.Greeter@127.0.0.1:8000> call SayHello
name (TYPE_STRING) => kev
{
  "message": "Hello kev!"
}

helloworld.Greeter@127.0.0.1:8000> 

health.HealthCheck@127.0.0.1:9000> call isHealthy
{
  "isHealthy": true
}
```