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.
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:
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:
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:
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:
Re-running our specs gives us a different failure message this time:
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:
Running the configuration specs produces:
Let’s add the Configuration class:
Let’s try again:
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:
Now with the Configuration class properly loaded, let’s run our specs again:
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:
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:
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:
Running the specs one more time for the Configuration class shows that we’re good:
Running the specs for the main spec/mega_lotto.rb class again:
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:
Running the specs for the drawing class gives us the following output:
Well…I guess it’s clear that it doesn’t have a configuration accessor, huh? Let’s add it to lib/mega_lotto.rb:
and our specs:
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:
Now, the Drawing class specs are passing:
Let’s flip back to the spec file spec/mega_lotto_spec.rb and see where we are:
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:
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:
For the sake of sanity, let’s run the whole suite to make sure everything is covered:
…and we’re good! Except, if we run our entire suite a few times in a row, we’ll eventually see a failure:
What’s going on???
In the setup of the spec for MegaLotto.configure, we added the following before block:
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:
As expected, we see failure because we have yet to implement the .reset method:
Let’s do that now:
Our specs for the .reset method pass, so now we need to make use of it to clean up after our .configure spec:
Now we can be sure that our specs pass no matter the order of execution.
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:
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.
Both approaches are certainly valid, so I’ll leave it to you to decide which approach is best for your gem.
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.