Production-ready microservice in Rust: 2. Add CLI sub-commands
Posted 2023-05-13 10:00:00 by sanyi ‐ 6 min read
A simple pattern to add multiple CLI sub-commands to the application
To create a new sub-command with clap
you have to do two things:
- add the subcommand to the
clap
configuration - handle the different commands according to command-line parameters
We could add these code snippets simply to main.rs
but that way
the main.rs
would become bloated really quickly.
I prefer to put these things into a dedicated commands
rust module.
Let's go to our shelter_main/src
folder and create a new commands
directory:
$ cd shelter_main/src
$ mkdir commands
$ cd commands
In the commands directory create a new mod.rs
file, and add two
methods: one to configure the command and one to handle the CLI
arguments:
use clap::{ArgMatches, Command};
pub fn configure(command: Command) -> Command {
command.subcommand(Command::new("hello").about("Hello World!"))
}
pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
if let Some(_matches) = matches.subcommand_matches("hello") {
println!("Hello World!");
}
Ok(())
}
The configure method simply takes an existing Command
configuration
and adds a new hello
subcommand to it.
The handle method takes the argument matches returned by clap
and checks whether our hello
subcommand was called. If that was
the case, it prints Hello World!
to the console.
Notice the return type anyhow::Result<()>
: the handle method
returns nothing by default but it can return an error result
is something goes wrong.
Now to use this code from main.rs
we have to change it a little:
mod commands;
use clap::{Arg, Command};
use dotenv::dotenv;
pub fn main() -> anyhow::Result<()> {
dotenv().ok();
let mut command = Command::new("Dog Shelter sample application")
...
);
command = commands::configure(command);
let matches = command.get_matches();
commands::handle(&matches)?;
Ok(())
}
First, we have to add the mod commands
declaration at the top
to integrate our new module to the codebase.
In the main
method we have to make the command
instance mutable,
becase the commands::configure
method creates a new version of it.
After calling command.get_matches()
we also call commands::handle
to handle all the subcommands we configured.
Notice the question mark at the end of the commands::handle
call:
when the method returns an error result, the execution of main will
be interrupted here and the main
method returns an error too.
One more trick: it's usually useful to arrange the crate to contain
both a lib.rs
and a main.rs
file. It will contain both a library
and a binary at the same time. This can make testing, benchmarking
easier later.
To do so, add a lib.rs
file to the src
directory and move the
mod commands
declaration from main.rs
:
pub mod commands;
We have to make it public, so main.rs
can use it later. Now change
the main.rs
file too:
use clap::{Arg, Command};
use dotenv::dotenv;
use shelter_main::commands;
pub fn main() -> anyhow::Result<()> {
...
}
The name of the lib module is equivalent to the name of our crate: shelter_main
,
so to import the commands module we add use shelter_main::commands
to main.rs
.
Now make things a little more complicated: add more subcommands. To do this, I will
split the commands
module into submodules. Add a new hello.rs
file to the
commands
folder and move the hello subcommand configuration and handler there:
use clap::{ArgMatches, Command};
pub fn configure() -> Command {
Command::new("hello").about("Hello World!")
}
pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
if let Some(_matches) = matches.subcommand_matches("hello") {
println!("Hello World!");
}
Ok(())
}
I changed the configure()
method a little so it only returns a new Command
and does not configure and existing one.
Now change commands/mod.rs
to use the new hello
submodule:
mod hello;
use clap::{ArgMatches, Command};
pub fn configure(command: Command) -> Command {
command.subcommand(hello::configure())
}
pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
hello::handle(matches)?;
Ok(())
}
The configure
method adds the new Command
returned by hello::configure()
as a subcommand to the main clap
configuration.
The handle
method simply tries to call the handle
method from the hello
module. Notice the question mark: errors are returned immediately.
Now add one more command: the serve command will run our webserver later.
Create a new file called commands/serve.rs
:
use clap::{value_parser, Arg, ArgMatches, Command};
pub fn configure() -> Command {
Command::new("serve").about("Start HTTP server").arg(
Arg::new("port")
.short('p')
.long("port")
.value_name("PORT")
.help("TCP port to listen on")
.default_value("8080")
.value_parser(value_parser!(u16)),
)
}
pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
if let Some(matches) = matches.subcommand_matches("serve") {
let port: u16 = *matches.get_one("port").unwrap_or(&8080);
println!("TBD: start the webserver on port {}", port)
}
Ok(())
}
This command can take a port parameter but uses 8080 by default.
Modify commands/mod.rs
to use the serve
submodule too:
mod hello;
mod serve;
use clap::{ArgMatches, Command};
pub fn configure(command: Command) -> Command {
command
.subcommand(hello::configure())
.subcommand(serve::configure())
}
pub fn handle(matches: &ArgMatches) -> anyhow::Result<()> {
hello::handle(matches)?;
serve::handle(matches)?;
Ok(())
}
Do you see the pattern? If you need more subcommands you can simply
add more submodules and call them in the configure
and handle
methods.
Now let's build our project and see how it works. Go to the project
root directory and call cargo build
:
$ cargo build
Compiling shelter_main v0.1.0 (.../shelter-project/shelter_main)
Finished dev [unoptimized + debuginfo] target(s) in 0.66s
First try the help subcommand:
$ ./target/debug/shelter_main help
A sample application to experiment with Rust-based microservices
Usage: shelter_main [OPTIONS] [COMMAND]
Commands:
hello Hello World!
serve Start HTTP server
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <config> Configuration file location [default: config.json]
-h, --help Print help
-V, --version Print version
As you can see we now have a hello and a serve subcommand. We can get more information on them too:
$ ./target/debug/shelter_main serve --help
Start HTTP server
Usage: shelter_main serve [OPTIONS]
Options:
-p, --port <PORT> TCP port to listen on [default: 8080]
-h, --help Print help
Now try the different subcommands:
$ ./target/debug/shelter_main hello
Hello World!
$ ./target/debug/shelter_main serve
TBD: start the webserver on port 8080
$ ./target/debug/shelter_main serve -p 3000
TBD: start the webserver on port 3000
$ ./target/debug/shelter_main serve --port 3000
TBD: start the webserver on port 3000
You can find the sample code on GitHub