z, ? | toggle help (this) |
space, → | next slide |
shift-space, ← | previous slide |
d | toggle debug mode |
## <ret> | go to slide # |
r | reload slides |
n | toggle notes |
Hello everyone. It is great to be back here at Mountain West Ruby Conf. The first time I presented at a conference was back in 2009 here at Mountain West. I’d like to apologize to those of you in the audience who had to endure that.|I last had the privilege of speaking at Mountain West back in 2010, and I’m thrilled to be back in 2015 giving a presentation on “Better Routing Through Trees”.
My name is Jeremy Evans. I’m the lead developer of the Sequel ruby database library, and the author of quite a few other ruby libraries. I’m also the maintainer of ruby ports for the OpenBSD operating system.
In this presentation, I’m going to discuss an approach to routing web requests that I call a routing tree, and explain the advantages that a routing tree offers compared to routing approaches used by other ruby web frameworks.
Before I can speak to benefits of a routing tree, I need to first discuss earlier approaches to routing. So let’s first delve into a brief history of routing web requests in Ruby.
I first starting using ruby in late 2004. At the time, there were only a few choices for web development in ruby. One approach was the old school approach of using CGI without a framework. With CGI, in general you had separate ruby files for each page, and the routing was handled by Apache, so routing wasn’t really an issue.
Another choice was Nitro, which is a web framework I’m guessing many of you have never heard of, since the last release was in 2006. At the time, Nitro used static routing, where the first segment in the request path specified a render class, and the second segment was the method to call on that class.
Another choice was Rails, which at the time was at version 0.8.5. Back then, Rails didn’t even perform routing. You could use Rails to generate pretty URLs, but Rails couldn’t actually route such URLs by itself.
If you wanted to use Rails with Apache, you had to use RewriteRules in Apache. Here’s an example from the apache.conf that shipped with Rails 0.9.1. It takes the /controller/action/id path and turns them into separate parameters for the controller, action, and id. This example implemented the static routing that Rails supported by default, but by using more advanced RewriteRules in Apache, you could get Rails to do custom routing.
RewriteRule ^([-_a-zA-Z0-9]+)/([-_a-zA-Z0-9]+)/([0-9]+)$ \
?controller=$1&action=$2&id=$3 [QSA,L]
In addition to supporting Apache, Rails at the time also worked with Webrick. However, if you were using Webrick, you were restricted to the controller/action/id static routing, which was hard coded into the Webrick integration. There was no way to support custom routing when using Webrick.
parsed_ok, controller, action, id = \
DispatchServlet.parse_uri(req.request_uri.path)
Rails started supporting custom routing in 0.10.0. The default was still the controller/action/id supported by the previous static routing, but you could use different patterns to support custom routing.
map.connect ':controller/:action/:id'
Internally, Rails stored each route in an array.
class RouteSet
def initialize
@routes = []
end
def add_route(route)
@routes << route
end
end
When a request came in, Rails would iterate over the stored routes, checking each to see if it matched the request.
self.each do |route|
controller, options = \
route.recognize(path)
if controller
request.path_parameters = options
return controller
end
end
As soon as it recognized the request path, routing would return the appropriate controller class, which would then handle the request.
self.each do |route|
controller, options = \
route.recognize(path)
if controller
request.path_parameters = options
return controller
end
end
Rails continues to use this basic approach of iterating over an array of routes for over three years. This code is from Rails 2.0 which still iterated over every route looking for a match.
routes.each do |route|
result = route.recognize(path, environment) \
and return result
end
Around the same time, Sinatra is released, with a radical simplification for how web applications can be developed, by specifying the routes directly, with each route yielding to a block to handle the action.
get '/' do
'Hello World'
end
While externally, Sinatra looks much simpler than Rails, internally they use a similar process for routing, storing routes in an array, and iterating over the set of routes when a request comes in, stopping at the first matching route.
def events
@events || []
end
def register_event(event)
(@events ||= []) << event
end
def determine_event(verb, path)
event = events.find do |e|
e.verb == verb && e.recognize(path)
end
end
Over the years, Sinatra’s basic approach hasn’t changed much. This is the current Sinatra code for routing, slightly simplified. The main change from the original implementation is that it now uses separate arrays of routes per request method.
if routes = base.routes[@request.request_method]
routes.each do |pattern, keys, conditions, block|
process_route(pattern, keys, conditions) do |*args|
env['sinatra.route'] = block.route_name
route_eval { block[*args] }
end
end
end
However, after getting the array of routes for the request method, it still iterates over the array of routes, checking each route until it finds a match.
if routes = base.routes[@request.request_method]
routes.each do |pattern, keys, conditions, block|
process_route(pattern, keys, conditions) do |*args|
env['sinatra.route'] = block.route_name
route_eval { block[*args] }
end
end
end
While Sinatra’s basic approach hasn’t changed much, Rails’ approach has changed significantly. From Rails 2.1 to 2.3, Rails tries to optimize route matching by checking for initially matching segments in the path, and if the current route’s prefix doesn’t match the current request, skipping subsequent routes with the same prefix.|As I wasn’t able to find another web framework that uses a similar approach, and modern versions of Rails use a different approach, I won’t be discussing this approach further.
In Rails 3.0 and 3.1, Rails uses rack-mount to handle routing. rack-mount is a dynamic tree-based router. It organizes routes into a tree based on parameters you provide, such that it can also skip similar routes if the current route does not match.
In case you want to use rack-mount for routing but don’t want the overhead of Rails, there’s a web framework called Synfeld that is a thin wrapper over rack-mount.
In Rails 3.2, Rails switched to Journey for route handling, which it still uses. Journey implements a deterministic finite automata engine for request path matching in pure ruby, which can take a request path and return all routes that could possibly match it.|Journey then iterates over the possibly matching routes to see if they actually match the request, then sorts routes by priority. In general the first of these resulting routes will be used to handle the request.
In case you want to use Journey for routing but don’t want the overhead of Rails, there’s a web framework called NYNY that offers a Sinatra-like DSL, but uses Journey internally for routing.
Back in January 2009, while Rails was at version 2.2, Christian Neukirchen, the author of Rack, was working on a proof of concept router named Rum.
The fundemental difference between Rum and the other routing approaches I’ve previous discussed is that in Rum, routing is not separated from request handling.
Rum.new do
on get, path('greet') do
on param('person') do |name|
puts 'Hello, ' + name
end
on default do
puts 'Hello, world!'
end
end
end
Instead, for each request, Rum yields to the block.
Rum.new do
on get, path('greet') do
on param('person') do |name|
puts 'Hello, ' + name
end
on default do
puts 'Hello, world!'
end
end
end
Routing is performed by calling the on method with arguments. If all the arguments are true, on will yield to the block passed to it. If not, on returns nil without yielding.
Rum.new do
on get, path('greet') do
on param('person') do |name|
puts 'Hello, ' + name
end
on default do
puts 'Hello, world!'
end
end
end
The get, path, and param methods here are predicates that check against the current request. The get method returns true if the current request uses the GET request method. The path method with the greet argument returns true if the first segment in the request path is greet. The param method with the person argument returns true if the request has a parameter named person.
Rum.new do
on get, path('greet') do
on param('person') do |name|
puts 'Hello, ' + name
end
on default do
puts 'Hello, world!'
end
end
end
By nesting these calls to on, you build what is basically a tree using Rum’s DSL. At any point in any of these on blocks, you can handle the current request.|One issue with Rum is that it was never published as a gem.
Rum.new do
on get, path('greet') do
on param('person') do |name|
puts 'Hello, ' + name
end
on default do
puts 'Hello, world!'
end
end
end
In April 2010, Michael Martins took Rum and added support for haml templates, and released the Cuba gem. And over the next 4 years he and others improved on Rum’s initial design.
In July of last year, after many years of using of using Sinatra as my primary web framework, I was trying out Cuba, and found that using a routing tree made certain aspects of web application development significantly simpler, but some aspects of Cuba’s design and implementation had issues that made it more cumbersome to use than Sinatra.
So I forked Cuba and released Roda, keeping the basic routing tree approach introduced by Rum, but otherwise trying to make it more friendly and Sinatra-like, as well as significantly faster.
So let me go over the routing approaches I’ve discussed so far.
The first was completely static routing, either using separate files using CGI, or using very early versions of Nitro or Rails. While static routing is fast, it also inflexible, and these days I don’t think anyone would consider a web framework that didn’t support custom routing.
The next approach was used by early versions of Rails and still used by Sinatra, by storing routes in an array, and just iterating over the array of routes, testing each route to see if it matches the current request. This process is fairly simple to implement and understand, but makes routing performance decrease linearly as the number of routes increases.
Next we have rack-mount, the underlying router used in Rails 3.0 and 3.1, and also by Synfeld. rack-mount organizes routes into a tree by considering things like matching prefixes shared by multiple routes. This can significantly increase routing performance, but at a large increase in complexity.
Next we have Journey, used by modern versions of Rails, and also by NYNY. While it is generally faster than the previous approaches used by Rails, it is probably the most difficult for the average ruby programmer to understand.
Finally, we have the routing tree based approach that was introduced by Rum and is currently implemented in Cuba and Roda.
I think there are three basic ways these routing implementations are different from one another. The first way is a quantitive difference.
The quantitative difference is in the performance. These different routing implementations all show different performance characteristics, especially as the number of routes increases.
In order to determine what the performance differences are, you need to benchmark the implementations with a varying number of routes. The issue here is that comparative benchmarks in general are biased specifically to show the advantages of the benchmark creator’s preferred choice.
And the benchmark I’m using is no different. The benchmark is called r10k, and it benchmarks each of these routing implementations using 10, 100, 1000, and 10,000 routes. I wrote r10k because the only other comparative benchmark I could find only benchmarked hello world applications with a single route.|While the structure of the sites benchmarked by r10k is certainly friendly to a routing tree approach, it should also be friendly to most other routing approaches.
r10k is open source on my GitHub, and I welcome external review to make sure I’m not doing anything stupid or unfair to the other routing implementations I’m benchmarking.|Let’s first look at the runtime results for 10, 100, and 1000 routes.
Here are the runtime results. Pay no attention to the absolute numbers, as it is only the relative performance differences that matter. One thing to note about these numbers is that r10k benchmarks using the Rack API directly, so this does not include the web server overhead.|From this graph, you can see that at 10 and 100 routes, Rails is an outlier, taking about three times as the next slowest framework. However, when get you to 1000 routes, Synfeld takes much longer than Rails and Sinatra takes almost as much time.
When you go to 10,000 routes, Synfeld and Sinatra take much more time than all the other frameworks put together. I’m not sure why Synfeld performs so poorly in this benchmark. It’s supposed to be a very thin layer over rack-mount, so it’s possibly an issue with rack-mount, or how Synfeld uses rack-mount. Anyway, because Synfeld, Sinatra, and Rails all throw off the scale of the graph, let’s take them out of the picture.
So with those frameworks gone, the performance picture is a little more clear. Near the bottom is the static-route implementation, which is basically the fastest routing you can get, but again, I don’t think anyone would use a static routing framework these days.|Next fastest is Roda, followed by NYNY and then Cuba. From this graph, you can see that a routing tree approach isn’t necessarily the fastest, performance is also highly dependent on the specific implementation.
Part of performance is also the amount of memory used. Here are the memory results for 10, 100, and 1000 routes. As you can see, up till 100 routes, all the webframeworks except Rails are clustered around 20MB of memory. At 1000 routes, there are three basic groups, with the static routing implementation, Cuba, and Roda using under 20MB, Sinatra, NYNY, and Synfeld using between 30 and 40MB, and then Rails up around 70MB.
When you get to 10,000 routes, the picture is pretty much the same, except that Synfeld jumps to the top of the memory list. At 10,000 routes, Sinatra uses about twice the memory of the routing tree implementations, and Rails about four times the memory of routing tree implementations.|One of the reasons routing tree implementations are very friendly on memory is that the routes themselves are not directly stored in a datastructure. The tree in a routing tree is really ruby’s abstract syntax tree for the routing tree block.
From a review of the benchmarks, other than the static routing approach, which I don’t think anyone would consider these days, Roda has the fastest implementation and uses the least memory, especially as the number of routes increases. NYNY does fairly well, showing the Journey’s approach to routing is also fast. Next comes Cuba, which performs much better than Rails or Sinatra, but is significantly slower than Roda despite using a similar approach.|Rails is a fairly heavyweight framework, but it’s performance doesn’t change drastically even with large numbers of routes. Finally, we have Synfeld and Sinatra, which both have significant performance issues with large numbers of routes.
One thing to keep in mind is that these performance numbers are pure routing performance numbers. In many if not most applications, routing performance will not be the bottleneck in the application, as the application will spend much more time handling a request than routing it.|However, I can say in the applications I’ve converted to Roda, performance is noticeably faster compared to Sinatra or Rails, as shown by the time it takes to run the tests. Using the same rack-test-based integration tests, after I converted applications from Sinatra to Roda, the tests ran about 50% faster, and after I converted applications from Rails to Roda, the tests ran about twice as fast. But that is probably due to Roda having lower per-request overhead, not because of its faster routing performance.
I mentioned earlier that there were three ways the routing implementations differ. The second way the routing implementations differ is a qualitative difference.
That qualitative difference is the internal complexity of each implementation. These routing approaches vary widely in their internal complexity.
Static routing is the simplest in terms of complexity, just parsing the request path using a single regular expression, and using the captures from the regular expression to call a method on an object.
Interating over an array of routes and checking each route to see if it matches the request is also fairly simple and easy for the average ruby programmer to understand.
rack-mount’s approach of analyzing the route set and building a tree is much more complex, and I think the average ruby programmer would have trouble understanding how it works without significant time to study it.
Journey is even more complex than that, and if you want to try to understand it, you should probably have at good memory of the compiler courses you took in college, or get ready to do some research into how compilers are implemented.
The routing tree approach is similar in complexity to an array of routes. You start off at the top of the routing tree block. Each method call checks to see if the current route matches the request. If so, the process is repeated for the block you pass to the method, otherwise you continue to the next method.|So a routing tree’s processing is equivalent to iterating over a small array of routes for each branch in the tree, instead of one large array of routes.
So the second type of difference between the routing approaches is the implementation complexity. Static routing, iterating over an array of routes, and using a routing tree all have fairly simple implementations that are easy to understand. Both rack-mount and Journey have much more complex implementations that would take a lot of time for the average ruby programmer to understand.
How does the internal complexity of the routing implementation impact users of the framework? Well, the higher the implementation complexity, the more difficult it is to find other programmers who can understand the code, add features to it, and fix bugs in it. In general, more complex code is harder to debug than simpler code. As a general rule, unless there is a substantial benefit from complexity, simplicity should be preferred.|Ulimately though, most users of a framework treat the internal complexity as an externality, something that does not affect them directly, and therefore does not affect their decision whether or not to use a framework.
So we come back to the 3 types of differences between the routing implementations. The first two were performance and implementation complexity. The third way the routing implementations differ is also a qualitative difference.
That difference is how routing integrates with request handling. Routing a request is not an end in itself, it is purely a means to make sure the request is handled correctly.
With a routing tree, routing is not separate from request handling, the two are integrated. So as you are routing a request, you can also be handling the request.
For all of the other implementations I’ve discussed, routing is separate from request handling.
This integration may not sound interesting, but I think it has by far the most impact. So I’m going to discuss the advantages of integrating routing with request handling, and then talk about what web frameworks that lack this integration offer in terms of similar functionality.
Let me first start with some example Sinatra code. This is fairly simple, we just have two routes, both related to a specific album, one for GET and one for POST. When I was using Sinatra, this was pretty typical in many of my applications.
get '/albums/:id' do
@album = Album[params[:id]]
erb(:album)
end
post '/albums/:id' do
@album = Album[params[:id]]
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
The main issue with this approach is that it leads to duplication. Here you see the path is duplicated in both of the routes.
get '/albums/:id' do
@album = Album[params[:id]]
erb(:album)
end
post '/albums/:id' do
@album = Album[params[:id]]
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
The retrieval of the album from the database is also duplicated in both of the routes.
get '/albums/:id' do
@album = Album[params[:id]]
erb(:album)
end
post '/albums/:id' do
@album = Album[params[:id]]
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
Using a routing tree, you can simplify things.
is 'albums/:id' do |id|
@album = Album[id]
get do
view(:album)
end
post do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
end
Instead of duplicating the path in both cases, it is specified once in the branch.
is 'albums/:id' do |id|
@album = Album[id]
get do
view(:album)
end
post do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
end
As soon as the branch is taken, the album is retreived from the database.
is 'albums/:id' do |id|
@album = Album[id]
get do
view(:album)
end
post do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
end
In both the get and post routes, the @album instance variable is available for use.
is 'albums/:id' do |id|
@album = Album[id]
get do
view(:album)
end
post do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
end
So one of the primary advantages of a routing tree is that it allows you to easily eliminate redundant code, by moving it to the highest branch where it is shared by all routes under that branch.
is 'albums/:id' do |id|
@album = Album[id]
get do
view(:album)
end
post do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
end
Now, it’s certainly possible to do something similar in Sinatra. You can use before blocks in Sinatra and provide a path to the before block, and Sinatra will iterate over all of the before blocks before routing the request, checking each to see if the request path prefix matches the before block. If so, it will yield to the before block. So using this you can still only retrieve the album from the database in a single place.
before '/albums/:id' do
@album = Album[params[:id]]
end
get '/albums/:id' do
erb(:album)
end
post '/albums/:id' do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
However, now note that you need to specify the path itself three times, instead of just once.
before '/albums/:id' do
@album = Album[params[:id]]
end
get '/albums/:id' do
erb(:album)
end
post '/albums/:id' do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
Unlike the routing tree example, the shared behavior is in a separate lexical scope, which I think makes it more difficult to understand the connection between the shared behavior and the two routes. The two routes are also in separate lexical scopes, which makes it more difficult to understand how they are connected.|Additionally, using before blocks like this in Sinatra has a negative effect on performance. Before blocks are processed pretty much the same way as route blocks, so adding a before block is equivalent to adding a route, and since routing performance degrades linearly as the number of routes increases, adding before blocks like this hurts preformance for the entire application.
before '/albums/:id' do
@album = Album[params[:id]]
end
get '/albums/:id' do
erb(:album)
end
post '/albums/:id' do
@album.update(params[:album])
redirect "/albums/#{@album.id}"
end
In Rails, you specify the routes in the config/routes.rb file, and the code to handle the routes goes in a controller class in a separate controller file, using a separate method per route.
def show
@album = Album.find(params[:id])
render(action: 'edit')
end
def update
@album = Album.find(params[:id])
@album.update_attributes(params[:product])
redirect_to @album
end
As in the initial Sinatra example, this duplicates the retrieval of the album from the database in both methods.
def show
@album = Album.find(params[:id])
render(action: 'edit')
end
def update
@album = Album.find(params[:id])
@album.update_attributes(params[:product])
redirect_to @album
end
Rails also offers a way to eliminate the redundant code, using a before filter to specify a method to call before the action, for a given set of actions.
before_filter :find_album, only: [:show, :update]
def show
render(action: 'edit')
end
def update
@album.update_attributes(params[:product])
redirect_to @album
end
private
def find_album
@album = Album.find(params[:id])
end
The main issue with this approach is that if you add more routes where you want to retrieve the album, you need to remember to update the only option to the before filter.|Also, as in the second Sinatra example, the shared behavior is in a separate lexical scope, which I think makes it more difficult to understand how it is connected.
before_filter :find_album, only: [:show, :update]
def show
render(action: 'edit')
end
def update
@album.update_attributes(params[:product])
redirect_to @album
end
private
def find_album
@album = Album.find(params[:id])
end
So Sinatra and Rails and most other web frameworks can use before filters to emulate code placed at the top of a routing tree block. However, as a routing tree is just standard ruby code, you can execute arbitrary code at any point during routing, not just at the top of the blocks.
route do
r.post 'login' do
session[:logged_in] = true
end
require_login!
on 'albums' do
# ...
end
end
One of the common places where this is useful is when doing access control. For example, if part of your site allows anonymous access, and part of your site does not, you can place the part that allows anonymous access first, and then run the check for a login, and then have the rest of the routes where anonymous access is not allowed.
route do
r.post 'login' do
session[:logged_in] = true
end
require_login!
on 'albums' do
# ...
end
end
Note that this is an issue with most sites that supports logins, since the login action is usually available to anonymous users.
route do
r.post 'login' do
session[:logged_in] = true
end
require_login!
on 'albums' do
# ...
end
end
This type of access control is more complex to handle in Sinatra. When I was using Sinatra, the usual way I would handle this would be to specifically whitelist each path or prefix that allowed anonymous access. This works OK if you only have a small number of paths that allow anonymous access, but quickly becomes difficult if you have a large number of separate paths that allow anonymous access.
before do
unless env['PATH_INFO'] =~ \
%r{\A/login|/foo|/bar/baz}
require_login!
end
end
Similarly, this type of access control is more complex in Rails. Usually in Rails, this would be handled by using a before filter in ApplicationController that required a login.
ApplicationController.before_filter :require_login!
Then, in each controller where you want to allow anonymous access, you need to skip the before filter. This spreads the access control handling to multiple places in the application, and again requires you to specifically whitelist all of the allowed actions.
ApplicationController.before_filter :require_login!
LoginController.skip_before_filter :require_login!
FooController.skip_before_filter :require_login!, \
except: [:index]
BarController.skip_before_filter :require_login!, \
except: [:baz]
Because a routing tree is directly executed, you can include arbitrary logic that affects routing. In this example, you have a routing tree that makes the list of albums available to everyone.
on 'albums' do
get 'list' do
view(:album_list)
end
if admin?
run AdminSite
end
end
For admins, it also routes other requests to an AdminSite rack application.
on 'albums' do
get 'list' do
view(:album_list)
end
if admin?
run AdminSite
end
end
In Sinatra, this is more difficult. You have to add routes for each request you want to support, with splats so that Sinatra will only do a prefix match on the request path.
get '/albums/list' do
erb(:album_list)
end
block = proc do
pass if admin?
e = env.dup
e['PATH_INFO']=e['PATH_INFO'].sub('/albums', '')
e['SCRIPT_NAME']=e['SCRIPT_NAME'] + '/albums'
AdminSite.call(e)
end
get '/albums/*', &block
post '/albums/*', &block
You then have the block pass if it isn’t an admin request.
get '/albums/list' do
erb(:album_list)
end
block = proc do
pass if admin?
e = env.dup
e['PATH_INFO']=e['PATH_INFO'].sub('/albums', '')
e['SCRIPT_NAME']=e['SCRIPT_NAME'] + '/albums'
AdminSite.call(e)
end
get '/albums/*', &block
post '/albums/*', &block
If it is an admin request, you need to create a new environment to call the AdminSite rack app with. Then you need to call the AdminSite rack app.|I’m sure this is possible in Rails too, but in the interests of time I won’t provide an example.
get '/albums/list' do
erb(:album_list)
end
block = proc do
pass if admin?
e = env.dup
e['PATH_INFO']=e['PATH_INFO'].sub('/albums', '')
e['SCRIPT_NAME']=e['SCRIPT_NAME'] + '/albums'
AdminSite.call(e)
end
get '/albums/*', &block
post '/albums/*', &block
These improvements may seem small, but taken together, they can result in much simpler applications. While it is possible to eliminate the redundant code without a routing tree using before filters, in most Sinatra applications I’ve looked at, that isn’t done as it isn’t natural. The usual case is code is just copied into all routes that need it.|This doesn’t surprise me, because when I was writing Sinatra applications, that’s what I would do. Using a separate before filter for every set of GET and POST routes, while possible in Sinatra, feels unnatural.
I analyzed one of the applications I’ve worked on a couple years, which is a process automation system my office uses. This application was originally built using Sinatra, and it was switched to Roda last year.|When it was using Sinatra, it had redundant code in most of the routes. When I switched it to a using a routing tree, I was able to eliminate the redundant code by moving it up to the highest enclosing branch where it was shared by all the routes.
Currently, the application has 79 total routes.
To get to those 79 routes, there are a total of 36 branches in the routing tree where the branch contains multiple routes.
Of those 36 branches containing multiple routes, 25 contain code that is shared by all routes under the branch. In most cases, the code that is shared is either retrieving objects from the database or enforcing access control.
That means 70% of the time there are branches in the routing tree, the integration of routing and request handling is resulting in the elimination of redundant code.|It also means that if I wanted to eliminate the same redundant code in Sinatra or Rails, I would have to add 25 separate before filters.
Using a routing tree makes sharing code for all routes under a branch natural, so web applications that use a routing tree tend to avoid redundant code naturally. Using before filters to eliminate redundant code isn’t natural in most other web frameworks, so even though it is possible, it often isn’t done, and the natural approach leads to redundant code.
Note that in order to extract maximum benefit from a routing tree, you need to structure your paths in such a way that shared behavior can be determined before the entire path has been routed. These days, this type of path structure is pretty natural.
For example, if you structure paths like this, they are naturally routing tree friendly.
/albums/1
/albums/1/tracks
/albums/1/similar
That’s because as soon as the routing tree has routed the /albums/1 prefix, it can retrieve the album from the database, so all the routes under the /albums/1 branch can share it.
/albums/1
/albums/1/tracks
/albums/1/similar
However, if you use Rails 1 style /controller/action/id routes, you can’t derive as much benefit from a routing tree.
/albums/show/1
/albums/tracks/1
/albums/similar/1
This is because the segment containing the albums’s id appears at the end of the path, after the branching for the show/tracks/similar segments has already taken place.|I have multiple applications that were initially developed using pre-Rails 1.0, and were updated all the way to Rails 4.1 without changing the path structure, and when I switched them to using a routing tree, I still ended up with redundant code in many routes.
/albums/show/1
/albums/tracks/1
/albums/similar/1
You should keep these path structure considerations in mind if you are planning to convert an existing web application to use a routing tree web framework.|Obviously if you are creating a new application, or are willing to change the path structure of an existing application, you can design the paths to be friendly to a routing tree approach.
So far I’ve discussed what I think are the main advantages to using routing trees. However, I would be remiss if I did not mention that there is a tradeoff when using a routing tree approach.
And that tradeoff is the loss of route introspection. Because routes are not stored in a data structure when using a routing tree, since a routing tree is really just ruby code, you can’t introspect your routes like you can in most other ruby web frameworks.|In my applications, this doesn’t matter, but there are some applications that rely on introspection of the routes, and those would need to be handled differently when using a routing tree.
Again, this is something you need to keep in mind if you are converting an existing application to use a routing tree. You need to check that it is not relying on introspection of the routes, or provide an alternative if it is.
I’d like to finish up this presentation by reviewing the advantages I’ve found from using a routing tree approach.
First and most importantly, a routing tree approach makes it simple and natural to eliminate redundant code.|This make not seem like such a big deal, but when converting applications with redundant code to a routing tree approach, I noticed that there were unintended differences in the redundant code, because changes were made to only one of the routes when it should have been made to all routes under a given branch.|By using a routing tree, you eliminate the redundant code, and you only need to make changes in one place to have it affect all related routes.
By eliminating redundancy in your code, the code becomes easier to read and understand, which makes maintenance easier.|I have been the only programmer where I work for over 12 years, and have continually maintained multiple applications for over a decade, so ease of maintenance is very important to me.
In most cases, moving to a routing tree approach, especially using Roda, will improve performance of your application. This isn’t just because Roda is fast at routing, but also because it has lower per-request overhead.
If you are interested in using a routing tree, I recommend checking out Roda. It’s the fastest and most featureful ruby web framework that uses a routing tree.|It has an very small core, but it ships with plugins for a wide variety of use cases. These plugins add support for simple features like rendering templates and JSON, to more advanced features like template streaming, asset packaging, and sending emails using a routing tree. The plugins are tested along side the core codebase, to ensure that they continue to work as Roda evolves.
If you are interested in learning more about Roda, please visit the website at roda.jeremyevans.net, hop on IRC, post on the Google Group, or just come talk to me.
That concludes my presentation. I want to thank you all for listening to me talk about routing trees and other approaches to routing.
If you have any questions, I’ll be happy to answer them now.