Calling private methods without losing sleep at night
Today, I'm going to show you a simple way to commit crimes with a clean conscience.
First, two things I strive for when writing software:
- Making my code do the right thing, even when it requires doing the wrong thing
- Finding out that my shit is broken before all my sins are exposed in production
Today, I was working on a custom wrapper of Rails' built-in static file server, and deemed that it'd be wiser to rely on its internal logic for mapping URL paths (e.g. /index.html
) to file paths (e.g. ~/app/public/index.html
) than to reinvent that wheel myself.
The only problem? The method I need, ActionDispatch::FileHandler#find_file
is private, meaning that I really "shouldn't" be calling it. But also, it's a free country, so whatever. I wrote this and it worked:
filepath, _ = @file_handler.send(:find_file,
request.path_info, accept_encoding: request.accept_encoding)
If you don't know Ruby, send
is a sneaky backdoor way of calling private methods. Encountering send
is almost always a red flag that the code is violating the intent of whatever is being invoked. It also means the code carries the risk that it will quietly break someday. Because I'm calling a private API, no one on the Rails team will cry for me when this stops working.
So, anyway, I got my thing working and I felt supremely victorious… for 10 whole seconds. Then, the doubts crept in. "Hmm, I'm gonna really be fucked if 3 years from now Rails changes this method signature." After 30 seconds hemming and hawing over whether I should inline the functionality and preemptively take ownership of it—which would separately run the risk of missing out on any improvements or security fixes Rails makes down the road—I remembered the answer:
I can solve this by codifying my assumptions at boot-time.
A little thing I tend to do whenever I make a dangerous assumption is to find a way to pull forward the risk of that assumption being violated as early as possible. It's one reason I first made a name for myself in automated testing—if the tests fail, the code doesn't deploy, and nothing breaks. Of course, I could write a test to ensure this method still works, but I didn't want to give this method even more of my time. So instead, I codified this assumption in an initializer:
# config/initializers/invariant_assumptions.rb
Rails.application.config.after_initialize do
next if Rails.env.production?
# Used by lib/middleware/conditional_get_file_handler.rb
unless ActionDispatch::FileHandler.instance_method(:find_file).parameters == [[:req, :path_info], [:keyreq, :accept_encoding]]
raise "Our assumptions about a private method call we're making to ActionDispatch::FileHandler have been violated! Bailing."
end
end
Now, if I update Rails and try to launch my dev server or run my tests, everything will fail immediately if my assumptions are violated. If a future version of Rails changes this method's signature, this blows up. And every time I engage in risky business in the future, I can just add a stanza to this initializer. My own bespoke early warning system.
Writing this note took 20 times longer than the fix itself, by the way. The things I do for you people.