Sat 03 December 2022
Sooner or later, that application you're writing will need to be configured.
At the very least, you'll need a way to adjust inputs without editing source code. Wouldn't it be nice to have a reasonable configuration system from the start?
The best way to configure your app will depend on
the environment in which you're using the software,
and the requirements of the project, all of which will change over time.
Ideally, we'd start out with a system
that had the flexibility to pull our configuration from a number of input sources:
- Command Line Interface for interactive development with standard flags, clear usage and error handling
.env
files for declarative configuration, either development or production
- Environment variables for containers and many production settings
- Reasonable defaults if nothing is provided by the user. And if there is no obvious default, mark it clearly as a mandatory argument.
For a language that is often refered to
as a low-level "systems" language, Rust allows for some very ergonomic
abstractions. We can implement a type-safe configuration system
with a minimal amount of imperative code, letting the third-party crates handle the mechanical details. Let's walk through a new project...
Project setup
In this example, we'll create a Rust project using the clap
and dotenv
crates.
cargo new myapp
cd myapp
cargo add clap --features derive,env
cargo add dotenv
Your Cargo.toml
file should look something like
[dependencies]
clap = { version = "4.0.29", features = ["derive", "env"] }
dotenv = "0.15.0"
Creating the configuration struct
Let's build it up from scratch, starting with a plain struct
defining all values we need to configure the app.
In our src/main.rs
pub struct Config {
pub ipaddr: String,
pub port: i32,
pub database_url: String,
}
Let's pause for a second to consider types. In Rust, types can help us out by providing powerful correctness guarantees.
Is ipaddr
really a String? The type system should enforce a valid IPv4 address instead of a free-form string.
Likewise, let's make sure the port
is a unsigned 16 bit integer to stay within
the range of viable port numbers.
use std::net::Ipv4Addr;
pub struct Config {
pub ipaddr: Ipv4Addr,
pub port: u16,
pub database_url: String,
}
Clap annotations
Next, we use the clap
crate and add annotations to our struct.
This turns our declarative struct into a powerful command line interface,
with error handling, default values and type conversion.
use clap::Parser;
use std::net::Ipv4Addr;
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Config {
#[arg(short, long, default_value = "0.0.0.0")]
pub ipaddr: Ipv4Addr,
#[arg(short, long, default_value_t = 3000)]
pub port: u16,
#[arg(short, long)]
pub database_url: String,
}
The author, version and about text are derived from the contents of our Cargo.toml
file.
Note that the database_url
does not use a default value.
Self documentation
We can add docstrings (///
) to the struct and to its members.
This serves the purpose of both documenting the code and exposing
friendly command line usage and error messages.
use clap::Parser;
use std::net::Ipv4Addr;
/// My Awesome Application
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Config {
/// IPv4 address
#[arg(short, long, default_value = "0.0.0.0")]
pub ipaddr: Ipv4Addr,
/// Port number
#[arg(short, long, default_value_t = 3000)]
pub port: u16,
/// Database connection string
#[arg(short, long)]
pub database_url: String,
}
Environment handling
Clap can handle env vars explicitly by add the env(...)
annotation
to each configuration item. Here, we explictly define each variable name
using the APP_*
prefix, all upper case, as a convention:
use clap::Parser;
use dotenv::dotenv;
use std::net::Ipv4Addr;
/// My Awesome Application
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Config {
/// IPv4 address
#[arg(short, long, env("APP_IPADDR"), default_value = "0.0.0.0")]
pub ipaddr: Ipv4Addr,
/// Port number
#[arg(short, long, env("APP_PORT"), default_value_t = 3000)]
pub port: u16,
/// Database connection string
#[arg(short, long, env("APP_DATABASE_URL"))]
pub database_url: String,
}
Constructor
Since we want to (optionally) populate our environment using a .env
file,
we have to set up the environment before invoking the Clap parser. To do this,
We'll implement a from_env_and_args
constructor method for our Config
struct.
impl Config {
pub fn from_env_and_args() -> Self {
dotenv().ok();
Self::parse()
}
}
With four potential inputs, how do we reason about which takes precendence?
To determine the config value, the effective order is as follows, first one wins:
- Command line interface argument
- File (
.env
)
- Environment variable
- Default value
Main
Finally, we write our main function to create and construct the Config
at runtime.
fn main() {
let cfg = Config::from_env_and_args();
println!("Starting HTTP server on {}:{}", cfg.ipaddr, cfg.port);
println!("Connecting to {}", cfg.database_url);
}
Presumably, your application will do something more interesting here!
Result
$ cargo build
...
$ ./target/debug/myapp --help
My Awesome Application
Usage: myapp [OPTIONS] --database-url <DATABASE_URL>
Options:
-i, --ipaddr <IPADDR> IPv4 address [env: APP_IPADDR=] [default: 0.0.0.0]
-p, --port <PORT> Port number [env: APP_PORT=] [default: 3000]
-d, --database-url <DATABASE_URL> Database connection string [env: APP_DATABASE_URL=]
-h, --help Print help information
-V, --version Print version information
In this case, we see that the database_url
is undefined in the environment, has no default, but
is required by the application. If we try to run it now, the app exits with status
code of 2
and we get a human-readable message that we are missing the database URL:
$ ./target/debug/myapp
error: The following required arguments were not provided:
--database-url <DATABASE_URL>
Usage: myapp --database-url <DATABASE_URL>
For more information try '--help'
To provide it we have three options, depending on your operational needs.
First, we can use the command line for interactive testing:
./target/debug/myapp --database-url postgres://postgres@localhost:5432/postgres
Or, an environment variable for production settings:
export APP_DATABASE_URL="postgres://postgres@localhost:5432/postgres"
./target/debug/myapp
Or finally, using a .env
file for declarative environment setup (in prod or dev).
echo 'APP_DATABASE_URL=postgres://postgres@localhost:5432/postgres' >> .env
./target/debug/myapp
Whichever way we configure the required DATABASE_URL
, we get the same result.
$ ./target/debug/myapp
Starting HTTP server on 0.0.0.0:3000
Connecting to postgres://postgres@localhost:5432/postgres
Error handling is intuitive from the command line.
Let's see what happens when we provide an invalid IP adress and port number.
$ ./target/debug/myapp --ipaddr 255.255.255.999
error: Invalid value '255.255.255.999' for '--ipaddr <IPADDR>': invalid IPv4 address syntax
For more information try '--help'
$ ./target/debug/myapp --port 999999
error: Invalid value '999999' for '--port <PORT>': 999999 is not in 0..=65535
For more information try '--help'
Viola. A simple, declarative, type-safe abstraction with minimal code.
We get operational flexibility and confidence in the validity of the inputs
without writing imperative code to handle the details of each scenario.
This can serve as a starter template suitable for most backend server or command line applications. Here it is, all 26 lines of code in one place:
use clap::Parser;
use dotenv::dotenv;
use std::net::Ipv4Addr;
/// My Awesome Application
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Config {
/// IPv4 address
#[arg(short, long, env("APP_IPADDR"), default_value = "0.0.0.0")]
pub ipaddr: Ipv4Addr,
/// Port number
#[arg(short, long, env("APP_PORT"), default_value_t = 3000)]
pub port: u16,
/// Database connection string
#[arg(short, long, env("APP_DATABASE_URL"))]
pub database_url: String,
}
impl Config {
pub fn from_env_and_args() -> Self {
dotenv().ok();
Self::parse()
}
}
fn main() {
let cfg = Config::from_env_and_args();
println!("Starting HTTP server on {}:{}", cfg.ipaddr, cfg.port);
println!("Connecting to {}", cfg.database_url);
}
Check out the clap docs for more examples
of how you can extend this approach.
I think this interface shows that we don't need to compromise between ergonomics and type-safety, speed and correctness. It's a great example of Rust's potential as a higher level application language.