Ruby Gem Configuration Patterns
This is one of the final post leading up the the launch of the Build a Ruby Gem Ebook, which is now available for sale in 3 packages, including 14 chapters of code and over 2 hours of screencasts.
The world isn’t black and white (as much as we’d like to believe it is). Just because our gem’s functionality may work for us, doesn’t mean it’ll work for everyone.
Fortunately, we can give users the ability to add custom configuration data, allowing them to adapt our code to their own use. In this post, we’ll adapt the mega_lotto gem to take a configuration block that modifies the number of integers returned from the #draw method output.
Use Case
Our mega_lotto gem provides the functionality to randomly draw 6 numbers. However, let’s assume that someone else has taken interest in the code for our gem, but needs the code to generate 10 random numbers within the same range.
One option would be for them to fork the code and modify the gem to fit their needs. However, now there’s a randomly forked gem with the same name and it’s unclear why one should be used over the other, especially if no changes to the README were made.
Rather than go down that path, we can make our existing mega_lotto gem more flexible by returning 6 integers by default, while also providing an interface to customize this value for those with the need.
Our goal is to adapt our gem to take a configuration block like this:
MegaLotto.configure do |config|
config.drawing_count = 10
endImplementation
Let’s first write some specs for the desired functionality. Because the .configure method above is off the main MegaLotto namespace, we’ll create the spec file spec/mega_lotto_spec.rb. With this spec, we’ll assert that after running the configuration block above, the #drawing method returns an array (like before), but this time with 10 integers instead:
require "spec_helper"
describe MegaLotto do
describe "#configure" do
before do
MegaLotto.configure do |config|
config.drawing_count = 10
end
end
it "returns an array with 10 elements" do
draw = MegaLotto::Drawing.new.draw
expect(draw).to be_a(Array)
expect(draw.size).to eq(10)
end
end
endThis spec serves as higher level integration spec because it’s accessing the public API of our gem. Because of this, we can be sure that once this spec passes, our implementation is complete. As expected, when we run this spec, it fails:
MegaLotto
#configure
returns an array with 10 elements (FAILED - 1)
Failures:
1) MegaLotto#config returns an array with 10 elements
Failure/Error: MegaLotto.configure do |config|
NoMethodError:
undefined method `configure` for MegaLotto:Module
# ./spec/mega_lotto_spec.rb:6
Finished in 0.00131 seconds
1 example, 1 failureNow that we have a spec to work against, let’s continue our implementation.
The failure above complained that there was no MegaLotto.configure method, so let’s add it:
module MegaLotto
def self.configure
end
endRe-running our specs gives us a different failure message this time:
MegaLotto
#configure
returns an array with 10 elements (FAILED - 1)
Failures:
1) MegaLotto#configure returns an array with 10 elements
Failure/Error: expect(draw.size).to eq(10)
expected: 10
got: 6
(compared using ==)
# ./spec/mega_lotto_spec.rb:15
Finished in 0.00246 seconds
1 example, 1 failureThe output now shows that the code still returned 6 integers, which we expected because our .configure method hasn’t done anything yet.
Because we’re using a object-oriented language like Ruby, we can create a Configuration class whose responsibility will be…(drumroll)….configuration!
Let’s start with some specs:
# spec/mega_lotto/configuration_spec.rb
require "spec_helper"
module MegaLotto
describe Configuration do
describe "#drawing_count" do
it "default value is 6" do
Configuration.new.drawing_count = 6
end
end
describe "#drawing_count=" do
it "can set value" do
config = Configuration.new
config.drawing_count = 7
expect(config.drawing_count).to eq(7)
end
end
end
endRunning the configuration specs produces:
/Users/bhilkert/Dropbox/code/mega_lotto/spec/
mega_lotto/configuration_spec.rb:4:in `<module:MegaLotto>`:
uninitialized constant MegaLotto::Configuration (NameError)Let’s add the Configuration class:
# lib/mega_lotto/configuration.rb
module MegaLotto
class Configuration
end
endLet’s try again:
/Users/bhilkert/Dropbox/code/mega_lotto/spec/
mega_lotto/configuration_spec.rb:4:in `<module:MegaLotto>`:
uninitialized constant MegaLotto::Configuration (NameError)What??? Same message…Even though we added the Configuration class above, our gem doesn’t load it. So we can dive in to the entry file lib/mega_lotto.rb and add the appropriate require statement:
require "mega_lotto/version"
require "mega_lotto/configuration"
require "mega_lotto/drawing"
begin
require "pry"
rescue LoadError
end
module MegaLotto
def self.configure
end
endNow with the Configuration class properly loaded, let’s run our specs again:
MegaLotto::Configuration
#drawing_count
default value is 6 (FAILED - 1)
#drawing_count=
can set value (FAILED - 2)
Failures:
1) MegaLotto::Configuration#drawing_count default value is 6
Failure/Error: expect(config.drawing_count).to eq(6)
NoMethodError:
undefined method `drawing_count` for #<MegaLotto::Configuration>
# ./spec/mega_lotto/configuration_spec.rb:8
2) MegaLotto::Configuration#drawing_count= can set value
Failure/Error: config.drawing_count = 7
NoMethodError:
undefined method `drawing_count=` for #<MegaLotto::Configuration>
# ./spec/mega_lotto/configuration_spec.rb:15
Finished in 0.00175 seconds
2 examples, 2 failuresEven though we still have failures, we’re making progress. The failures above relate to the lack of a #drawing_count= method, so let’s add an accessor for it:
module MegaLotto
class Configuration
attr_accessor :drawing_count
end
endNote: We could’ve just added an attr_writer to satisfy the spec. However, I know I’m going to need a getter down the road, so I chose to do it at the same time.
With our accessor in place, let’s check the specs again:
MegaLotto::Configuration
#drawing_count=
can set value
#drawing_count
default value is 6 (FAILED - 1)
Failures:
1) MegaLotto::Configuration#drawing_count default value is 6
Failure/Error: expect(config.drawing_count).to eq(6)
expected: 6
got: nil
(compared using ==)
# ./spec/mega_lotto/configuration_spec.rb:8
Finished in 0.00239 seconds
2 examples, 1 failureStill a failure, but we’re slowly making more progress. The default value isn’t getting set so we’ll change that in the implementation:
module MegaLotto
class Configuration
attr_accessor :drawing_count
def initialize
@drawing_count = 6
end
end
endRunning the specs one more time for the Configuration class shows that we’re good:
MegaLotto::Configuration
#drawing_count
default value is 6
#drawing_count=
can set value
Finished in 0.00172 seconds
2 examples, 0 failuresRunning the specs for the main spec/mega_lotto.rb class again:
MegaLotto
#configure
returns an array with 10 elements (FAILED - 1)
Failures:
1) MegaLotto#configure returns an array with 10 elements
Failure/Error: expect(draw.size).to eq(10)
expected: 10
got: 6
(compared using ==)
# ./spec/mega_lotto_spec.rb:15:in `block (3 levels) in <top (required)>'
Finished in 0.00168 seconds
1 example, 1 failureWe still have the same failures from before, but it’s because we didn’t change the MegaLotto::Drawing to actually use the new configuration class. Since we have this awesome new class, let’s make use of it in MegaLotto::Drawing:
module MegaLotto
class Drawing
def draw
MegaLotto.configuration.drawing_count.times.map { single_draw }
end
private
def single_draw
rand(0...60)
end
end
endRunning the specs for the drawing class gives us the following output:
MegaLotto::Drawing
#draw
each element is less than 60 (FAILED - 1)
each element is an integer (FAILED - 2)
returns an array (FAILED - 3)
using the default drawing count
returns an array with 6 elements (FAILED - 4)
Failures:
1) MegaLotto::Drawing#draw each element is less than 60
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `configuration` for MegaLotto:Module
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:19
2) MegaLotto::Drawing#draw each element is an integer
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `configuration` for MegaLotto:Module
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:13
3) MegaLotto::Drawing#draw returns an array
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `configuration` for MegaLotto:Module
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:9
4) MegaLotto::Drawing#draw using the default
drawing count returns an array with 6 elements
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `configuration` for MegaLotto:Module
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:26
Finished in 0.00219 seconds
4 examples, 4 failuresWell…I guess it’s clear that it doesn’t have a configuration accessor, huh? Let’s add it to lib/mega_lotto.rb:
module MegaLotto
class << self
attr_accessor :configuration
end
def self.configure
end
end
and our specs:
MegaLotto::Drawing
#draw
each element is less than 60 (FAILED - 1)
each element is an integer (FAILED - 2)
returns an array (FAILED - 3)
using the default drawing count
returns an array with 6 elements (FAILED - 4)
Failures:
1) MegaLotto::Drawing#draw each element is less than 60
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `drawing_count` for nil:NilClass
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:19
2) MegaLotto::Drawing#draw each element is an integer
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `drawing_count` for nil:NilClass
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:13
3) MegaLotto::Drawing#draw returns an array
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `drawing_count` for nil:NilClass
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:9
4) MegaLotto::Drawing#draw using the default
drawing count returns an array with 6 elements
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `drawing_count` for nil:NilClass
# ./lib/mega_lotto/drawing.rb:4:in `draw'
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:26
Finished in 0.00146 seconds
4 examples, 4 failures
A different message this time, related to the fact that the configuration accessor has no #drawing_count method. This makes sense because we don’t actually return anything from #configuration. Let’s instantiate a new Configuration object and see where that gets us:
module MegaLotto
class << self
attr_writer :configuration
end
def self.configuration
Configuration.new
end
def self.configure
end
endNow, the Drawing class specs are passing:
MegaLotto::Drawing
#draw
each element is an integer
each element is less than 60
returns an array
using the default drawing count
returns an array with 6 elements
Finished in 0.01007 seconds
4 examples, 0 failuresLet’s flip back to the spec file spec/mega_lotto_spec.rb and see where we are:
MegaLotto
#configure
returns an array with 10 elements (FAILED - 1)
Failures:
1) MegaLotto#configure returns an array with 10 elements
Failure/Error: expect(draw.size).to eq(10)
expected: 10
got: 6
(compared using ==)
# ./spec/mega_lotto_spec.rb:15
Finished in 0.00167 seconds
1 example, 1 failureStill failing, but at least we have what seems like the pieces setup to implement the global configuration. The .configure methods needs to yield the configuration block to a new instance of the Configuration class. However, we’ll need to memoize the configuration instance, so when the Drawing class accesses #drawing_count, it returns the initial configuration value:
module MegaLotto
class << self
attr_writer :configuration
end
def self.configuration
@configuration ||= Configuration.new
end
def self.configure
yield(configuration)
end
endNote: it’s important to return the class instance variable from .configuration and check if that is set rather than the reader method, otherwise it’d get stuck in a loop and never return.
Running our specs again, we see that we’re green:
MegaLotto
#configure
returns an array with 10 elements
Finished in 0.00168 seconds
1 example, 0 failuresFor the sake of sanity, let’s run the whole suite to make sure everything is covered:
$ rake
.......
Finished in 0.00688 seconds
7 examples, 0 failures…and we’re good! Except, if we run our entire suite a few times in a row, we’ll eventually see a failure:
Failures:
1) MegaLotto::Drawing#draw returns an Array with 6 elements
Failure/Error: expect(drawing.size).to eq(6)
expected: 6
got: 10
(compared using ==)
# ./spec/mega_lotto/drawing_spec.rb:13
Finished in 0.00893 seconds
7 examples, 1 failureWhat’s going on???
In the setup of the spec for MegaLotto.configure, we added the following before block:
before :each do
MegaLotto.configure do |config|
config.drawing_count = 10
end
endBecause this configuration is global, if this spec is run before the others in our suite, the remaining specs will use it. So when the specs for MegaLotto::Drawing run, 10 elements are return instead of the 6, the default, and we see the failure.
For global values like this, it’s best to clean up after each spec to ensure the system is back to a default state. In our case, we can implement a .reset method on MegaLotto and set the configuration back to a new instance of the Configuration class. Let’s start with a spec for this in spec/mega_lotto_spec.rb:
describe ".reset" do
before :each do
MegaLotto.configure do |config|
config.drawing_count = 10
end
end
it "resets the configuration" do
MegaLotto.reset
config = MegaLotto.configuration
expect(config.drawing_count).to eq(6)
end
endAs expected, we see failure because we have yet to implement the .reset method:
Failures:
1) MegaLotto.reset resets the configuration
Failure/Error: MegaLotto.reset
NoMethodError:
undefined method `reset` for MegaLotto:Module
# ./spec/mega_lotto_spec.rb:28
Finished in 0.00762 seconds
8 examples, 1 failureLet’s do that now:
module MegaLotto
class << self
attr_writer :configuration
end
def self.configuration
@configuration ||= Configuration.new
end
def self.reset
@configuration = Configuration.new
end
def self.configure
yield(configuration)
end
endOur specs for the .reset method pass, so now we need to make use of it to clean up after our .configure spec:
describe "#configure" do
before :each do
MegaLotto.configure do |config|
config.drawing_count = 10
end
end
it "returns an array with 10 elements" do
draw = MegaLotto::Drawing.new.draw
expect(draw).to be_a(Array)
expect(draw.size).to eq(10)
end
after :each do
MegaLotto.reset
end
endNow we can be sure that our specs pass no matter the order of execution.
Local Configuration
The configuration approach above implements a global configuration object. The downside is that we can’t have multiple instances of our code running with different configurations. To avoid this, we could isolate the configuration class and only pass it to those objects that need it. By doing this, we’d avoid needing the MegaLotto.configure method entirely.
With this in mind, Drawing might look like:
module MegaLotto
class Drawing
attr_accessor :config
def initialize(config = Configuration.new)
@config = config
end
def draw
config.drawing_count.times.map { single_draw }
end
private
def single_draw
rand(0...60)
end
end
endWe can supply our own configuration object during instantiation if the defaults aren’t appropriate. In this case, as long as the object responds to drawing_count, everything will work.
require 'ostruct'
config = OpenStruct.new(drawing_count: 10)
MegaLotto::Drawing.new(config).draw #=> [23, 4, 21, 33, 48, 12, 43, 13, 2, 5]Both approaches are certainly valid, so I’ll leave it to you to decide which approach is best for your gem.
Implementations in the Wild
The CarrierWave gem is a popular choice to support avatar uploading. The author(s) realized that not everyone would want to store upload assets on the local system, so they offered the functionality to support Amazon S3 and other similar block storage services. In order to set this value, you’d use a configure block almost identical to the one we wrote above.
Thoughtbot wrote a great article about the configuration implementation in their Clearance gem. It’s worth reading even if you don’t plan to use Clearance.
Summary
Keeping gems configurable means balancing your use case with the use cases of others. The more flexibility you offer to users of your gem, the more users will find value in your work. However, there’s a point when offering too much configuration can make the internals of a gem unnecessarily complicated. As you probably know, Ruby is a language full of conventions and it’s best to provide reasonable defaults and only adapt if the need arises.
One approach to balance complexity is to create a system where users can write their own middleware to modify the behavior of the default system. Mike Perham created a middleware system for Sidekiq allowing users to add functionality as they wish. Doing so doesn’t require the gem to change at all when unique use cases arise. The implementation of such system is beyond the scope of this book. However, if you want to learn more, Sidekiq’s implementation is a great place to start.