Last week I was showcasing WebAssembly in the cloud using krustlet and Fermyon Spin at Cloudland 2022 in Brühl (Germany). While preparing for the talk, I recognized how much I’d missed Rust 🦀 over the past months.

Although I’ve been reading articles and watching some videos on Rust in general from time to time, I missed doing some hands-on. So I dedicated some time to do a quick refresher for Rust, updated my local installation rustup update, and wrote a small gRPC server in Rust using tonic. However, before we dive into the tutorial, let’s quickly look at what tonic is and what we can do with it.



What is tonic

Tonic is a super lightweight gRPC implementation with batteries (prost, hyper, and protobuf) included to build both gRPC servers and clients. The server and client can leverage HTTP/2 and code generation to generate the necessary code for individual services from standard protocol buffer specifications (.proto).

Let’s build the gRPC service with Rust and tonic

Consider this post a practical and structured introduction to tonic where we build the - perhaps - most simple gRPC service ever. You can find the entire source explained in this tutorial here on GitHub.

Creating the client & server projects

We’ll use cargo (the Rust package manager) to create our projects and bring in all necessary dependencies.

#move to your source directory
cd ~/source

# create a directory for the entire sample (as we will build server and client)
# alternatively, you can do a mkdir and cd...
mkdir rusty-grpc && cd rusty-grpc

# create the projects with cargo
cargo new server
cargo new client

# create a folder for the service definition (proto)
mkdir protos

Define the service definition

Having the folder structure and the projects in place, it’s time to create the service definition using protocol buffers (protos). We will create a single service definition for the sake of this tutorial. That said, move to the protos folder and create a new file called voting.proto (touch voting.proto while being in the ~/source/rusty-grpc/protos folder) and add the following service definition:

syntax = "proto3";
package voting;

service Voting {
    rpc Vote (VotingRequest) returns (VotingResponse);
}

message VotingRequest {
  string url = 1;

    enum Vote {
        UP = 0;
        DOWN = 1;
    }
    Vote vote = 2;    
}

message VotingResponse {
    string confirmation = 1;
}

The proto is pretty self-explaining. We want to expose a service that allows its consumers to vote up or down for a specific url. Nothing fancy. However, it’s more than enough for demonstration purposes.

Let tonic generate the service facade

Now that we’ve defined the proto, it’s time to add the necessary dependencies to our server project and instruct tonic to generate the server-side code based on that service definition.

Let’s install the necessary dependencies for our server project using cargo:

cd server

# add dependencies
cargo add tonic
cargo add prost
co

# add build dependencies
cargo add tonic-build --build

Your Cargo.toml (~/source/rusty-grpc/server/Cargo.toml) should now look like the following snippet:

[package]
name = "server"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
prost = "0.10.4"
tokio = { version = "1.19.2", features = ["macros", "rt-multi-thread"] }
tonic = "0.7.2"

[build-dependencies]
tonic-build = "0.7.2"

Having all dependencies and the build dependency to tonic-build in place, let’s add instructions and make tonic-build generate the plumbing for our service. To do so, let’s create a dedicated file in the server directory with the name build.rs (touch ~/source/rusty-grpc/server/build.rs) and add the following content:

fn main () -> Result<(), Box<dyn std::error::Error>> {
  tonic_build::compile_protos("../protos/voting.proto")?;
  Ok(())
}

Implement the service

All of the following steps take place in server/main.rs. First, let’s add all necessary modules and bring the generated gRPC facade into a dedicated module:

use tonic::{transport::Server, Request, Response, Status};
use voting::{VotingRequest, VotingResponse, voting_server::{Voting, VotingServer}};

pub mod voting {
  tonic::include_proto!("voting");
}

Having this in place, we can take care of implementing the actual service. We do so by defining a VotingService struct and implementing the Voting trait asynchronously (tokio allows us to implement async traits):

#[derive(Debug, Default)]
pub struct VotingService {}

#[tonic::async_trait]
impl Voting for VotingService {
  async fn vote(&self, request: Request<VotingRequest>) -> Result<Response<VotingResponse>, Status> {
    let r = request.into_inner();
    match r.vote {
      0 => Ok(Response::new(voting::VotingResponse { confirmation: { 
        format!("Happy to confirm that you upvoted for {}", r.url)
      }})),
      1 => Ok(Response::new(voting::VotingResponse { confirmation: { 
        format!("Confirmation that you downvoted for {}", r.url)
      }})), 
      _ => Err(Status::new(tonic::Code::OutOfRange, "Invalid vote provided"))
    }
  }
}

The actual logic of the service implementation is straight forward. Depending on what’s provided as vote, we generate a proper response.

Create the underlying server and wire everything up

Having the service implementation in place, we must add the final part of our server implementation, we have to bootstrap the actual server and hook up our service. Replace the automatically generated main method with the following code:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
  let address = "[::1]:8080".parse().unwrap();
  let voting_service = VotingService::default();

  Server::builder().add_service(VotingServer::new(voting_service))
    .serve(address)
    .await?;
  Ok(())
     
}

We use the builder pattern exposed by tonic::Server to register our service, bind the server to a particular socket and start it asynchronously.

Run the server

At this point, you can already start the server by executing cargo run in the ~/sources/rusty-grpc/server folder.

Let’s build a gRPC client with Rust and tonic

Now that we’ve our server up and running, it’s time to implement a client that consumes our gRPC service. Again we’ll use tonic to do the heavy lifting for us, so we can focus on consuming the gRPC service.

