DEV Community

loading...

Setting Up a gRPC Protobuf Server With Tonic

transienterror profile image Kevin Wu Originally published at kvwu.io ・7 min read

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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\"!"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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!"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Discussion (6)

pic
Editor guide
Collapse
vixorem profile image
Victor

Hi! I am planning to create a web app with grpc but I was told that the majority of host providers don't support grpc. Have you tried to deploy any grpc app? Would be nice if you give me the right way. Thank you!

Collapse
transienterror profile image
Kevin Wu Author

I think gRPC works pretty well with AWS. I can do a little research and make my next post about how to set that up.

Collapse
vixorem profile image
Victor • Edited

That would be great. I wanted to use it for project in my uni so the post would be very helpful

Thread Thread
transienterror profile image
Thread Thread
vixorem profile image
Victor

Thank you very much!

Collapse
yooakim profile image
Joakim Westin

Thanks for sharing