DEV Community

Cover image for Let’s build a single binary gRPC server-client with Rust in 2020 - Part 4
T.J. Telan
T.J. Telan

Posted on • Originally published at tjtelan.com

Let’s build a single binary gRPC server-client with Rust in 2020 - Part 4

In the previous post, we covered using our protobuf compiled Rust code to implement our gRPC server and include it in our CLI frontend.

It is recommended that you follow in order since each post builds off the progress of the previous post.

This is the last post of a 4 part series. If you would like to view this post in a single page, you can follow along on my blog.


Client

We’re in the homestretch. Implementing a client. We’re going to create a new module within remotecli called client.rs that will follow the same patterns as we established for the server.

$ tree
.
├── build.rs
├── Cargo.toml
├── proto
│ └── cli.proto
└── src
    ├── main.rs
    └── remotecli
            ├── client.rs
            ├── mod.rs
            └── server.rs
Enter fullscreen mode Exit fullscreen mode

remotecli/mod.rs

pub mod client;
pub mod server;
Enter fullscreen mode Exit fullscreen mode

We’re declaring the client module within mod.rs

remotecli/client.rs

Our client is a lot more straightforward. But splitting the module up into pieces for description purposes. Again, full file is at the end of the secion

Imports

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

// Proto generated client
use remotecli_proto::remote_cli_client::RemoteCliClient;

// Proto message structs
use remotecli_proto::CommandInput;

use crate::RemoteCommandOptions;
Enter fullscreen mode Exit fullscreen mode

Just like in our server, we create a module remotecli_proto and we use the tonic::include_proto!() macro to copy/paste our generated code into this module.

We then include the generated RemoteCliClient to connect, and the CommandInput struct since that is what we send over to the server.

Last include is the RemoteCommandOptions struct from the frontend so we can pass in the server address we want to connect to.

client_run

pub async fn client_run(rc_opts: RemoteCommandOptions) -> Result<(), Box<dyn std::error::Error>> {
   // Connect to server
   // Use server addr if given, otherwise use default
   let mut client = RemoteCliClient::connect(rc_opts.server_addr).await?;

   let request = tonic::Request::new(CommandInput {
       command: rc_opts.command[0].clone().into(),
       args: rc_opts.command[1..].to_vec(),
   });

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

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

   Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The helper function client_run() is an async function like our server. The frontend passes in a RemoteCommandOptions struct for the server address info as well as our raw user command.


First thing we do is create client and connect to the server with RemoteCliClient::connect and do an .await.


Then we build our request by creating a tonic::Request struct with our CommandInput.

The user command is raw and needs to be sliced up to fit the shape of what the server expects. The first word of the user command is the shell command, and the rest are the arguments.


Lastly we use client and call our endpoint with our request and .await for the execution to complete.

main.rs

This is the final form of main.rs. The last thing we do to main.rs is plug in our client_run() function.

pub mod remotecli;

use structopt::StructOpt;

// These are the options used by the `server` subcommand
#[derive(Debug, StructOpt)]
pub struct ServerOptions {
   /// The address of the server that will run commands.
   #[structopt(long, default_value = "127.0.0.1:50051")]
   pub server_listen_addr: String,
}

// These are the options used by the `run` subcommand
#[derive(Debug, StructOpt)]
pub struct RemoteCommandOptions {
   /// The address of the server that will run commands.
   #[structopt(long = "server", default_value = "http://127.0.0.1:50051")]
   pub server_addr: String,
   /// The full command and arguments for the server to execute
   pub command: Vec<String>,
}

// These are the only valid values for our subcommands
#[derive(Debug, StructOpt)]
pub enum SubCommand {
   /// Start the remote command gRPC server
   #[structopt(name = "server")]
   StartServer(ServerOptions),
   /// Send a remote command to the gRPC server
   #[structopt(setting = structopt::clap::AppSettings::TrailingVarArg)]
   Run(RemoteCommandOptions),
}

// This is the main arguments structure that we'll parse from
#[derive(StructOpt, Debug)]
#[structopt(name = "remotecli")]
struct ApplicationArguments {
   #[structopt(flatten)]
   pub subcommand: SubCommand,
}

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

   match args.subcommand {
       SubCommand::StartServer(opts) => {
           println!("Start the server on: {:?}", opts.server_listen_addr);
           remotecli::server::start_server(opts).await?;
       }
       SubCommand::Run(rc_opts) => {
           println!("Run command: '{:?}'", rc_opts.command);
           remotecli::client::client_run(rc_opts).await?;
       }
   }

   Ok(())
}
Enter fullscreen mode Exit fullscreen mode

remotecli/client.rs all together

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

// Proto generated client
use remotecli_proto::remote_cli_client::RemoteCliClient;

// Proto message structs
use remotecli_proto::CommandInput;

use crate::RemoteCommandOptions;

pub async fn client_run(rc_opts: RemoteCommandOptions) -> Result<(), Box<dyn std::error::Error>> {
   // Connect to server
   // Use server addr if given, otherwise use default
   let mut client = RemoteCliClient::connect(rc_opts.server_addr).await?;

   let request = tonic::Request::new(CommandInput {
       command: rc_opts.command[0].clone().into(),
       args: rc_opts.command[1..].to_vec(),
   });

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

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

   Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

We just walked through building a CLI application that parses user input and uses gRPC to send a command from a gRPC client to the server for execution and return of command output.

Based on how we structured the frontend CLI using StructOpt, we allowed both the client and server to compile into a single binary.

Protocol buffers (or protobufs) were used to define the interfaces of the server and the data structures that were used. The Tonic and Prost crates and Cargo build scripts were used to compile the protobufs into native async Rust code.

Tokio was our async runtime. We experienced how little code was necessary to support async/await patterns.

I hope that this walkthrough satisfies some curiosity about using gRPC for your backend code. As well as piqued your interest in writing some Rust code.

Top comments (0)