Creating a Microservice in Rust Using gRPC

Creating a Microservice in Rust Using gRPC

ยท

7 min read

TL;DR Le Code

Introduction

In today's Rust ๐Ÿฆ€ tutorial, we will be discovering the world of gRPC. For this, we will create a very simple microservice with a single endpoint which will echo back the message we send to it. To test our microservice, we're also going to create a simple Rust ๐Ÿฆ€ client. We are also going to use some of the features we learned in the previous blog posts from my series on Rust ๐Ÿฆ€.

If you haven't read the previous blog posts, I recommend you to go ahead and read them before:

Prerequisites

Before we start, we need to make sure we have the following tools installed:

  • Rust

  • An IDE or text editor of your choice

  • Protocol Buffer Compiler (protoc)

Install protoc

To generate the gRPC code, we need to install the protoc compiler. You can find the installation instructions for your platform here.

If you are on macOS, you can install it using Homebrew:

brew install protobuf

Ensure that the protoc compiler is available in your PATH:

protoc --version # should print the version
# libprotoc 3.21.9

Now we have all set up, let's get started and talk a bit about: What is gRPC ๐Ÿ“ก?

What is gRPC ๐Ÿ“ก?

In gRPC, a client application can directly call methods on a server application on a different machine as if it was a local object, making it easier to create distributed applications and services. On the server side, the server implements the service and runs a gRPC server to handle client calls. On the client side, the client has a stub that provides the same methods as the server.

Protocol Buffers

Protocol Buffers are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data and are used by gRPC by default.

Let me give you an example of how Protocol Buffers work. The first step is to define the data structure in a file with a .proto extension. Protocol buffer data is structured in messages, which are collections of named fields. Here is a very simplified example of a message:

message Weather {
  string city = 1;
  int32 temperature = 2;
}

Once we have defined our message, we can use the protoc compiler to generate the data access classes in your preferred language from your proto definition. The generated classes will have accessors for each of the fields in the message.

You can define gRPC services in the same .proto file as the messages, with RPC methods that use those messages.

service WeatherService {
  rpc GetWeather (WeatherRequest) returns (WeatherResponse) {}
}

message WeatherRequest {
  string city = 1;
}

message WeatherResponse {
  string forecast = 1;
}

You can then use the protoc compiler to generate the gRPC client and server interfaces from your .proto service.

Building the Microservice in Rust ๐Ÿฆ€

Create a New Project

Let's start by creating a new project using the cargo command:

cargo new

Add CLI Support

We are going to use the clap crate to add CLI support to our microservice and client. Add the dependency to your project via following command:

cargo add clap --features derive

Create the Proto File

Now we create a new directory called proto and inside this folder we create a new file called echo.proto in it. Here we define our service and the messages we want to use:

syntax = "proto3";
package api;

message EchoRequest {
  string message = 1;
}

message EchoResponse {
  string message = 1;
}

service EchoService {
  rpc Echo(EchoRequest) returns (EchoResponse);
}

Generate the Rust ๐Ÿฆ€ Code From the Proto File

To generate the Rust ๐Ÿฆ€ code from the proto file, we're going to use the tonic-build crate. We need to add it to our project as a build dependency via following command:

cargo add tonic-build --build

Now we can add the following code to our build.rs file:

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

Normally, you would run cargo build to generate the Rust ๐Ÿฆ€ code from the proto file. In IntelliJ IDEA, you may have to activate org.rust.cargo.evaluate.build.scripts in the Experimental Features settings to make it work.

tonic-build is part of the tonic crate, which is a gRPC over HTTP/2 implementation focused on high performance, interoperability, and flexibility. It's built on top of hyper, tokio, and prost.

Some features of tonic are:

  • Bi-directional streaming

  • High performance async io

  • Interoperability

  • TLS backed by rustls

  • Load balancing

  • Custom metadata

  • Authentication

  • Health Checking

Finally, we're going to add tokio as a dependency to our project:

cargo add tokio --features macros,rt-multi-thread

Now with all the gRPC ๐Ÿ“ก code generated, we can start implementing our microservice.

Implement the Microservice

Let's start by creating a new file called server.rs.rs in our src directory. Here we're going to implement our server logic.

