There are many kinds of applications and many ways to manage how they behave or perform via configurations. Software is designed with default behavior built in, so some specific configuration is usually required before the software works (like a path to reach a dependency), or a default and possibly rudimentary implementation may run without it.
From system tools that can have a central configuration for system-wide settings to a local user configuration to perhaps a per project config, the layers of configuration and the means by which they are stored can vary greatly. The choices for how you implement a configuration object should depend on where and how it will be used.
Simple Configuration Objects
Many simple applications won't need a robust system for configuring the application and can go with a simple Ruby object, such as a hash, and a stored configuration file format such as YAML.
A key-value store is the simplest concept of what a configuration object needs to be. And Ruby's Hash
is the basic building block for all simple implementations of it -- whether it's a lone hash object or an object wrapping the hash with preferred behavior.
config = {} config[:my_key] = :value config[:my_key] # => :value
A slightly more complex way is an object-oriented approach that dynamically defines methods to act as the keys and their return values as the values.
A common Ruby configuration object that takes the middle ground between these two approaches is the OpenStruct object. It uses a hash internally but also defines methods for each item you set so that they may be retrieved either way.
require 'ostruct' config = OpenStruct.new config.my_key = :value config.my_key # => :value config[:my_key] # => :value
A Ruby Hash
will return nil
as the default for a key that's used for lookup but hasn't been set yet. This is okay for rudimentary projects and simple designs, but it is not the OOP way, and on occasion you'll want something else as the default. Hash has a default_proc
method on it from which you may define the default behavior for the return value as well as assigning to undefined keys.
If you wanted a hash to create deeply nested keys in one go, you could have the inner hash recursively return a new empty inner hash.
module MyProject def self._hash_proc ->hsh,key{ hsh[key] = {}.tap do |h| h.default_proc = _hash_proc() end } end def self.config @config ||= begin hsh = Hash.new hsh.default_proc = _hash_proc() hsh end end end MyProject.config # => {} MyProject.config[:apple][:banana][:cherry] = 3 MyProject.config # => {:apple=>{:banana=>{:cherry=>3}}}
But in this case, we've merely exchanged nil
for an empty hash that hasn't met object-oriented programming standards. That's not a bad thing, and it's great for small or simple projects. However, the default value here has no meaning for us when it hasn't been set, so we have to build in guards and default behavior to work around this kind of model.
Rails provides an alternative kind of hash you can use called HashWithIndifferentAccess
, which will allow you to use both a string or a symbol as the same key for a value.
config = HashWithIndifferentAccess.new config["asdf"] = 4 config[:asdf] # => 4
Better Configuration Objects
If you use Rails, you'll be happy to know that they have a system for configuration objects available for you to use -- and it's pretty simple.
class DatabaseConfig include ActiveSupport::Configurable config_accessor :database_api do DummyDatabaseAPI.new end end
In the code above, the config_accessor
creates the appropriate config methods on the DatabaseConfig
class, and they will provide a DummyDatabaseAPI
instance as the default value object. This is nice because we can define some default behavior when a proper database has not been configured and set yet. And to update the database API on the DatabaseConfig
object instance, we need merely to call the setter method database_api=
. Being explicit with configuration method keys and return duck-typed objects is good practice and should make the code base more enjoyable to work with in the future.
In a previous post, we covered “Creating Powerful Command Line Tools in Ruby”. It has some tools, such as slop, that take optional command-line input and give us a configuration object that includes defaults for anything not set via the command line. This is useful for when the configurations are few in number.
!Sign up for a free Codeship Account
Persisted Configuration
Persisted configuration is the type of configuration that is either hard-coded in the program, stored in a file, or stored in a database. Most configurations are intended to be handled this way, although some configurations are set in environment variables or an encrypted equivalent.
Ruby comes with YAML support, which is a configuration favorite among many people. You may set where you'd like to have the configuration file loaded from and make that available in the README or documentation for easy-to-read configuration for your end users or yourself. Rails keeps many YAML files under a config
directory; Linux applications may have them in the project directory, in the users home directory hidden under .config/project_name
, or sometimes a systems config location such as /etc
. So there are many places where you may choose to have them reside.
A YAML file looks as simple as:
--- # A list of tasty fruits fruits: - Apple - Orange - Strawberry - Mango
And in Ruby, to load this into an easy to access Hash
:
require 'yaml' YAML.load( File.open('example.yml').read ) # => {"fruits"=>["Apple", "Orange", "Strawberry", "Mango"]}
If you want, you may produce YAML from a Ruby Hash
instance with the to_yaml
method. This is available after require yaml
or via YAML.dump
.
config = { "default" => { "adapter"=>"postgres", "encoding"=>"utf8", "pool"=>5, "timeout"=>5000 } } puts config.to_yaml # --- # default: # adapter: postgres # encoding: utf8 # pool: 5 # timeout: 5000
Generally you won't be writing YAML to configuration files from Ruby unless it's a first-time setup. Even then, YAML files are generally provided along with a project rather than written from one.
Environment Variables
One thing you'll want to avoid is having environment variable checks scattered across your code base. Application configuration should be centralized to avoid the potential of surprise behavior when states change.
YAML doesn't provide a way to retrieve environment variables on its own. But Ruby includes the ERB template engine, which we may use as an additional layer of processing for our configuration files. With ERB, we can run Ruby code within and block designated with <%= %>
.
# # YAML file # production: # secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> # # End YAML file require 'yaml' require 'erb' yaml_contents = File.open('config.yml').read config = YAML.load( ERB.new(yaml_contents).result ) config # => {"production"=>{"secret_key_base"=>"uih...943"}}
This keeps the more human readable configuration file and centralizes everything that belongs together into one place.
Precedence
When an application has a system-wide configuration as well as a user local configuration, the user local typically takes precedence.
You can choose several different behaviors for this. You could ignore the system config if a local one exists, or you could override only specific system configurations if they're provided locally. It's possible that some configurations have priority for the system config first, and others for the user, although this is an usual scenario.
If one simply overwrites the other, you can merge the hash results of the configurations.
These examples will assume YAML loading has already been done and demonstrate different hash precedence techniques.
system_conf = {root: false, name: "root"} user_conf = {root: true} # Completely overwrite any system config from user config config = system_conf.merge(user_conf) config # => {:root=>true, :name=>"root"}
Another approach can be to use Hash#dig
and prioritize what settings get called in what order.
class Config def system_conf # Assume loaded from YAML @system_conf ||= {root: false, name: "root"} end def user_conf # Assume loaded from YAML @user_conf ||= {root: true} end def name user_conf.dig(:name) || system_conf.dig(:name) end def root? system_conf.dig(:root) end end config = Config.new config.name # => root config.root? # => false
In this example, we were able to prevent the user from overriding his status as root. And even though the user didn't provide a name, the config fell back to the default provided from the system configuration.
Summary
Creating configuration object(s) for your own project need not be difficult but will often require much consideration in its implementation for the scope and size of the project you're building.
If your project is rather large and you may pivot to an alternative way of handling configurations in the future, then you may seriously want to consider implementing your configuration system through Ruby Object Mapper. ROM is a uniform way to process data across a myriad of data storage types like YAML, as well as many databases. It offers flexibility with a minimal learning curve. If your project is small, then a simpler solution may be a better fit.
When it comes to implementing configuration systems in Ruby, the world is truly your oyster.