Rust application configuration
Lately, I have been exploring Rust language primarily to write 12-factor cloud-native apps.
12-factor principles are strict when it comes to the separation of config from code. So, the configuration can be provided to the application in different ways. To name a few:
- config files
- environment variables
In this article, I will share certain practices that I have used professionally and so have mapped same to Rust-based projects. Overall I will restrict this article to the application configuration. Happy to hear your thoughts if you see scope for improvement or any feedback.
Project Setup
As a service will be an executable after compilation, we can stick with the default binary target src/main.rs
. One can notice that the project setup also includes additional files. I will discuss them below.
.
├── Cargo.toml
├── config
│ └── app.yaml
└── src
├── cmd.rs
├── config.rs
├── error.rs
├── main.rs
└── prelude.rs
Error Variants
error.rs: This file lists different error variants that may be used across the application. Here by popular choice, I use thiserror crate for error handling as it really keeps it simple.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
ConfigError(#[from] twelf::Error),
}
Preludes
prelude.rs: This file will use the enum Error (defined above) and will define a type alias for Result which returns the application’s Error. This simplifies the function’s API signature across the application. Later we import the prelude with use crate::prelude::*;
across modules within the crate. It is a balancing act that we don’t want to misuse prelude.
pub use crate::error::Error;
pub type Result<T> = std::result::Result<T, Error>;
CLI Arguments
cmd.rs: A service binary may have zero or more sub-commands and zero or more arguments/flags for the root command or different sub-commands. I keep this logic in cmd.rs. Here by popular choice, I use clap crate to manage the parsing of CLI arguments. One can notice that config_path
can be made available to the application either directly as a flag/option or using an environment variable APP_CONFIG_PATH
.
use clap::Parser;
#[derive(Parser, Debug)]
#[command(version)]
pub struct Args {
#[arg(short, long, default_value_t = String::from("./config/app.yaml"), env("APP_CONFIG_PATH"))]
pub config_path: String,
}
pub fn parse() -> Args {
Args::parse()
}
Configuration
config.rs: This file defines the service configuration struct which lists attributes of base types and custom types. Here I use twelf crate, as it provides config
macro which makes it easy to define application configuration using a struct and then at runtime configuration building capability with its layering approach. Here one may notice that due to the simplicity of thiserror
crate we were able to map twelf::Error
to Error
defined in this application.
use crate::prelude::Result as AppResult;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use twelf::{config, Layer};
pub fn load(path: PathBuf) -> AppResult<Config> {
let path = path.into();
// Layer from different sources to build configuration. Order matters!
let conf = Config::with_layers(&[
Layer::Yaml(path),
Layer::Env(Some(String::from("APP_"))),
])?;
Ok(conf)
}
#[config]
#[derive(Debug, Default, Serialize)]
pub struct Config {
pub log: Log,
pub port: u16,
pub db: Database,
}
//---snip---//
Binary Crate Dependencies
Cargo.toml
lists key crates (mentioned above) as dependencies.
#---snip---#
[dependencies]
clap = { version = "4.3.5", features = ["derive", "env"] }
twelf = { version="0.11.0", features = ["yaml"]}
thiserror= "1.0.40"
tokio = { version = "1.28.2", features = ["full"] }
#---snip---#
Application
Here one may notice that first, we parse command line arguments thus getting a valid config file path. Later we load the configuration from the given config file.
#![allow(unused)]
mod cmd;
mod config;
mod error;
mod prelude;
use crate::prelude::*;
//---snip---//
#[tokio::main]
async fn main() -> Result<()> {
let args = cmd::parse();
let conf = config::load(PathBuf::from(args.config_path))?;
LogBuilder::with_level(&conf.log.level)
.with_default_writer(new_writer(io::stdout()))
.init();
log::info!(conf = log::as_serde!(conf); "configuration");
sleep(Duration::from_millis(100)).await;
Ok(())
}
$ cargo run -q -- --config-path=./config/app.yaml
{"conf":{"log":{"level":"INFO"},"port":9080,"db":{"url":"postgres://localhost:5432/postgres"}},"level":"INFO","message":"configuration","target":"app_a","timestamp":1688836890547}
Conclusion
In this article, we noticed that we could easily separate configuration from code. Configuration file path can be provided as a flag/option or via an environment variable. clap
crate makes it super easy. Similarly, we can define application configuration using a struct and twelf
crate can help us with building layered configuration at runtime.
GitHub source repo: https://github.com/govinda-attal/app-a/tree/1-config