First, we need to import the generated code from our proto file, as well as the tonic and clap crates:

use tonic::{transport::Server, Request, Response, Status};

use api::echo_service_server::{EchoService, EchoServiceServer};
use api::{EchoRequest, EchoResponse};

use ::clap::{Parser};

We also need to include the generated proto server and client items using the include_proto! macro:

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

Now we can implement the service logic of our microservice. We're going to implement the Echo method of our service, which will echo back the message we send to it. We use here the async keyword to make the function asynchronous and #[tonic::async_trait] to make it compatible with tonic. If you want to learn more about async/await in Rust ๐Ÿฆ€, feel free to check out my previous blog post: ---

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

#[tonic::async_trait]
impl EchoService for Echo {
    async fn echo(&self, request: Request<EchoRequest>) -> Result<Response<EchoResponse>, Status> {
        println!("Got a request: {:?}", request);

        let reply = EchoResponse {
            message: format!("{}", request.into_inner().message),
        };

        Ok(Response::new(reply))
    }
}

Now we can start our server and listen for incoming requests, we're going to use clap to configure our server host and port. Details on how to use clap can be found in my previous article ---

#[derive(Parser)]
#[command(author, version)]
#[command(about = "echo-server - a simple echo microservice", long_about = None)]
struct ServerCli {
    #[arg(short = 's', long = "server", default_value = "127.0.0.1")]
    server: String,
    #[arg(short = 'p', long = "port", default_value = "50052")]
    port: u16,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = ServerCli::parse();
    let addr = format!("{}:{}", cli.server, cli.port).parse()?;
    let echo = Echo::default();

    println!("Server listening on {}", addr);

    Server::builder()
        .add_service(EchoServiceServer::new(echo))
        .serve(addr)
        .await?;

    Ok(())
}

Next, we define a bin target in our Cargo.toml file:

[[bin]]
name = "echo-server"
path = "src/server.rs"

Start our server:

cargo run --bin echo-server

And you should see the following output:

Server listening on 127.0.0.1:50052

You can configure the server host and port using the --server and --port flags:

cargo run --bin echo-server -- --server 0.0.0.0 --port 50051

Implement the Client

For the client, we will add the following lines to our existing main.rs file. First, we need to import the generated code from our proto file, as well as the clap crate for parsing command line arguments:

use api::echo_service_client::EchoServiceClient;
use api::EchoRequest;
use ::clap::{Parser};

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

Similar to the server, we're going to use clap to configure our client host and port. But we are going to use the argument called message to send a message of our choice to the server:

#[derive(Parser)]
#[command(author, version)]
#[command(about = "echo - a simple CLI to send messages to a server", long_about = None)]
struct ClientCli {
    #[arg(short = 's', long = "server", default_value = "127.0.0.1")]
    server: String,
    #[arg(short = 'p', long = "port", default_value = "50052")]
    port: u16,
    /// The message to send
    message: String,
}

What is left is the main function, which will create a client and send a message to the server. As we use async/await, we need to use the tokio runtime. This is done by adding the #[tokio::main] attribute to our main function:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = ClientCli::parse();

    let mut client = EchoServiceClient::connect(format!("http://{}:{}", cli.server, cli.port)).await?;

    let request = tonic::Request::new(EchoRequest {
        message: cli.message,
    });

    let response = client.echo(request).await?;

    println!("RESPONSE={:?}", response.into_inner().message);

    Ok(())
}

We define a bin target in our Cargo.toml file for our client as well:

[[bin]]
name = "echo-client"
path = "src/main.rs"

Start our client:

cargo run --bin echo-client -- "Hello World!"

You should see the following output:

RESPONSE="Hello World"

You can configure the server host and port using the --server and --port flags, similar to the server.

Wrapping Up

In this article, we have learned how to use tonic and clap to create a simple gRPC microservice in Rust ๐Ÿฆ€. We have also learned how to write a proto file and generate the client and server code using tonic-build via the build.rs

As always in tech, this kind of article can give you only a basic overview of the topic. Subjects like gRPC are much more complex and I highly recommend to continue reading the official documentation of tonic and gRPC to learn more about the topic.

ย