August 10, 2020
So I’m currently making a Rubygem called Snowpacker and I ran into an interesting problem.
In Snowpacker, I allow users to define various attributes within a Rails app initializer like so:
Snowpacker.configure do |snowpacker|snowpacker.config_dir = Rails.root.join("config", "snowpacker")# ... more optionsend
The code to set this up is fairly straight forward. In my gem I have the following 2 files:
First, we have to make a Configuration
object.
module Snowpackerclass Configurationattr_accessor :config_dirattr_accessor :config_fileattr_accessor :babel_config_file# ... more accessorsendend
Then, we need to make the configuration available project wide. To do
so, we have to create a class method to define a Configuration
instance
and then we create an attr_accessor
to be able to set & get the
Configuration
values. In a nutshell we want to be able to do the
following:
Snowpacker.configure do |snowpacker|snowpacker.attr = "value"end
As well as be able to do this:
Snowpacker.config.attr = "other value"
To do so, we have to do the following:
# ... other require statementsrequire "snowpacker/configuration"module Snowpacker# Everything below this is the same as def self.method; stuff; endclass << selfattr_accessor :configdef configureself.config ||= Configuration.newyield(config) if block_given?endendend
So now everything works as expected. There’s just one problem. What if a
user wants to define another attr_accessor
? I can’t possibly account
for this. So, lets look at how to define a dynamic attr_accessor
.
Well first, attr_accessor
combines attr_writer
and attr_reader
.
Totally not helpful right? Well lets break it down further.
attr_reader :name
is the equivalent of:
def name@nameend
And attr_writer :name
is the equivalent of:
def name=(value)@name = valueend
So attr_accessor
neatly provides the 2 above methods for us.
The only issue is, you can’t technically dynamically define an
attr_accessor
, instead, you have to manually define both methods listed
above to achieve the same functionality.
But Konnor, why does that matter? Well the reason it matters is that in
my snowpack.config.js
I read the value of Environment variables to
make certain things behave in certain ways. The way these values are
set are via instance variables that are read from the Configuration
object. Basically, Snowpacker
will take all
the instance_variables of the Configuration
object and prepend
“SNOWPACKER_” to them.
For example, if you’re given the following code:
Snowpacker.configure do |snowpacker|snowpacker.config_dir = Rails.root.join("config", "snowpacker")snowpacker.babel_config_file = File.join(snowpacker.config_dir, "babel.config.js")# ... more optionsend
What Snowpacker will do at runtime is create a SNOWPACKER_CONFIG_DIR
environment variable as well as a SNOWPACKER_BABEL_CONFIG_FILE
. Both
values can now be accessed via ENV["SNOWPACKER_CONFIG_DIR"]
and ENV["SNOWPACKER_BABEL_CONFIG_FILE"]
respectively.
Initially I had a very ugly non-idiomatic workaround. Then it dawned on
me to use the method_missing
approach.
In a nutshell, the method_missing
is a method defined on every
Object
that checks to see if a method exists. If it does not exist, it
prints a stacktrace and raises a NoMethodError
. So what we’re doing is
overriding the existing method_missing
on the Configuration
Object
to be able to dynamically define methods. Rails makes heavy use of this
pattern.
Here’s how I setup dynamic attribute getting and setting in Snowpacker.
module Snowpackerclass Configurationattr_accessor :config_dir# ... Other base accessors`def method_missing(method_name, *args, &block)# Check if the method missing is an "attr=" methodraise unless method_name.to_s.end_with?("=")setter = method_namegetter = method_name.to_s.slice(0...-1).to_syminstance_var = "@#{getter}".to_symdefine_singleton_method(setter) do |new_val|instance_variable_set(instance_var, new_val)enddefine_singleton_method(getter) { instance_variable_get(instance_var) }# Ignores all arguments but the first onevalue = args[0]# Actually sets the value on the instance variablesend(setter, value)rescue# Raise error as normal, nothing to see heresuper(method_name, *args, &block)endendend
So now with the above we could add an attr onto our Configuration
object without worry about adding an attr_accessor
.
If you’re sitting there scratching your head, I don’t blame you. This may seem like a lot but lets break it down line by line.
def method_missing(method_name, *args, &block)
All this means is that we’re overriding method_missing
for all
Configuration Objects.
raise unless method_name.to_s.end_with?("=")
If the method name does not end with an equal sign, raise an error.
In other words, we want to raise an error if the method we’re trying to
call is not a setter
(attr=
). That’s it, pretty cool right!
Heres an example of what we want:
Snowpacker.config.test # will raise an errorSnowpacker.config.test = "value" # will not raise an error.Snowpacker.config.test # now returns "value"
So now that we know we’re only dealing with methods that look like
random_attribute=
we can start making more assumptions.
setter = method_name
we’re just renaming the argument to make our intent more clear.
getter = method_name.to_s.slice(0...-1).to_sym
Because the setter method
contains an equal sign, the getter cannot contain the equal sign. So to
fix this we turn it to a string, slice
off the equal sign at the end,
then convert it back to a symbol so we can use it as a method.
instance_var = "@#{getter}".to_sym
When we create add an instance
variable it must be in the form:
:@example_instance_variable
so all we’re doing here is prepending a ”@” to tell Ruby that its an instance variable.
Alright now we’re getting to do the actual work:
define_singleton_method(setter) do |new_val|instance_variable_set(instance_var, new_val)end
This is our setter method. What we’re saying is “create a method in the
form variable_name=(value)
. In other words, we’re recreating
attr_writer
here. This allows us to write new values to the instance
variable.
define_singleton_method(getter) { instance_variable_get(instance_var) }
So if the previous method was the attr_writer
, this is the
attr_reader
. So now we technically have the attr_accessor
functionality we were looking for, theres one issue though. When a user
goes to set the value for the first time, it wont actually set. To fix
this we implement the below code:
value = args[0]send(setter, value)
This sets our instance variable to the value we passed in.
For example:
Snowpacker.config.test_attr = "attr_value"# "test_attr" is the setter# "attr_value" is the value
Alright so thats all the logic. But what does that last little bit do?
rescuesuper(method_name, *args, &block)end
All this does, is if any error occurs, send it up the method_missing
call chain and raise a NoMethodError
.
That’s it. Wield this new found power wisely!
If you use a linter, it will probably tell you to define a
respond_to_missing?
method. It’s really not needed here since we’re
directly defining methods, but if you want to make your linter happy,
here ya go:
def respond_to_missing?(method_name, include_private = false)method_name.to_s.end_with?("=") || superend
We’re just telling Ruby, any method that ends with an equal sign is
actually a method for the Configuration
Object.
Heres the Thoughtbot post on it: https://thoughtbot.com/blog/always-define-respond-to-missing-when-overriding
Happy Rubying, or whatever the kids say these days!
Written by Konnor Rogers who currently works as a paramedic looking to transition into becoming a full time software developer. You should follow him on Twitter