Friday, September 7, 2007

Ruby on Rails: Reducing clutter in actions by placing common code in filters

This is a tiny but useful tip, that saved many lines of repeated code in my controllers, hence why not share it :)

Do Not Repeat Thyself?

If you've looked at the controller code that's generated by scaffolding, you'll find something like this:

 
  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def destroy 
    User.find(params[:id]).destroy
  end

Sure, in this case all we are doing repeatedly is instantiating the user by a potentially available parameter value. What if instead we placed these common fetches in a controller filter, which would simply set an instance variable for us? Hell, we could even handle exceptions (such as invalid ID) in only one place this way! What not to like.

When dealing with a more complicated route that has been defined, this instantiation may become quite a bit more elaborate, and the case for a filter is even more justified.

Consider the case of building a collaboration system where you have projects and individual contributions under that project, as well as a producer of the project. We might want to support all project operations under a URL that looks kind of like this:

/user/kigster/projects/MyVacation/contribution/view/34 with a corresponding route in the config/routes.rb file:
# project management route
map.connect '/user/:username/project/:project_name/contribution/:action/:id',
    :controller     => "contribution"

Based on the route defined, Rails would create params[:username], params[:project_name] and params[:id] for contribution id, in addition to the standard action/controller pair.

Now imagine that the controller we are writing has many actions, such as add, edit, view, list, append, preview, post, comment, etc. All of them could use a handle on the project, it's producer and contribution instances. Ideally - in @project, @producer_user and @contribution respectively.

If we followed the scaffolding example, we'd simply add appropriate lookups at the beginning of each action. But that's a lot of repeated code!

So let's use filters instead, to get our common lookups under control.

class ContributeController < ApplicationController
  before_filter :setup_project

  def home
     # do stuff
  end

  private

  def setup_project
    @producer_user = 
      User.find_by_username(params[:username])
    @project = 
      @producer_user.produced_projects.detect do |p|
      p.name.downcase.eql?(params[:project_name].downcase)
    end
    @contribution = 
        @project.contributions.find_by_id(params[:id])

    return true

  rescue Exception => e
    logger.info "can't show project:", e
    flash[:error] = e.message if e
    redirect_to :controller => "home" and return
  end
end    

This is short and elegant, and now every action in this controller (which is not excluded for setup_project filter) will have access to our instance variables. Groovy!

Issues

Of course nothing is free, and in this case we are potentially loosing performance. Perhaps some actions don't need to know the @producer_user. We'll be fetching it all the time, which saves time for us - developers. If performance problems occur because of extra unused fetches the filter can be broken up or optimized. Until then - it sure saves me a headache.

That's it!

Thoughts, comments?

3 comments:

Anonymous said...

You can pass in the :only command on the filter like so:

before_filter :setup_project, :only => [:show, :edit, :update, :destroy]

brucek said...

In the RoR Wiki (http://wiki.rubyonrails.org/rails/pages/TipsAndTricks at the bottom)
they say:
"There’s a growing trend to abuse before_filter in controllers to load data for multiple actions. This is not what before_filter is for, and using it this way makes code harder to read and maintain as well as negating the benefits of controller action caching (and possibly other current or future Rails features).

As a rule, use before_filter only for actual filtering, and always use before_filter for code that does do filtering. When you want to factor out common initialization code, simply call the shared initialization method at the start of each action method that needs it."

Who's right?

Waheed Barghouthi said...

could you please change the voting block on your blog, it shows an incorrect results in total percentage,


Votes results below

Mac OS-X: 19 (54%)

Windows: 11 (31%)

Linux: 17 (48%)

FreeBSD: 1 (2%)

Total: 48 (135%)