Install necessary dependencies for the client project

With a new rust project in place, let’s bring in necessary dependencies using cargo:

cd client

# install dependencies
cargo add tonic
cargo add prost
cargo add tokio -F "macros rt-multi-thread"

# install build-dependencies
cargo add tonic-build --build

At this point the Cargo.toml should look similar to this:

[package]
name = "client"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
prost = "0.10.4"
tokio = { version = "1.19.2", features = ["macros", "rt-multi-thread"] }
tonic = "0.7.2"

[build-dependencies]
tonic-build = "0.7.2"

Now that all dependencies and the build dependency are specified in Cargo.toml, we again instruct tonic-build to generate the plumbing for our service. It’s the same instruction we used for the server. However, to keep both projects independent, create another build.rs this time in the~/source/rusty-grpc/client/ folder and add the following content:

fn main () -> Result<(), Box<dyn std::error::Error>> {
  tonic_build::compile_protos("../protos/voting.proto")?;
  Ok(())
}

Consume the gRPC service from the client

Consuming a gRPC service with tonic is straightforward, for demonstration purposes, let’s add the generated code again using a module and add necessary use statements:

use voting::{VotingRequest, voting_client::VotingClient};

pub mod voting {
  tonic::include_proto!("voting");
}

For the sake of this tutorial, we want to invoke the service as long as the user keeps on providing actual votes. Let’s replace the main function and use a loop to achieve that:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
  let mut client = VotingClient::connect("http://[::1]:8080").await?;
  loop {
    println!("\nPlease vote for a particular url");
    let mut u = String::new();
    let mut vote: String = String::new();
    println!("Please provide a url: ");
    stdin().read_line(&mut u).unwrap();
    let u = u.trim();
    println!("Please vote (d)own or (u)p: ");
    stdin().read_line(&mut vote).unwrap();
    let v = match vote.trim().to_lowercase().chars().next().unwrap() {
      'u' => 0,
      'd' => 1,
      _ => break,
    };
        // here comes the service invocation
  }
  Ok(())
}

Finally, lets replace the comment with the actual service invocation:

let request = tonic::Request::new(VotingRequest {
  url: String::from(u),
  vote: v,
});
let response = client.vote(request).await?;
println!("Got: '{}' from service", response.into_inner().confirmation);

That’s it. No more is required to call an existing gRPC service in Rust using tonic. That said, here is the final ~/source/rusty-grpc/client/main.rs for reference:

use std::io::stdin;

use voting::{voting_client::VotingClient, VotingRequest};

pub mod voting {
  tonic::include_proto!("voting");
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
  let mut client = VotingClient::connect("http://[::1]:8080").await?;
  loop {
    println!("\nPlease vote for a particular url");
    let mut u = String::new();
    let mut vote: String = String::new();
    println!("Please provide a url: ");
    stdin().read_line(&mut u).unwrap();
    let u = u.trim();
    println!("Please vote (d)own or (u)p: ");
    stdin().read_line(&mut vote).unwrap();
    let v = match vote.trim().to_lowercase().chars().next().unwrap() {
      'u' => 0,
      'd' => 1,
      _ => break,
    };
    let request = tonic::Request::new(VotingRequest {
      url: String::from(u),
      vote: v,
    });
    let response = client.vote(request).await?;
    println!("Got: '{}' from service", response.into_inner().confirmation);
  }
  Ok(())
}

Test it by running the gRPC client

Invoke cargo run from within the ~/source/rusty-grpc/client folder and see the service response being printed to STDOUT as shown here:

❯ cargo run
  Compiling client v0.1.0 (/Users/abc/source/rusty-grpc/client)
  Finished dev [unoptimized + debuginfo] target(s) in 2.61s
   Running `target/debug/client`

Please vote for a particular url
Please provide a url:
https://rust-lang.org
Please vote (d)own or (u)p:
u
Got: 'Happy to confirm that you upvoted for https://rust-lang.org' from service

Please vote for a particular url
Please provide a url:
https://thorsten-hans.com
Please vote (d)own or (u)p:
u
Got: 'Happy to confirm that you upvoted for https://thorsten-hans.com' from service

Please vote for a particular url
Please provide a url:

Further optimizations

We can also combine client and server into a single rust project by adding two [[bin]] blocks to Cargo.toml. This would make perfect sense because they share all dependencies and the build instructions. However, having separate projects for client and server made it easier to explain all steps in proper order while maintaining the distinction between client and server. On top of the core functionality, there are more crates available for tonic. For example, consider adding tonic-reflection to support gRPC service discoverability or tonic-health to add standard gRPC health checking capabilities for every service. These are definitely topics worth checking out my blog regularly to spot upcoming articles on those :D

What we’ve covered in this post

  • Created rust projects and managed their dependencies with cargo
  • Implemented a service definition using protocol buffers
  • configured tonic-build to generate necessary code in both client and server
  • implemented client and server logic
  • bootstrapped the gRPC server and added the VotingService as an endpoint

Conclusion

gRPC is commonly used to build APIs in distributed systems (e.g., Microservices). I enjoyed diving into tonic to do a quick Rust refresher. I will give it a spin when addressing more sophisticated (or real-world) requirements to see how it behaves under load while more complex API surfaces.

Additional capabilities like the previously mentioned health checking capabilities provided by tonic-health sound quite exciting and are definitely on my list for the upcoming evenings :D