Getting started with gRPC-Rust

1. Introduction

In this codelab, you'll use gRPC-Rust to create a client and server that form the foundation of a route-mapping application written in Rust.

By the end of the tutorial, you will have a client that connects to a remote server using gRPC to get the name or postal address of what's located at specific coordinates on a map. A fully fledged application might use this client-server design to enumerate or summarize points of interest along a route.

The service is defined in a Protocol Buffers file, which will be used to generate boilerplate code for the client and server so that they can communicate with each other, saving you time and effort in implementing that functionality.

This generated code takes care of not only the complexities of the communication between the server and client, but also data serialization and deserialization.

What you'll learn

  • How to use Protocol Buffers to define a service API.
  • How to build a gRPC-based client and server from a Protocol Buffers definition using automated code generation.
  • An understanding of client-server communication with gRPC.

This codelab is aimed at Rust developers new to gRPC or seeking a refresher of gRPC, or anyone else interested in building distributed systems. No prior gRPC experience is required.

2. Before you begin

Prerequisites

Make sure you have installed the following:

  • GCC. Follow instructions here
  • Rust, version 1.89.0. Follow installation instructions here.

Get the code

So that you don't have to start entirely from scratch, this codelab provides a scaffold of the application's source code for you to complete. The following steps will show you how to finish the application, including using the protocol buffer compiler plugins to generate the boilerplate gRPC code.

First, create the codelab working directory and cd into it:

mkdir grpc-rust-getting-started && cd grpc-rust-getting-started

Download and extract the codelab:

curl -sL https://github.com/grpc-ecosystem/grpc-codelabs/archive/refs/heads/v1.tar.gz \
  | tar xvz --strip-components=4 \
  grpc-codelabs-1/codelabs/grpc-rust-getting-started/start_here

Alternatively, you can download the .zip file containing only the codelab directory and manually unzip it.

The completed source code is available on GitHub if you want to skip typing in an implementation.

3. Define the service

Your first step is to define the application's gRPC service, its RPC method, and its request and response message types using Protocol Buffers. Your service will provide:

  • An RPC method called GetFeature that the server implements and the client calls.
  • The message types Point and Feature that are data structures exchanged between the client and server when using the GetFeature method. The client provides map coordinates as a Point in its GetFeature request to the server, and the server replies with a corresponding Feature that describes whatever is located at those coordinates.

This RPC method and its message types will all be defined in the proto/route_guide.proto file of the provided source code.

Protocol Buffers are commonly known as protobufs. For more information on gRPC terminology, see gRPC's Core concepts, architecture, and lifecycle.

Service method

Let's first define our service methods and then define our message types Point and Feature. The proto/routeguide.proto file has a service structure named RouteGuide that defines one or more methods provided by the application's service.

Add the rpc method GetFeature inside the RouteGuide definition. As explained earlier, this method will look up the name or address of a location from a given set of coordinates, so have GetFeature return a Feature for a given Point:

service RouteGuide {
  // Definition of the service goes here

  // Obtains the feature at a given position.
  rpc GetFeature(Point) returns (Feature) {}
}

This is a unary RPC method: a simple RPC where the client sends a request to the server and waits for a response to come back, just like a local function call.

Message types

In the proto/route_guide.proto file of the source code, first define the Point message type. A Point represents a latitude-longitude coordinate pair on a map. For this codelab, use integers for the coordinates:

message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

The numbers 1 and 2 are unique ID numbers for each of the fields in the message structure.

Next, define the Feature message type. A Feature uses a string field for the name or postal address of something at a location specified by a Point:

message Feature {
  // The name or address of the feature.
  string name = 1;

  // The point where the feature is located.
  Point location = 2;
}

4. Generate the client and server code

We have already given you the generated code from the .proto file in the generated directory.

As with any project, we need to think of the dependencies that are necessary for our code. For Rust projects, the dependencies will be in Cargo.toml. We have already listed the necessary dependencies in the Cargo.toml file.

If you want to learn how to generate code from the .proto file yourself, refer to these instructions.

The generated code contains:

  • Struct definitions for message types Point and Feature.
  • A service trait we'll need to implement: route_guide_server::RouteGuide.
  • A client type we'll use to call the server: route_guide_client::RouteGuideClient<T>.

Next, we'll implement the GetFeature method on the server-side, so that when the client sends a request, the server can reply with an answer.

5. Implement the service

In src/server/server.rs, we can bring the generated code into scope through gRPC's include_generated_proto! macro and import the RouteGuide trait and Point.

mod grpc_pb {
    grpc::include_generated_proto!("generated", "routeguide");
}

pub use grpc_pb::{
    route_guide_server::{RouteGuideServer, RouteGuide},
    Point, Feature,
};

We can start by defining a struct to represent our service, we can do this on src/server/server.rs for now:

