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
remotecli/mod.rs
pub mod client;
pub mod server;
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;
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(())
}
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(())
}
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(())
}
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)