We’ve been hearing great things about Merb routing so a few weeks ago, we wanted to see if we could get it working in a Rails application as a proof of concept. The goal of this project was to use the Merb routing engine along with router.rb and without touching any of our existing Rails application (i.e. anything in app/*). Pre-existing URL route recognition needed to continue to work and all named routes and url_for logic needed to generate the same URLs as before.
With the announcement that Merb and Rails are merging, we figured that it’d be a great time to share a little bit of what we learned.
Background
- We’re running Rails 2.2.2.
- When we started messing with routes, we had 2500 generated routes (i.e. “rake routes | wc -l”). After Aaron’s formatted routes patch, we had 1250. We currently have over 1500.
- Routing accounted for around 4 seconds out of 6 for ./script/console and mongrel_rails to start up.
- Each mongrel_rails process was running at a minimum of 250mb in production.
Step 1: compiling
Compiling is the first part of routing and involves loading an application’s routes.rb or router.rb and storing it in some way that allows for generation and recognition.
The merb_routing plugin contains a subset of merb-core… just the classes related to routes. It shouldn’t be a surprise that these classes assume they are running in a Merb environment and not a Rails environment. To successfully load these merb routing classes without drastically modifying the source, we wrote two small compatibility layers (mini-merb and mini-extlib) that provide some basic functionality that Merb and ExtLib provide (i.e. Merb.logger, Merb.root, etc). This enabled us to successfully load the Merb::Router classes in our environment without any errors.
We then rewrote our routes.rb by hand using router.rb syntax. We had to modify Merb routing slightly because we were getting “memory exhausted” compile errors. It turned out that Merb routing uses a single if/elsif structure to recognize routes and due to our large number of routes, we hit a ruby quirk where you can’t have more than 2498 branches of logic in a single if-elsif statement.
irb(main):001:0> eval("if 1; #{"elsif 1;" * 2498} end")
=> nil
irb(main):002:0> eval("if 1; #{"elsif 1;" * 2499} end")
SyntaxError: (eval):1:in `irb_binding': compile error
(eval):1: memory exhausted
The quick fix was using many single if statement with a return inside rather than one giant if-elsif. After a few hooks into ActionController::Routing we had our environment using Merb/router.rb instead of Rails/routes.rb.
We had to make a few other minor changes to Merb routing to be compatible with rails:
- added support for { :method => :any } in routing conditions
- added support for BLAH_index as a named route for singular resources (Rails creates a named route called “blog_index” instead of just “blog” for singular resources)
Lastly, we wrote a rake task that overrides the default “rake routes” to pull from Merb routes instead of Rails routes. At this point, we had the routes loaded and could see the objects in script/console.
Step 2: recognizing
Recognition is the second part of routing and happens at the beginning of every request. It translates a URL into various parameters and figures out which controller/action to invoke for that request.
Once we had router.rb working correctly, route recognition and parameter loading was surprisingly easy to wire in.
Step 3: generating
Generation is the third part of routing and translates structured options into a URL string. This happens every time you use url_for, a named route helper or redirect_to.
Generation was the trickiest part of this project and had the most edge cases. Philosophically speaking, Merb and Rails route generation are very different. Rails gives you named routes as helpers (person_path, person_url, etc) that you can use as well as a url_for() method which will search through all of the routes and find the best route given the options you provide. Merb on the other hand provides a single method, url(), which all generation goes through. If you don’t explicitly provide a named route, url() will use the default route (i.e. :controller/:action/:id) rather than looking for the “best route”.
Getting the named routes to work was pretty easy and only required a single method_missing catch-all for ActionController::Base and ActionView::Base. Using a simple regexp, if the requested method ends in _url or _path, it uses Merb::Router#url and passes in the named route.
There was a bit of trickery involved in getting the path vs. url as well as the :only_path stuff working correctly, but overall not too hard.
The last tricky piece was returning the best route instead of the default route when no named route was provided. This is accomplished by looping through all of the available routes and determining which of the routes satisfies the most options passed in. With 1500 routes, this turned out to be a bit slow, so some optimization ideas were borrowed from Rails and a cache is maintained of available routes given a controller/action.
Conclusion
We’ve been using this plugin successfully in production for the last month. Our environment startup time as well as our memory overhead were both reduced drastically as soon as we put it in to production. We started this development when Rails 2.1 was the latest stable release and benchmarks against Rails 2.1 put Merb routing way ahead in just about every metric we tested.
The routing in Rails 2.2 was sped up substantially and is now comparable to Merb (Rails wins in some benchmarks, Merb in others). BUT…. Merb still blows away rails in startup time, by 2-3x. We thought we could take out our merb-routing hacks and reduce code complexity, but after watching production restarts, we decided to put it back.
This plugin can be found on github: merb_routing
UPDATE: after talking to Carl Lerche, it sounds like the new router refactoring he is working on will support both syntaxes on the same codebase. That’ll be very cool.