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
end
Implementation
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
end
This 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 failure
Now 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
end
Re-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 failure
The 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
end
Running 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
end
Let’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
end
Now 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 failures
Even 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
end
Note: 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 failure
Still 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
end
Running 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 failures
Running 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 failure
We 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
end
Running 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 failures
Well…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
end
Now, 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 failures
Let’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 failure
Still 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
end
Note: 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 failures
For 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 failure
What’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
end
Because 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
end
As 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 failure
Let’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
end
Our 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
end
Now 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
end
We 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.