Yes, it’s best to avoid “monkey-patching” — changing an already loaded ruby class by reopening the class to add or replace methods.
But sometimes you’ve got no choice, because a dependency just doesn’t give you the API you need to do what you need, or has a bug that hasn’t been fixed in a release you can use yet.
And in some cases I really do think it actually makes sense to most forward-compatibly make your customization to a dependency, in a way that’s surgically targetted to avoid replacing or copy-pasting code you _don’t_ want to customize, to make it most likely your code will keep working with future releases of the dependency.
Module#prepend, added in Ruby 2.0, makes it easier to do this kind of surgical intervention, because you can monkey-patch a new method replacing an original implementation, and still call super
to call default/original implementation of that very same method. Something you couldn’t do before to methods that were implemented directly in the original class-at-hand (rather than a module/superclass it includes/extends).
But a Module you are going to prepend can’t include “class macros”, class methods like activerecord’s `validates` for instance. For a module that’s going to be included in a more normal way, ActiveSupport::Concern in Rails can let ‘class macros’ live sensibly in the module — but AS::Concern has no support for prepend
, not gonna help. (Maybe a PR to Rails? If I had some indication that rails maintainers might be interested in such a PR, I might try to see if I could make something reasonable, but I hate working on tricky stuff only to have maintainers reject it as something they’re not interested in).
You might be able to hack something up yourself with Module#prepended, similar to an implementation one could imagine being a part of AS::Concern. But I don’t, I just Use The Ruby. Here’s how I do my class_eval monkey-patches with Concern, trying to keep everything as non-magical as possible, and without diminishing readability too much from when we just used class_eval
without Module.prepend
.
# spell out entire class name, so it's not defined yet # we'll get a raise -- we don't want to define it fresh here # accidentally when we're expecting to be monkey-patching Some::Dependency::Foo.class_eval # 'class macros' go here validates :whatever # We want the instance methods inline here for legibility, # looking kind of like an ordinary class. But we want # to use prepend. And giving it a name rather than an # anonymous module can help with stack traces and other debugging. # this is one way to do all that: prepend(FooExtension = Module.new do def existing_method if custom_guard_logic return false end super end end)
Last part: I put all these extensions in a directory I create, ./app/extensions
Because of what I’ll show you next, you can call these files whatever you want, so I put them in the same directory structure and with the same name as the original file being patched, but with _extension
on the end. So the above would be at ./app/some/dependency/foo_extension.rb
.
And then I put this to_prepare
in my ./config/application.rb
, to make sure all these monkey-patch extensions get loaded in dev-mode class-reloading, properly effecting the thing they are patching even if that thing is dev-mode class-reloaded too:
config.to_prepare do # Load any monkey-patching extensions in to_prepare for # Rails dev-mode class-reloading. Dir.glob(File.join(File.dirname(__FILE__), "../app/extensions/**/*_extension.rb")) do |c| Rails.configuration.cache_classes ? require(c) : load(c) end
So there you go. This seems to be working for me, arrived at this pattern in fits and pieces, copying techniques from other projects and figuring out what worked best for me.
This is some slick stuff. Had this bookmarked for a few weeks and finally tried it today and damn thats clean. Ruby just makes me happy.
I would like to point out to other readers that, Yes the parenthesis `( )` for the prepend method are required when using this inline method. I tried without and it silently failed.