In the Ruby ecosystem, a well established way of customizing gem behavior is by using configuration blocks. Most likely, you came across code that looked like this.
MyGem.configure do |config|
config.some_config = true
config.some_class = MyApp
config.some_lambda = ->(variable) { do_important_stuff(variable) }
end
This standard way of configuring gems runs once, usually when starting an application, and allows developers to tailor the gem's functionality to their needs.
Adding the configuration block to a gem
Let's take a look at how this style of configuration can be implemented. We'll divide our implementation in two steps: writing a configuration class and exposing it to users.
Writing the configuration class
The configuration class is responsible for keeping all the available options and their defaults. It is composed of two things: an initialize method where defaults are set and attribute readers/writers.
Let's take a look at an example, where we can configure an animal and the sound that it makes. If the sound does not match the expected, we want to raise an error while configuring our gem. We'll then access what is configured for animal and sound and use it in our logic.
# lib/my_gem/configuration.rb
module MyGem
class Configuration
# Custom error class for sounds that don't
# match the expected animal
class AnimalSoundMismatch < StandardError; end
# Animal sound map
ANIMAL_SOUND_MAP = {
"Dog" => "Barks",
"Cat" => "Meows"
}
# Writer + reader for the animal instance variable. No fancy logic
attr_accessor :animal
# Reader only for the sound instance variable.
# The writer contains custom logic
attr_reader :sound
# Initialize every configuration with a default.
# Users of the gem will override these with their
# desired values
def initialize
@animal = "Dog"
@sound = "Barks"
end
# Custom writer for sound.
# If the sound variable is not exactly what is
# mapped in our hash, raise the custom error
def sound=(sound)
raise AnimalSoundMismatch, "A #{@animal} can't #{sound}" if SOUND_MAP[@animal] != sound
@sound = sound
end
end
end
Exposing the configuration
To expose the configuration means both letting users configure it and allowing the gem itself to read the value of each option.
This is done with a singleton of our Configuration class and a few utility methods.
# lib/my_gem.rb
module MyGem
class << self
# Instantiate the Configuration singleton
# or return it. Remember that the instance
# has attribute readers so that we can access
# the configured values
def configuration
@configuration ||= Configuration.new
end
# This is the configure block definition.
# The configuration method will return the
# Configuration singleton, which is then yielded
# to the configure block. Then it's just a matter
# of using the attribute accessors we previously defined
def configure
yield(configuration)
end
end
end
The gem is now set to be configured by the applications that use it.
MyGem.configure do |config|
# Notice that the config block argument
# is the yielded singleton of Configuration.
# In essence, all we're doing is using the
# accessors we defined in the Configuration class
config.animal = "Cat"
config.sound = "Meows"
end
Using the configuration in the gem
Now that we have the customizable Configuration singleton, we can read the values to change behavior based on it.
# lib/my_gem/make_sound.rb
module MyGem
class AnimalSound
def initialize
@animal = MyGem.configuration.animal
@sound = MyGem.configuration.sound
end
def make_sound
"The #{@animal} #{@sound}"
end
end
end
Using lambdas for configuration
In specific scenarios, there may be a need for a configuration to not have a predetermined value, but rather to evaluate some logic as the application is running.
For these cases, it is typical to define a lambda for the configuration value. Let's go through an example. The configuration class is similar to our previous case.
# lib/my_gem/configuration.rb
module MyGem
class Configuration
attr_reader :key_name
# Define no lambda as the default
def initialize
@key_name = nil
end
# Raise an error if trying to set the key_name
# to something other than a lambda
def key_name=(lambda)
raise ArgumentError, "The key_name must be a lambda" unless lambda.is_a?(Proc)
@key_name = lambda
end
end
end
Now we can configure the lambda to whatever we need. You could even query the database if desired inside the lambda and return values from the app's models.
MyGem.configure do |config|
config.lambda_config = ->(model_name) { model_name == "Post" ? :posts : :articles }
end
Finally, the gem can use the lambda and get different results as the app is running.
MyGem.configuration.key_name&.call("Article")
=> :articles
MyGem.configuration.key_name&.call("Post")
=> :posts
That's about it for configuration blocks. Have you used this before to make your code/gems customizable? Do you know other strategies for configuring third party libraries? Let me know in the comments!
Top comments (7)
Unless I'm trying to avoid dependencies, my usual go to for configuration is dry-configurable as it provides a nice way to to define, even deeply nested, settings.
As a bonus you get settings thread safety.
Oh, nice! I had not heard about that one, will have to check it out. And yeah, the solution in this article is not thread safe.
Dry-rb has a lot of nice gems with basically no heavy dependencies.
Totally worth a look. Maybe worth an internal presentation @ Shopify, fellow Shopifolk.
Love that you wrote about this — I see a lot of code that has instance variables that are really configuration vars. I, too, have been guilty of this, whenever I touch code and find myself altering one, I see if it’s too much effort. Most of the time things were simple enough that just needed a hash of key-values needing only a setter and getter. What I end up using is ActiveSupport Configurable. Are you familiar with it and do you use or advise the use of it?
I know it exists, but haven't used it myself. Do you find it more convenient? The only drawback in my opinion would be adding activesupport as a dependency if all you need is the configurable part.
I do! I've read about the different ways to do configurations, several blog posts from different people that I admire. I can't seem to find it in my bookmarks right now, but it's actually how I discovered it... the below is literally pulled from the Rails docs. But yeah, I've done none of the other variants, and found yours very interesting.
docs
Thanks for the heads up! Fixed the lambda syntax. I like the DSL approach too. The only inconvenience is that editors usually won't autocomplete the options for you, but with proper docs that's easy to overcome.
I actually wrote a bit on writing DSLs with
instance_eval
:)Write a simple DSL in Ruby
Vinicius Stock ・ Sep 24 '19 ・ 4 min read