Configuring the Ruby module

  • Tutorial
I think you are familiar with the configure method, which many gems provide for configuration. For example carrierwave configuration:

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 !

Also popular now: