Cascading View Paths for Fun and Profit

posted by
mike

We built an iPhone site and an accompanying app for a major Canadian sports broadcaster, and after we launched it, we wanted to add functionality so that any mobile device could log on and be presented with the content in a way appropriate to the unique characteristics of that device. Screen size, resolution, HTML support, etc.

One Rails feature that really helped us along was the view path system in ActionController::Base. To put it simply, view_paths is an array that contains a list of paths in which to look for templates. If a template isn’t found in the first path, it continues onto the next path until it reaches the end, at which point a “missing template” exception will be thrown. By default, this array has one element, RAILS_ROOT + '/app/views', but you can use prepend_view_path and append_view_path to add more paths to the list.

We took advantage of this cascading view path system to create a tree of templates that can fall back to lower-priority paths to display pages to the client in a way custom tailored to their device, while at the same time keeping things very DRY. We do this by detecting the remote device type at request time, and then categorizing the client into what we call “device groups”. Each device group has a special view path that contains the views tailored to their device type, but we only wrote custom templates where required. Where the default template sufficed, we allowed the view path to fall back to a lower priority path.

For example, an iPhone connects and is given the default view path. But when a Moto RAZR connects and is classified as a “narrow” device, we prepend blackberry_views, then wap_views, then narrow views. The Blackberry views directory is our base view path for non-iPhone mobile devices, but some of its templates have too much data for a small WAP phone, so we prepend wap_views ahead of it. Along the same lines, a WAP device which has a particularly narrow view, like the RAZR, needs some further templates overridden so that things look just right, so we prepend narrow_views as well.

This system worked great for our purposes, but there’s one gotcha. The view_paths array looks just like an array, and the paths contained therein look like regular strings. And you can prepend/append strings to the view paths array:

>> view_paths
=> ["/Users/mferrier/dev/thescore/app/views"]
>> prepend_view_path RAILS_ROOT + "/app/views/narrow_views"
=> ["/Users/mferrier/dev/thescore/app/views/narrow_views", "/Users/mferrier/dev/thescore/app/views"] <br/>

But upon closer examination, these aren’t just regular arrays and strings:

>> view_paths.class
=> ActionView::PathSet
>> view_paths.first.class
=> ActionView::PathSet::Path <br/>

Interesting. But why should you care? Here’s why:

#from vendor/rails/actionpack/lib/action_view/paths.rb:99
def templates_in_path
  (Dir.glob("#{@path}/**/*/**") | Dir.glob("#{@path}/**")).each do |file|
    unless File.directory?(file)
      yield Template.new(file.split("#{self}/").last, self)
    end
  end
end <br/>

Oof. Adding strings to the view_paths causes filesystem traversal… expensive stuff. In fact, in the same file, a kind Rails contributor has alerted us about this very issue:

#from vendor/rails/actionpack/lib/action_view/paths.rb:3
def self.type_cast(obj)
  if obj.is_a?(String)
    if Base.warn_cache_misses && defined?(Rails) && Rails.initialized?
      Base.logger.debug "[PERFORMANCE] Processing view path during a " +
        "request. This an expense disk operation that should be done at " +
        "boot. You can manually process this view path with " +
        "ActionView::Base.process_view_paths(#{obj.inspect}) and set it " +
        "as your view path"
    end
    Path.new(obj)
  else
    obj
  end
end <br/>

If we follow this advice and call ActionView::Base.process_view_paths and pass it an array of the paths where our views reside, it will return some nice preprocessed ActionView::PathSet::Path objects. By processing these at boot time and then sticking them in memory, we can use these instead during the request and can avoid those costly filesystem traversals:

class ApplicationController
  cattr_accessor :preprocessed_pathsets
  # preprocess some pathsets on boot. doing pathset generation during a 
  # request is very costly.
  @@preprocessed_pathsets = begin
    %w(blackberry touchphone narrow wap).inject({}) do |pathsets, device_group|
      path = ApplicationController.device_view_path(device_group)
      pathsets[path] = ActionView::Base.process_view_paths(path).first
      pathsets
    end
  end
end <br/>

Yup, good times.