Configuring the Ruby module
- Tutorial
I think you are familiar with the configure method, which many gems provide for configuration. For example carrierwave configuration:
How to implement this in your module?
Let's start with the falling tests.
Now that we have the falling tests, we will begin to implement the functionality. First of all, declare a module containing the configure method.
We need a place to store our configuration. I think the module variable is good for this.
There is a problem here. We will not be able to store the configuration in a hash. For now, I will replace the hash with OpenStruct, which matches the functionality that we are going to get in the long run. After that, I can already call the block inside the method and pass the storage to it as an argument.
The desired functionality is ready. Tests pass.
It's time to refactor this solution. Two problems are immediately apparent:
Add tests to make sure that when we call a non-existent configuration method, we will get an exception.
We have two ways to fix this. The first is to use Struct with a white list of available configuration methods.
Everything looks great. Tests pass, the code is simple and readable. But I forgot one important detail . The default configuration values. For them, you need to add another test.
To avoid overwriting configuration values in different tests, you need to add a reset of the previous configuration before starting each test . I will add a reset method directly in the test class, because it is needed only for test needs and there is no need to make it part of the public API.
Let's get back to solving the problem with the default values. The simplest solution would look like this:
Hmm, the code starts to smell . The default values can be much more complicated. Such code will be difficult to maintain. I think we can do better. Let's replace Struct with a class . In the class, we can set default values directly in the initializer. Such code will be easy to read and extend.
I like this solution. It is still very simple and readable . It is also quite flexible . We can set complex defaults and, if necessary, put them into separate methods. We also have two ways to get configuration values: using the method and through subscript.
That is all I wanted to share today. Sources are available here: goo.gl/feCwCC
UPD:
northbear has proposed an alternative that uses Struct with an initializer instead of a class.
In this case, there is no need to use public_send to define a subscript, which makes the code even simpler. Thank you, northbear !
CarrierWave.configure do |config|
config.storage = :file
config.enable_processing = false
end
How to implement this in your module?
Quick and dirty
Let's start with the falling 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 that we have the falling tests, we will begin to implement the functionality. First of all, declare a module containing the configure method.
module MyModule
def self.configure
end
end
We need a place to store our configuration. I think the module variable is good for this.
module MyModule
def self.configure
@config ||= {}
end
def self.config
@config
end
end
There is a problem here. We will not be able to store the configuration in a hash. For now, I will replace the hash with OpenStruct, which matches the functionality that we are going to get in the long run. After that, I can already call the block inside the method and pass the storage to it as an argument.
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
The desired functionality is ready. Tests pass.
➜ 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. Two problems are immediately apparent:
- We can store anything inside our configuration. The set of methods to which we can convey the value is not limited by anything. This is not cool for configuration because it hides errors from the user. If the user makes a mistake in the name of the configuration method, we must immediately let him know about it, throwing an exception .
- OpenStruct is not a good idea for production code. It is much slower than a regular Struct or class and uses a lot more memory.
Add tests to make sure that when we call a non-existent configuration method, 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. The first is to use Struct with a white list of available configuration methods.
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 great. Tests pass, the code is simple and readable. But I forgot one important detail . The default configuration values. For them, you need to add another test.
def test_default_values
MyModule.configure do |config|
config.name = "TestName"
end
assert_equal 10, MyModule.config.per_page
end
To avoid overwriting configuration values in different tests, you need to add a reset of the previous configuration before starting each test . I will add a reset method directly in the test class, because it is needed only for test needs and there is no need to make it part of the public API.
module ::MyModule
def self.reset
@config = nil
end
end
def setup
MyModule.reset
end
Let's get back to solving the problem with the default values. The simplest solution would look like this:
self.config ||= begin
config = Struct.new(*CONFIG_ATTRIBUTES).new
config.per_page = 10
config
end
Hmm, the code starts to smell . The default values can be much more complicated. Such code will be difficult to maintain. I think we can do better. Let's replace Struct with a class . In the class, we can set default values directly in the initializer. Such code will be easy to read and extend.
module MyModule
class Configuration
attr_accessor :name, :per_page
def initialize
@per_page = 10
end
def [](value)
self.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 like this solution. It is still very simple and readable . It is also quite flexible . We can set complex defaults and, if necessary, put them into separate methods. We also have two ways to get configuration values: using the method and through subscript.
That is all I wanted to share today. Sources are available here: goo.gl/feCwCC
UPD:
northbear has proposed an alternative that uses Struct with an initializer instead of a class.
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 need to use public_send to define a subscript, which makes the code even simpler. Thank you, northbear !