Rails Routing Internals
Lately I’ve been trying to figure out D (the programming language). I grew up on PHP and Ruby, so I’m not used to compiled languages. I decided I’d make an API (for use with an Ember app) and learn D in the process. First thing I did was check out vibe-d. Vibe-d is a web server and framework for D. I’m not a fan of Vibe route definition so I decided to try and reimplement Rails routes. Along the way I learned a ton about how Rails routing works.
The problem I was trying to work around was how Vibe-d implemented their router. There’s an object, UrlRouter
which you use to define routes, with a path and a pointer to a method.
void userInfo(HTTPServerRequest req, HTTPServerResponse res) {
auto username = req.params["user"];
render!("userinfo.jd", username)(res);
}
void addUser(HTTPServerRequest req, HTTPServerResponse res) {
enforceHTTP("user" in req.post, HTTPStatus.badRequest, "Missing user field.");
res.redirect("/users/"~req.post["user"]);
}
shared static this() {
auto router = new UrlRouter;
router.get("/users/:user", &userInfo);
router.post("/adduser", &addUser);
}
// This gives us two routes:
// GET /user/1 => this.userInfo(request, response);
// POST /adduser => this.addUser(request, response);
I wasn’t a huge fan of that. My router should be able to infer method names and routes from a resource (just like Rails does it). I wanted nice, easy to define restful routes:
ActionController::Routing::Routes.draw do |map|
map.resources :users
end
# This provides routes
# GET /users => UsersController#index
# GET /users/1 => UsersController#show
# POST /users => UsersController#create
# GET /users/1/edit => UsersController#edit
# PUT /users/1 => UsersController#update
# DELETE /users/1 => UsersController#destroy
As you can see, with just three lines we get six routes. Super powerful. So how do we get there? To understand how Rails pulls it off, I dug into the source code.
Understanding the Rails request cycle #
When Rails receives a request, it creates a new request object with the URL. The request sends the URL to the mapper. The mapper attempts to find a matching route. Once the mapper finds a route, the mapper creates a new controller object. The mapper passes the request information to the controller. Finally, the controller calls the correct action and renders the view.
The first concept to understand here is a route. A route consists of an http verb, a path, a controller and an action. The verb is one of the HTTP Methods. The path can have parameters in it, for example the path /users/:id
has one parameter, :id
. The controller and action correspond to a class and method.
The second is the mapper, or what Rails calls a RouteSet. Rails uses a gem by the name of Journey to handle its mapper. Rails supports a lot of flexibility that I don’t intend to support, so there’s a lot more code involved. It all boils down to RouteSet#add_route
which looks like this:
# lib/journey/routes.rb
###
# Add a route to the routing table.
def add_route(app, path, conditions, defaults, name = nil)
route = Route.new(name, app, path, conditions, defaults)
route.precedence = routes.length
routes << route
named_routes[name] = route if name && !named_routes[name]
clear_cache!
route
end
When you call #resources
, it just calls #add_route
a bunch of times with different names, paths and conditions.
Matching a request to a route #
When a request comes in, Rails iterates the routes in the mapper to find a match. Finding a match is straight forward. If you noticed above, Journey is setting a precedence to each route. If more than one route matches a path, Rails uses the one with the highest precedence.
As Rails looks for a match it pulls the params from any potential paths and builds the params hash. It then passes the hash to the controller object. For example, a path of /users/1/edit
could match a route with path /users/:id/edit
with a params hash of{:id => "1"}
. It could also match a route with a path of /users/1/:action
with a params hash of {:action => "edit"}
. Because of this, Rails builds a params hash for every potential route match.
Once Rails identifies a route, it takes the controller and action from the route, then calls the action. That’s as easy as
def dispatch(controller, action, env)
controller.action(action).call(env)
end
Next time I’ll walk through my implementation in D. There were a few surprising hangups.