In previous articles I shared how I moved a solution to a problem into a general tool.
Building your own tools helps you avoid solving the same problem over and over again. Not only does it give you more power over the challenges in your system, but it gives you a point of communication about how a problem is solved.
By building tools around your patterns you'll be able to assign a common language to how you understand it's solution. Team members are better able to pass along understanding by using and manipulating the tools of their trade rather than reexplaining a solution and repeating the same workarounds.
We can compress our ideas and solutions into a simpler language by building up the Ruby code that supports it.
Here's the code:
module ProcessLater
def later(which_method)
later_class.enqueue(initializer_arguments, 'trigger_method' => which_method)
end
private
def later_class
self.class.const_get(:Later)
end
class Later < Que::Job
# create the class lever accessor get the related class
class << self
attr_accessor :class_to_run
end
# create the instance method to access it
def class_to_run
self.class.class_to_run
end
def run(*args)
options = args.pop # get the hash passed to enqueue
self.class_to_run.new(args).send(options['trigger_method'])
end
end
def self.included(klass)
# create the unnamed class which inherits what we need
later_class = Class.new(::ProcessLater::Later)
# name the class we just created
klass.const_set(:Later, later_class)
# assign the class_to_run variable to hold a reference
later_class.class_to_run = klass
end
end
I showed how I'd use this code with this sample:
class SomeProcess
include ProcessLater
def initialize(some_id)
@initializer_arguments = [some_id]
@object = User.find(some_id)
end
attr_reader :initializer_arguments
def call
# perform some long-running action
end
end
Unfortunately EVERY class that uses ProcessLater will need to implement initializer_arguments
. What will happen if you forget to implement it? Errors? Failing background jobs?
Ruby's Comparable library is an example of one that requires a method to be defined in order to be used properly, so it's not an unprecedented idea.
Dangerous combination: implicit dependencies and confusing failures
The Comparable library is a fantastic tool in Ruby's standard library. By defining one method, you gain many other useful methods for comparing and otherwise organizing your objects.
But here's an example of what happens when you don't define that required method:
# in a file called compare.rb
class CompareData
include Comparable
def initialize(data)
@data = data
end
end
first = CompareData.new('A')
second = CompareData.new('B')
first < second # => compare.rb:12:in `<': comparison of CompareData with CompareData failed (ArgumentError)
comparison of CompareData with CompareData failed (ArgumentError)
isn't a helpful error message. It even tells me the problem is in the <
data-preserve-html-node="true" method and it's an ArgumentError
, but it's actually not really there.
If you're new to using Comparable, this is a surprising result and the message tells you nothing about what to do to fix it.
If you know how to use Comparable, you'd immedately spot the problem in our small class: there's no <=>
method (often called the "spaceship operator").
The Comparable library has an implicit dependency on <=>
in classes where it is used.
We can fix our code by defining it:
# in a file called compare.rb
class CompareData
include Comparable
def initialize(data)
@data = data
end
attr_reader :data
def <=>(other)
data <=> other.data
end
end
first = CompareData.new('A')
second = CompareData.new('B')
first < second # => true
After what could have been a lot of head scratching, we've got our comparable data working. Thanks to our knowledge of that implicit dependency, we got past it quickly.
Built-in dependency warning system
Although it's true that the documentation for Comparable says The class must define the <=> operator
, it's always nice to know that the code itself will complain in useful ways when you're using it the wrong way.
Sometimes we like to dive into working with the code to get a feel for how things work. Comparable and libraries like it that have implicit dependencies don't lend themselves to playful interaction to discover it's uses.
I mentioned this implicit dependency in the previous article:
The downside with this is that we have this implicit dependency on the
initializer_arguments
method. There are ways around that and techniques to use to ensure we do that without failure but for the sake of this article and the goal of creating this generalized library: that'll do.
But really, that won't do. Requiring developers to implement a method to use this ProcessLater library isn't bad, but there should be a very clear error to occur if they do forget.
Documentation can be provided (and it should!) but I want the concrete feedback I get from direct interaction with it. I'd hate to have developers spend time toying with a problem only to remember hours later that they forgot the most important part.
Better yet, I'd like to provide them with a way to ensure that they don't forget.
We could check for the method we need when the module is included:
module ProcessLater
def self.included(klass)
unless klass.method_defined?(:initializer_arguments)
raise "Oops! You need to define `initializer_arguments' to initialize this class in the background."
end
end
end
class SomeProcess
include ProcessLater
end # => RuntimeError: Oops! You need to define `initializer_arguments' to initialize this class in the background.
That's helpful noise. And it should be easy to fix:
class SomeProcess
include ProcessLater
def initializer_arguments
# ...
end
end # => RuntimeError: Oops! You need to define `initializer_arguments' to initialize this class in the background.
Wait a minute! What happened!?
When the Ruby virtual machine processes this code, it executes from the top to the bottom.
The included
hook is fired before the required method is defined.
We could include the library after the method definition:
class SomeProcess
def initializer_arguments
# ...
end
include ProcessLater
end # => SomeProcess
Although that works, other developers will find this to be a weird way of putting things together. Ruby developers tend to expect modules at the top of the source file. Although this example is small, it is, afterall, just an example so we should expect that a real world file would be much larger than just these few lines. Finding dependecies included at the bottom of the file would be a surprise, or perhaps we might not find them at all when first reading.
Everything in it's right place
Let's keep the included module at the top of the file to prevent confusion and make our dependencies clear.
We can automatically define the initializer_arguments
method and return an empty array:
module ProcessLater
def initializer_arguments; []; end
end
But that would do away with the helpful noise when we forget to set it.
One way to ensure that the values are set is to intercept the object initialization. I've written about managing the initialize method before but here's how it can be done:
module ProcessLater
def new(*args)
instance = allocate
instance.instance_variable_set(:@initializer_arguments, args)
instance.send(:initialize, *args.flatten)
instance
end
end
The new
method on a class is a factory which allocates a space in memory for the object, runs initialize
on it, then returns the instance. We can change this method to also set the @initializer_arguments
variable.
But this also requires that we change the structure of our module.
Because we want to use a class method (new
) we need to extend our class with a module instead of including it.
Our ProcessLater module already makes use of the included
hook, so we can do what we need there. But first, let's make a module to use under the namespace of ProcessLater
.
module ProcessLater
module Initializer
def new(*args)
instance = allocate
instance.instance_variable_set(:@initializer_arguments, args)
instance.send(:initialize, *args.flatten)
instance
end
end
end
Next, we can add a line to the included
hook to wire up this new feature:
module ProcessLater
def self.included(klass)
later_class = Class.new(::ProcessLater::Later)
klass.const_set(:Later, later_class)
# extend the klass with our Initializer
klass.extend(Initializer)
later_class.class_to_run = klass
end
end
The final change, is to make sure that all objects which implement this module, have the initializer_arguments
method to access the variable that our Initializer sets.
module ProcessLater
attr_reader :initializer_arguments
end
No longer possible to forget
Our library will now intercept calls to new
and store the arguments on the instance allowing them to be passed into our background job.
Developers won't find themselves in a situation where they could forget to store the arguments for the background job.
Here's what it's like to use it:
class SomeProcess
include ProcessLater
def initialize(some_id)
@some_id = some_id
end
attr_reader :some_id
def call
# ...
end
end
That's a lot simpler than adding a line in every initialize
method to store an implicitly required @initializer_arguments
variable.
Although developers on your team will no longer find themselves in a situation to forget something crucial, you may still not like overriding the new
method like this. That's might be a valid concern for your team, and I have an alternative approach to create a custom and explicit initialize method next time.
For now, however, we can see that Ruby gives us the power to make our code easy to run in the background, but Ruby gives us what we need to automatically manage our dependencies as well.
What this means for other developers
When we write software, we are not only solving a technical or business problem, but we're introducing potential for our fellow developers to succeed or fail.
This can be an important factor in how your team communicates about your work and the code required to do it.
It may be acceptable to have a library like Comparable which implicitly requires a method to be defined. Or perhaps something like that might fall through the cracks and cause bugs too easily.
If we build tools that implicitly require things, it's useful to automatically provide them.
Ready to go
We finally have a tool that can be passed along to others without much fear that they'll run into surprising errors.
Our ProcessLater library is ready to include in our classes. We can take our long-running processes and isolate them in the background by including our module and using our later
method on the instance:
class ComplexCalculation
include ProcessLater
# ...existing code for this class omitted...
end
ComplexCalculation.new(with, whatever, arguments).later(method_to_run)
This gives us a way to reevaluate the code which might be slow or otherwise time consuming and make a decision to run it later. As developers come together to discuss application performance issues, we'll have a new tool in our vocabulary of potential techniques to overcome the challenges.
Finally, here's the complete library:
module ProcessLater
def later(which_method)
later_class.enqueue(initializer_arguments, 'trigger_method' => which_method)
end
attr_reader :initializer_arguments
private
def later_class
self.class.const_get(:Later)
end
class Later < Que::Job
# create the class lever accessor get the related class
class << self
attr_accessor :class_to_run
end
# create the instance method to access it
def class_to_run
self.class.class_to_run
end
def run(*args)
options = args.pop # get the hash passed to enqueue
self.class_to_run.new(args).send(options['trigger_method'])
end
end
def self.included(klass)
# create the unnamed class which inherits what we need
later_class = Class.new(::ProcessLater::Later)
# name the class we just created
klass.const_set(:Later, later_class)
# add the initializer
klass.extend(Initializer)
# assign the class_to_run variable to hold a reference
later_class.class_to_run = klass
end
module Initializer
def new(*args)
instance = allocate
instance.instance_variable_set(:@initializer_arguments, args)
instance.send(:initialize, *args.flatten)
instance
end
end
end
When you solve your application's challenges, how to you build new tools? In what ways are the tools you build aiding future developers in there ability to overcome challenges without confusing errors or unknown dependencies?