I think you are familiar with configure
method, which a lot of gems provide for configuration needs. For example configuration of carrierwave:
CarrierWave.configure do |config|
config.storage = :file
config.enable_processing = false
end
So how to implement it?
Fast and dirty
Let’s start from failing tests.
# configure.rb
require 'minitest/autorun'
class ConfigurationTest < MiniTest::Test
def test_configure_block
MyModule.configure do |config|
config.name = "TestName"
config.per_page = 25
end
assert_equal "TestName", MyModule.config.name
assert_equal 25, MyModule.config.per_page
assert_equal "TestName", MyModule.config[:name]
assert_equal 25, MyModule.config[:per_page]
end
end
➜ Projects ruby configure.rb
Run options: --seed 25758
# Running:
E
Finished in 0.001166s, 857.6329 runs/s, 0.0000 assertions/s.
1) Error:
ConfigurationTest#test_configure_block:
NameError: uninitialized constant ConfigurationTest::MyModule
configure.rb:5:in `test_configure_block'
1 runs, 0 assertions, 0 failures, 1 errors, 0 skips
Now we have failing tests and can start to implement functionality. First, define module with configure
method.
module MyModule
def self.configure
end
end
Now we need a place to store our configuration. I think module’s variables will suitable for this need.
module MyModule
def self.configure
@config ||= {}
end
def self.config
@config
end
end
Here there is an issue. We can’t store our config in a hash. For now, I will replace it with OpenStruct, which conform to our functional needs. After this, I already can yield block and pass config struct to it.
require 'minitest/autorun'
require 'ostruct'
module MyModule
def self.configure
@config ||= OpenStruct.new
yield(@config) if block_given?
@config
end
def self.config
@config || configure
end
end
And now tests are passed.
➜ Projects ruby configure.rb
Run options: --seed 8967
# Running:
.
Finished in 0.001607s, 622.2775 runs/s, 2489.1101 assertions/s.
1 runs, 4 assertions, 0 failures, 0 errors, 0 skips
Refactoring
It’s time to refactor this solution. It has few problems:
- We can store anything inside
@config
variable. It’s not cool for configuration, because it will hide errors from users. If user will make a mistake in the name of configuration attribute, we need immediately let him know about the mistake by raising an exception. - OpenStruct is not a good idea for production code. It’s much slower than struct/class and uses more memory.
Here’s tests, for being confident, that when we call not existing config attributes we will get an exception.
def test_set_not_exists_attribute
assert_raises NoMethodError do
MyModule.configure do |config|
config.unknown_attribute = "TestName"
end
end
end
def test_get_not_exists_attribute
assert_raises NoMethodError do
MyModule.config.unknown_attribute
end
end
We have two ways to fix this. First we can use struct with whitelist of configurable attributes.
module MyModule
CONFIG_ATTRIBUTES = %i(name per_page)
def self.configure
@config ||= Struct.new(*CONFIG_ATTRIBUTES).new
yield(@config) if block_given?
@config
end
def self.config
@config || configure
end
end
Everything looks OK. Tests pass. Code feels simple and readable. But I forget one more important detail. Default config values. Add one more test for it.
def test_default_values
MyModule.configure do |config|
config.name = "TestName"
end
assert_equal 10, MyModule.config.per_page
end
To avoid overriding config attributes in different tests we need to reset config before run each single test. I will provide reset method right inside test class, because it needs only for testing purposes.
module ::MyModule
def self.reset
@config = nil
end
end
def setup
MyModule.reset
end
Simplest solution of the problem with default values will look something like this:
self.config ||= begin
config = Struct.new(*CONFIG_ATTRIBUTES).new
config.per_page = 10
config
end
But code starts to smell. Default values may be much more complex. It will be difficult to maintain this code. I think we can do better. Let’s replace struct by class. In class, we can provide default values in initializer. It’s very easy to read and extend.
module MyModule
class Configuration
attr_accessor :name, :per_page
def initialize
@per_page = 10
end
def [](value)
public_send(value)
end
end
def self.configure
@config ||= Configuration.new
yield(@config) if block_given?
@config
end
def self.config
@config || configure
end
end
I think this is my favourite solution. It’s still very simple and readable. It’s also flexible. We can provide complex default values and separate logic into methods if needed. We also give two ways to get config values: via methods and subscript.
That’s all what I want to share with you today. Have a nice weekend!
Source code available here: http://goo.gl/feCwCC
UPD:
User of habr.com @northbear suggested an alternative way where instead of Class
uses Struct
with initializer.
module Sample
DefaultConfig = Struct.new(:a, :b) do
def initialize
self.a = 10
self.b = 'test'
end
end
def self.configure
@config = DefaultConfig.new
yield(@config) if block_given?
@config
end
def self.config
@config || configure
end
end
In this case there is no needs to use public_send
for implementing subscript. It makes code easier. Thanks, @northbear!