#[derive(Debug)]
pub struct RouteGuideService {
    features: Vec<Feature>,
}

Now, we need to implement the route_guide_server::RouteGuide trait from our generated code.

Unary RPC

The RouteGuideService implements all our service methods. The get_feature function on the server side is where the main work is done: this takes a Point message from the client and returns in a Feature message the corresponding location information from a list of known places. Here's the function's implementation in src/server/server.rs:

#[tonic::async_trait]
impl RouteGuide for RouteGuideService {
    async fn get_feature(&self, request: Request<Point>) -> Result<Response<Feature>, Status> {
        println!("GetFeature = {:?}", request);
        let requested_point = request.get_ref();
        for feature in self.features.iter() {
            if feature.location().latitude() == requested_point.latitude() {
                if feature.location().longitude() == requested_point.longitude(){
                    return Ok(Response::new(feature.clone()))
                };
            };    
        }
        Ok(Response::new(Feature::default()))
    }
}

In the method, populate a Feature object with the appropriate information for the given Point, and then return it.

Once we've implemented this method, we also need to start up a gRPC server so that clients can actually use our service. Replace main()with this.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:10000".parse().unwrap();
    println!("RouteGuideServer listening on: {addr}");
    let route_guide = RouteGuideService {
        features: load(),
    };
    let svc = RouteGuideServer::new(route_guide);
    Server::builder().add_service(svc).serve(addr).await?;
    Ok(())
}

Here's what is happening in main(), step by step:

  1. Specify the port we want to use to listen for client requests
  2. Create a RouteGuideService with features loaded in by calling the helper function load()
  3. Create an instance of the gRPC server using RouteGuideServer::new() using the service we created.
  4. Register our service implementation with the gRPC server.
  5. Call serve() on the server with our port details to do a blocking wait until the process is killed.

6. Create the client

In this section, we'll look at creating a Rust client for our RouteGuide service in src/client/client.rs.

As we did in src/server/server.rs, we can bring the generated code into scope through gRPC's include_generated_code! macro and import the RouteGuideClient type.

mod grpc_pb {
    grpc::include_generated_proto!("generated", "routeguide");
}

use grpc_pb::{
    route_guide_client::RouteGuideClient,
    Point,
};

Call service methods

In gRPC-Rust, RPCs operate in a blocking/synchronous mode, which means that the RPC call waits for the server to respond, and will either return a response or an error.

To call service methods, we first need to create a channel to communicate with the server. We create this by first creating an endpoint, connecting to that endpoint, and passing the channel made when connected to RouteGuideClient::new() as follows:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create endpoint to connect to
    let endpoint = Endpoint::new("http://[::1]:10000")?; 
    let channel = endpoint.connect().await?;             

    // Create a new client
    let mut client = RouteGuideClient::new(channel);
    Ok(())
}

In this function, when we create the client, we wrap the generic channel created above with the generated code stub that implements the specific methods defined in the .proto service.

Simple RPC

Calling the simple RPC GetFeature is nearly as straightforward as calling a local method. Add this in main().

println!("*** SIMPLE RPC ***");
let point = proto!(Point{
    latitude: 409_146_138,
    longitude: -746_188_906
});
let response = client
    .get_feature(Request::new(point))
    .await?.into_inner();
Ok(())

As you can see, we call the method on the stub we got earlier. In our method parameters we create and populate a request protocol buffer object (in our case Point). If the call doesn't return an error, then we can read the response information from the server from the first return value.

println!("Response = Name = \"{}\", Latitude = {}, Longitude = {}",
    response.name(),
    response.location().latitude(),
    response.location().longitude());

In all, the client's main() function should look like this:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    //Create endpoint to connect to
    let endpoint = Endpoint::new("http://[::1]:10000")?; 
    let channel = endpoint.connect().await?;             

    // Create a new client
    let mut client = RouteGuideClient::new(channel); 

    println!("*** SIMPLE RPC ***");
    let point = proto!(Point{
        latitude: 409_146_138,
        longitude: -746_188_906
    });
    let response = client
        .get_feature(Request::new(point))
        .await?.into_inner();

    println!("Response = Name = \"{}\", Latitude = {}, Longitude = {}",
        response.name(),
        response.location().latitude(),
        response.location().longitude());
    Ok(())
}

7. Try it out

First, to run our Client and Server, let's add them as binary targets to our crate. We need to edit our Cargo.toml accordingly and add the following:

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

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

Then, execute the following commands from our working directory:

  1. Run the server in one terminal:
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-server 
  1. Run the client from another terminal:
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-client

You'll see output like this, with timestamps omitted for clarity:

*** SIMPLE RPC ***

FEATURE: Name = "Berkshire Valley Management Area Trail, Jefferson, NJ, USA", Lat = 409146138, Lon = -746188906

8. What's next