Announcing a new project: configure
Hi :) I’ve been working on a project called configure, which is intended to create a uniform way to load configuration variables from the environment of the program. Specifically, the goal is to create something that libraries can rely on to allow applications to delegate decisions about how configuration is loaded to applications, without those applications having to write a lot of bespoke configuration management glue.
Storing configuration in the environment
“The 12 Factor App” has this very good advice about managing configuration:
Store config in the environment
An app’s config is everything that is likely to vary between deploys (staging, production, developer environments, etc). This includes:
- Resource handles to the database, Memcached, and other backing services
- Credentials to external services such as Amazon S3 or Twitter
- Per-deploy values such as the canonical hostname for the deploy
For reasons laid out in the linked web page, it is poor practice to store this information inline in the source code. Setting aside even their reasons, it is especially inconvenient in compiled languages like Rust, as it requires recompiling the entire project between deployment environments, and it means performing an entire redeploy to change one of these values.
However, most libraries today take these kinds of configuration as an argument to their constructor, leaving application authors responsible for developing their own system for pulling those configurations from the environment. Configure is an attempt to create a standardized way for libraries to pull configuration from the environment, making it easier for end users to follow best practices regarding configuration.
At first, you might have just a few of these env vars, and the code isn’t that complicated:
fn main() {
let socket_addr = env::get("SOCKET_ADDR").unwrap();
let server = Server::init(socket_addr);
}
But as your application grows, more and more of these variables become necessary, and you end up needing to establish a naming convention and maintain a whole subroutine for loading and applying them to the different libraries you use to build your application.
configure is intended to simplify this by establishing a common interface that libraries and applications can use to easily abstract this problem, similar to how the log crate handles logging.
The Configure trait
Libraries adopting this model should put the appropriate configuration all into a struct:
struct Config {
socket_addr: SocketAddr,
tls_cert: Option<PathBuf>,
// ... etc
}
This struct that needs to implement Configure
. The easiest way to get
Configure
implemented correctly is to derive it. Deriving Configure
requires the struct to also implement Deserialize
, which can also be derived.
Libraries are especially encouraged, where possible, to have default values for their configuration, so that users do not necessarily need to make a decision on their own. For example:
#[macro_use] extern crate configure;
extern crate serde;
#[macro_use] extern crate serde_derive;
#[derive(Deserialize, Configure)]
#[serde(default)]
struct Config {
socket_addr: SocketAddr,
tls_cert: Option<PathBuf>,
// ... etc
}
impl Default for Config {
fn default() -> Config {
Config {
socket_addr: "127.0.0.1:7878".parse().unwrap(),
tls_cert: None,
}
}
}
The Configure trait provides two functions:
Configure::generate
, a constructor which generates the configuration from the environment.Configure::regenerate
, a method that updates the configuration by pulling from the environment again.
The generated implementation of Configure
all pull the configuration from a
configuration source, which is controlled by the end application.
Libraries are encouraged to make this struct public, and have both a constructor which takes the struct as an argument (for use cases in which the 12-factor app’s advice is not appropriate), and one which generates it from the environment.
Configuration sources
Applications can set the source for configuration using the use_config_from!
or use_default_config!
macros. A configuration source implements the
ConfigSource trait, which allows it to act as a source of
configuration for libraries. configure ships with a default configuration
source, but users can override it with their own.
The default source
Configure provides a default source for configuration that users can depend on
by using the use_default_config!();
macro. Invoke this macro at the beginning
of your main function to begin sourcing configuration variables using the
default source:
#[macro_use] extern crate configure;
fn main() {
use_default_config!();
}
The default source pulls configuration from env vars, falling back to the
Cargo.toml
if they are not set. In my detail:
- If the library
foo
has a config struct with a fieldbar
, the env var to control that field isFOO_BAR
. - If that env var is not set and there is a
Cargo.toml
manifest present, we will look up thebar
member of the[package.metadata.foo]
section of the manifest.
Under the hood
Internally, configure uses a global static called CONFIGURATION
, which
applications use to set wha the source of configuration is in their
application. This choice was made, rather than a more conventional choice like
a type parameter, for these reasons:
- Compile times. Parameterizing all of your library types by the source of their configuration would (through monomorphization) delay compiling your library code until the binary is compiled. Monomorphization of the config source doesn’t provide performance benefits (you should not be accessing configuration sources in a loop), so this would worsen compile times significantly for little benefit. Using a static, we do not need to recompile library code every time you compile your binary.
- API Complexity. Adding type parameters increases the noise of the API. When those parameters aren’t relevant to the core functionality of the API, they can be distracting and confusing.
- Guaranteed consistency. Users could set the configuration source for one library to be something different than another. Its also possible a library could be designed to only work with some configuration sources and not others. This guarantees the end user has full control over the source configuration, and all libraries use the same source.
Next steps
This is a very preliminary release, and there’s still a lot to be done. Here are the key action items I plan to work on in the near future:
- Logging:
env_logger
is great, but works in a slightly different way from the default configuration source, which can lead to confusion. I’d like to write a logger implementation which is very similar toenv_logger
, but which allows each crate’s logging to be controlled by an independentFOO_LOG
variable. - A test case: I’m working on an HTTP server implementation which combines configure with hyper, creating an easy to use “server in a box” for people to build on top of. This project is called tyger and is still in its very early stages.
- Docs and UX improvements: configure currently has very minimal documentation, and there are probably ways to extend its API to give users a better experience. We’re still a ways from a 1.0 release. Opinionated early adopters welcome!
Unlike failure, configure can support a wide variety of breaking changes in its API without forking the ecosystem, so this release is much more preliminary than failure’s first release. I intend to continue evolve the project considerably in the near term, and would be excited to receive feedback and contributions from other people interested in this work.