TiC
Wed, Nov 03, 2010
Coding an "acts_as" Gem for Rails 3

A few days ago, I was idly picking through my site statistics (being the numbers junky that I am). I tend to do this pretty frequently – sometimes several times in a day – but it had been some time because I’ve been so distracted with my WebGL tests lately.

So there I was, skimming through the list of HTTP referrers. I do that periodically, and it shows me where traffic is coming from. If there’s a blog post, for instance, and I don’t get a pingback from it, then this is another way for me to pick up on those posts, head over to the other site and see what I said that someone actually found interesting.

As I said, I do this pretty regularly, so I get used to seeing the same old list of referrers (mostly search engines, crawlers, spambots and RSS readers). There had already been one from Stack Overflow dating to the beginning of the month, so I didn’t notice right away when a second Stack Overflow link showed up. Eventually, I expanded the entire list of referrers (which is a step I actually take pretty rarely), and this time I noticed the new referrer.

Whenever someone mentions my blog, to me it’s a Really Big Deal. That’s why I’m so anal about the referrers. I don’t want to miss a chance to thank someone. Every link is free publicity, and it’s coming from someone who really means what they say – they aren’t being paid to say it. So I value that.

In this case, it was a question about how to code a gem. Apparently, cbrulak had garnered some information from my blog (hence the link) but it wasn’t quite enough to suit his needs. I noted that no one had answered his question, and it just happened to be something I’ve done before (OK, not exactly, but very similarly). So I answered.

Now, for both posterity and easier Google access, I’m more or less copying what I wrote to my blog. We’ve gone full circle!

Get to the code, already!

Fine – if that didn’t interest you then I’m sorry. But I’d be remiss if I hadn’t at least given some background for such an epic story as this.

In any case, in this article we’ll discuss some general concepts about how to write an “acts_as_awesome” gem for your Rails 3 app. It should be noted that this gem won’t actually be something you’d want to use for anything in particular – it’s here as a demonstration to help you get started with your own acts_as gem.

Tie the Rails

The first thing to get familiar with for any Rails 3 gem is the new Railtie class. See the documentation for it at api.rubyonrails.org/classes/Rails/Railtie.html. Railtie is actually very cool, and I think it’s going to be a huge advancement over the Rails 2 style of coding gems, once people like yourself learn the ins and outs of them.

To summarize, a Railtie is a sort of configurator for your gem. You can use it to add view paths, add load paths, add generators, access a Rails application’s specific configuration, set up ORM, or any other awesome thing you can think of. In this case, we’re going to use it to inject some code into ApplicationController. Note that the code we actually inject isn’t the code that would make the ApplicationController “awesome”; it is, instead, an acts_as_awesome method which will provide a hook into that very functionality.

The reason for adding this extra step is so that a user can explicitly call acts_as_awesome on the controllers that they want to be “awesome”, while other controllers can remain non-awesome.

If the above was confusing, re-read it in less generic terms: we’re going to have our Awesome functionality add a before filter to affected controllers. However, we don’t necessarily want all controllers to have the before filter; we want to let the user decide which controllers should have the before filter, and leave all other controllers unaffected. That’s why we have a hook method like acts_as_awesome; if the user never calls the hook method, then the controller is never given the before filter, and the controller itself remains unchanged.

Finally, the above paragraph in code:

1
2
3
4
5
6
7
8
class AwesomeController < ApplicationController
  # We call acts_as_awesome to make it awesome.
  acts_as_awesome
end
class NotAwesomeController < ApplicationController
  # We don't call acts_as_awesome, so it isn't awesome.
  # Easy as that.
end

OK, enough dilly-dallying. Here’s the Railtie which will inject our hook method into ApplicationController:

1
2
3
4
5
6
7
8
9
10
# lib/acts_as_awesome/railtie.rb
require 'rails'
require 'acts_as_awesome'
module ActsAsAwesome
  class Railtie < Rails::Railtie
    config.to_prepare do
      ApplicationController.send(:extend, ActsAsAwesome::Hook)
    end
  end
end

The to_prepare block is executed once in production mode, but is executed before every request in development mode, just after the classes are reloaded. What you don’t want, in this instance, is to use an initializer, which only happens once. This is an important distinction because when Rails reloads ApplicationController, our hook method will be wiped out and we’ll need to re-include it. If you ever get strange issues indicating that a method doesn’t exist when you think it should, you may want to check whether you’re using to_prepare or an initializer. If it’s the latter, your changes were likely erased between requests. A tell-tale sign is that the app works fine in production mode (because reloading is disabled), but works on only the first request in development mode. Subsequent requests would raise odd errors, usually involving a missing method or an “expected to define” message.

The Hook

Now, the Railtie is going to tell ApplicationController to extend ActsAsAwesome::Hook. This is to make the method a class method. If we wanted an instance method, (that is, one that we can call while using the ApplicationController rather than while loading it), then we’d include ActsAsAwesome::Hook instead.

And the Hook module? Here she is:

1
2
3
4
5
6
7
8
9
# lib/acts_as_awesome/hook.rb
module ActsAsAwesome::Hook
  def acts_as_awesome(*args)
    options = args.extract_options!
    # now do the things that make the controller awesome.
    include ActsAsAwesome::InstanceMethods
    before_filter :an_awesome_filter
  end
end

Notice that our hook method accepts any number of arguments. You’d probably want to tweak this for your own gem depending on your constraints, but having the variable arguments allows us a lot of flexibility when it comes to adding new functionality on top of it.

As a side note, extract_options! is a really useful ActiveSupport method which you can call on any Array. It will find the last instance of Hash in the given array, remove it, and return it. If there is no such Hash, then an empty Hash is returned. Rails uses this method liberally for handling options to, for instance, render – and since I discovered it (by reading the Rails 2.x source, no less!) I’ve begun to use it just as often. That method alone makes an ActiveSupport dependency totally worth it.

The rest of the method should be fairly straightforward; it includes another module, InstanceMethods, and then adds the before filter we discussed earlier. The an_awesome_filter method represented by the filter is actually defined in the InstanceMethods module.

It’s important to note that this is evaluated directly within the context of the receiving controller. That is, the call to before_filter actually calls AwesomeController#before_filter. The module to be included is actually included directly into AwesomeController. We don’t have to know that the code is running in the context of a controller; we can just assume as much.

This is also really nice for the user because if they want, for some reason, to use our gem without the corresponding controller, all they have to do is define their own version of before_filter (which could be anything from a no-op to some really complicated, proprietary code) and they’re good to go. This is where Ruby’s dynamic typing really begins to shine. Let’s see Brand X (heh) do that!

The InstanceMethods

OK, back on track. I apologize for being so wordy today; it’s probably because it’s been the better part of a month since my last post.

Here’s the ActsAsAwesome::InstanceMethods module that the Hook module relies upon:

1
2
3
4
5
6
# lib/acts_as_awesome/instance_methods.rb
module ActsAsAwesome::InstanceMethods
  def an_awesome_filter
    render :text => "I'm awesome!"
  end
end

This should be so easy to understand, it should be scary. We’ll define a mix-in method called an_awesome_filter, which does one simple thing: it renders “I’m awesome!” for every single request. Not very useful, but it totally demonstrates this article’s namesake. Obviously, your own code should do something far more awesome.

Finally, we need to tie it all together. Here’s the source for the main gem dependency, which loads all other Ruby files in this gem:

1
2
3
4
5
6
7
# lib/acts_as_awesome.rb
require 'active_support/core_ext'
require File.join(File.dirname(__FILE__), "acts_as_awesome/railtie")
module ActsAsAwesome
  autoload :Hook,            File.join(File.dirname(__FILE__), "acts_as_awesome/hook")
  autoload :InstanceMethods, File.join(File.dirname(__FILE__), "acts_as_awesome/instance_methods")
end

Once again, pretty straightforward, yes? Still, here’s the play-by-play:

First, we require the ActiveSupport core extensions. This includes our lovely extract_options! method. Note that we require the core extensions directly, rather than requiring all of ActiveSupport. The fact is, we don’t need all of ActiveSupport, and it’s a fair amount of bloat if you don’t plan to use it. Whether it will be used by Rails is not the point; our gem doesn’t rely on it, so we shouldn’t require it. What if someone wants to use the gem outside of a Rails environment? It’d be nice not to have such a beautiful piece of work become hideous due simply to requiring tons of unused libraries.

Here’s a pointer: treat the Rails framework itself just like any other gem. It just happens to require your gem as a dependency. Does that mean you should assume that it’s going to need tons of other files? Of course not. If you don’t need them, then don’t require them.

The next thing we do is require our Railtie explicitly. This puzzles me, because the documentation (linked above) reads as if it should find and load the Railtie automatically. However, it very rarely works this way for me. So here’s my line of thought: first of all, if it’s not loading it automatically, we have no choice. Second, if it does load the Railtie automatically, we lose nothing because require will only load a file once. Third, the file must be loaded for our gem to work at all. All together, I really have no problem throwing the extra line into my source.

Note that the remainder of our files are loaded via autoload. There are arguments for and against this, but personally I use it because it defers loading of the rest of our library until it’s actually being used. That means, if a controller never calls the acts_as_awesome method, we’ll never need to bloat the user’s application any further than the hook method itself.

Of course, the hook method will always be required by virtue of our injecting it into ApplicationController, so this is a bit of a question mark. I used autoload just for consistency’s sake. It does help if the user’s application never fires our to_prepare block, though that’s an unlikely scenario.

So, this concludes our simple ActsAsAwesome gem. You can view the full source code of the gem at github.com/sinisterchipmunk/acts_as_awesome. If you have any questions, don’t be afraid to drop me a line. Until next time!

1 comment
Gravatar
Eugene
Great article. I like this clean approach to creating the gem using railtie. I am struggling with something though, was hoping maybe you could help. In my gem I'm making an acts_as gem for the model. So that after_save gets called I can perform my functionality on the object. The trouble I'm having is I'm trying to take params passed into the Hook (*args) and pass them as params to the after_save method call. For example options = args.extract_options! asset_type = options[:type] include ActsAsAsset::InstanceMethods after_save 'save_asset asset_type' I get undefined local variable. How is that variable not in scope? Any help would be much appreciated. Thanks!!
Please log in if you wish to leave a comment.